Custom Hooks: A Diary of Getting Less Terrible

By Odilon15 min read

Custom Hooks: A Diary of Getting Less Terrible#

Week 1: my first custom hook was terrible. Week 3: slightly less terrible. Month 2: I think I actually get it.

Every blog post about custom hooks shows the final, polished version. Nobody shows the messy middle — the hooks that leaked memory, the ones that triggered infinite re-renders, the ones that produced baffled code review comments.

Here's the real progression.

Week 1: The Terrible Version#

Before hooks, sharing stateful logic meant higher-order components or render props. Both work. Both add indirection that makes you want to close your laptop. Hooks changed this -- stateful logic became a first-class thing you could extract like a regular function.

A custom hook is any function that starts with use and calls at least one React hook. That's the entire definition. No special API, no registration, no inheritance.

My first attempt was a useWindowTitle hook. Simple enough:

typescript
// This was fine actually. Beginner's luck.
function useWindowTitle(title: string) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;
    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

// Usage
function ProductDetailPage({ product }: { product: Product }) {
  useWindowTitle(`${product.name} — My Store`);
  return <div>{/* product content */}</div>;
}

OK, that one was easy. The use prefix isn't just convention -- React's linting rules use it to enforce that hooks only get called at the top level of components and other hooks. Name them well: useFetch, useDebounce, useLocalStorage.

The problems started with my second hook.

Week 2: The Debounce Disaster#

EDIT (2021-05-20): Updated the useDebounce example below -- the original had a subtle memory leak. The cleanup function wasn't clearing the timeout when the component unmounted between timer ticks. Leaving the broken version here for educational purposes.

My first attempt at debounce:

typescript
// DON'T DO THIS -- the version I shipped initially
function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delayMs);
    // I forgot the cleanup. Classic.
  }, [value, delayMs]);

  return debouncedValue;
}

The fix is obvious when you see it:

typescript
// The fixed version
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delayMs);

    return () => clearTimeout(timer); // cleanup! always cleanup!
  }, [value, delayMs]);

  return debouncedValue;
}

// Usage: search that only fires after typing stops
function ProductSearch({ onResults }: { onResults: (products: Product[]) => void }) {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (!debouncedQuery.trim()) return;
    fetch(`/api/products/search?q=${encodeURIComponent(debouncedQuery)}`)
      .then((res) => res.json())
      .then(onResults);
  }, [debouncedQuery, onResults]);

  return (
    <input
      type="search"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search products..."
    />
  );
}

The lesson: every setTimeout inside a useEffect needs a cleanup. Every subscription needs an unsubscribe. Every event listener needs a removal. This seems obvious written down. I promise you'll forget it at least once.

Week 3: useLocalStorage (Getting Slightly Better)#

This one I'm actually somewhat proud of. State that survives page reloads without a backend:

typescript
import { useState } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      // parse failed, probably corrupted data
      return initialValue;
    }
  });

  const setValue = (value: T) => {
    try {
      setStoredValue(value);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(value));
      }
    } catch (error) {
      console.error(`useLocalStorage: failed to set "${key}"`, error);
    }
  };

  return [storedValue, setValue];
}

// Usage: remember user's table preferences
function OrdersTable() {
  const [pageSize, setPageSize] = useLocalStorage<number>('orders-page-size', 25);
  const [sortField, setSortField] = useLocalStorage<string>('orders-sort', 'createdAt');

  return (
    <div>
      <select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
        <option value={10}>10 per page</option>
        <option value={25}>25 per page</option>
        <option value={50}>50 per page</option>
      </select>
      {/* table content */}
    </div>
  );
}

The lazy initializer in useState was key -- you don't want to read localStorage on every render, just the first one. And that typeof window guard? Learned that the hard way when Next.js tried to run it on the server. Instant crash.

Month 1: useFetch (Ambitious. Too Ambitious.)#

I thought "I'll build my own useFetch, how hard can it be?" Pretty hard, it turns out. But the exercise taught me more about hooks than anything else.

typescript
import { useState, useEffect, useRef } from 'react';

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

export function useFetch<T>(url: string | null): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
  const abortControllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    if (!url) {
      setState({ status: 'idle' });
      return;
    }

    // Cancel any in-flight request from a previous render
    abortControllerRef.current?.abort();
    abortControllerRef.current = new AbortController();

    setState({ status: 'loading' });

    fetch(url, { signal: abortControllerRef.current.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ status: 'success', data }))
      .catch((error: unknown) => {
        if (error instanceof Error && error.name === 'AbortError') return;
        setState({ status: 'error', error: error instanceof Error ? error : new Error(String(error)) });
      });

    return () => {
      abortControllerRef.current?.abort();
    };
  }, [url]);

  return state;
}

// Usage
function CustomerProfile({ customerId }: { customerId: string }) {
  const state = useFetch<Customer>(`/api/customers/${customerId}`);

  if (state.status === 'loading') return <p>Loading...</p>;
  if (state.status === 'error') return <p>Error: {state.error.message}</p>;
  if (state.status === 'idle' || state.status !== 'success') return null;

  return (
    <div>
      <h2>{state.data.name}</h2>
      <p>{state.data.email}</p>
    </div>
  );
}

In production? Reach for React Query instead. But building this teaches you about abort controllers, cleanup patterns, and discriminated unions for state machines. The FetchState<T> type is something worth carrying everywhere — a clean state machine prevents entire categories of bugs.

Month 2: Composing Hooks Together#

This is where things clicked. Custom hooks can call other custom hooks. This is where the composability shines:

typescript
// A debounced search that only runs when the query is non-empty
export function useDebouncedSearch<T>(
  fetcher: (query: string) => Promise<T[]>,
  debounceMs = 300
) {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, debounceMs);
  const [recentSearches, setRecentSearches] = useLocalStorage<string[]>('recent-searches', []);

  const url = debouncedQuery.trim().length >= 2
    ? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
    : null;

  const fetchState = useFetch<T[]>(url);

  function saveToRecent(term: string) {
    setRecentSearches((prev) =>
      [term, ...prev.filter((s) => s !== term)].slice(0, 10)
    );
  }

  return { query, setQuery, fetchState, recentSearches, saveToRecent };
}

useDebouncedSearch calls useDebounce, useLocalStorage, and useFetch. Three hooks composed into one. The HOC/render prop patterns never achieved this level of clean composition.

Beyond the Basics: useMediaQuery#

Sometimes you need to respond to media queries in JavaScript, not just CSS:

typescript
import { useState, useEffect } from 'react';

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState<boolean>(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    if (typeof window === 'undefined') return;
    const mediaQuery = window.matchMedia(query);
    setMatches(mediaQuery.matches);

    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

function NavigationBar() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

And useIntersectionObserver#

Lazy loading and infinite scroll both need to know when an element enters the viewport:

typescript
import { useRef, useState, useEffect } from 'react';

interface UseIntersectionObserverOptions {
  threshold?: number;
  rootMargin?: string;
  once?: boolean;
}

export function useIntersectionObserver<T extends Element>(
  options: UseIntersectionObserverOptions = {}
): [React.RefObject<T | null>, boolean] {
  const { threshold = 0, rootMargin = '0px', once = false } = options;
  const ref = useRef<T>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsIntersecting(entry.isIntersecting);
        if (once && entry.isIntersecting) {
          observer.unobserve(element);
        }
      },
      { threshold, rootMargin }
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [threshold, rootMargin, once]);

  return [ref, isIntersecting];
}

// Lazy-load heavy chart only when visible
function AnalyticsDashboard() {
  const [chartRef, isVisible] = useIntersectionObserver<HTMLDivElement>({ once: true });

  return (
    <div ref={chartRef} style={{ minHeight: '400px' }}>
      {isVisible ? (
        <RevenueChart />
      ) : (
        <div className="chart-skeleton" />
      )}
    </div>
  );
}

Testing Custom Hooks#

React Testing Library's renderHook makes this straightforward:

typescript
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './use-debounce';

describe('useDebounce', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it('returns the initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('hello', 300));
    expect(result.current).toBe('hello');
  });

  it('does not update before the delay', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'hello' } }
    );

    rerender({ value: 'world' });
    vi.advanceTimersByTime(200);
    expect(result.current).toBe('hello');
  });

  it('updates after the delay', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'hello' } }
    );

    rerender({ value: 'world' });
    act(() => vi.advanceTimersByTime(300));
    expect(result.current).toBe('world');
  });
});

Common Pitfalls#

Here's what I've learned the hard way over the past couple months:

Not starting the name with use. React's linting tools won't recognize your function as a hook, and they won't enforce the rules of hooks inside it. Always prefix with use. I had a createDebounce utility once that broke everything because I used useState inside a non-hook function.

Returning unstable references. If your hook returns a new object on every render, you'll cause unnecessary re-renders everywhere it's used:

typescript
// BAD: new object every render
function useFilters() {
  const [filters, setFilters] = useState(['active']);
  return { filters, setFilters }; // fresh object every time
}

// BETTER: memoize the return value
function useFilters() {
  const [filters, setFilters] = useState(['active']);
  return useMemo(() => ({ filters, setFilters }), [filters]);
}

200-line hooks. I've written them. They're terrible. A hook that handles a dozen concerns is just as hard to reason about as a component that does everything. Keep hooks focused. Compose them.

Forgetting the server-side case. Hooks like useLocalStorage and useMediaQuery access window. It doesn't exist on the server. Guard with typeof window !== 'undefined' or your Next.js app will explode on first render.

The trajectory is clear: custom hooks are the feature that made React genuinely exciting again after years of class components. If you find yourself copying a group of hooks from one component to another, that's a custom hook waiting to be extracted. If a component has 5+ unrelated useEffect calls, break each concern into its own hook.

The hooks get less terrible every week. That's the bar.