Custom Hooks That Earn Their Keep

Design real, reusable custom hooks that hide messy logic behind a clean, honest API.

You already know a custom hook is just a function starting with use that calls other hooks. The senior skill is knowing what to extract and what shape to return so the hook feels like a tiny, dependable library β€” not a leaky abstraction.

What makes a good custom hook

  • One job. useDebouncedValue, useLocalStorage, useOnScreen β€” each does exactly one thing and names it well.
  • An honest return shape. Return a tuple when order is obvious (like useState), an object when there are several named things a caller might want.
  • Stable identities. If you return functions, wrap them so they do not change every render β€” callers will put them in dependency arrays.

The smallest useful example

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  return [on, toggle];
}

// const [isOpen, toggleOpen] = useToggle();

Notice the useCallback: toggle keeps the same identity across renders, so a memoized child or an effect depending on it will not churn.

Extract logic, not state

Every component that calls a hook gets its own independent state. Two components calling useToggle do not share a value β€” they share the behavior. That is the whole point, and it is why hooks replaced render props and HOCs for most logic reuse.

Example

Example Β· javascript
import { useState, useEffect, useRef, useCallback } from 'react';

// A genuinely reusable hook: read + write a value in localStorage,
// synced across renders and stable across the tab.
function useLocalStorage(key, initialValue) {
  // Lazy initializer: read storage exactly once, not on every render.
  const [value, setValue] = useState(() => {
    try {
      const raw = window.localStorage.getItem(key);
      return raw !== null ? JSON.parse(raw) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Keep the latest key in a ref so the writer effect stays stable.
  const keyRef = useRef(key);
  keyRef.current = key;

  useEffect(() => {
    try {
      window.localStorage.setItem(keyRef.current, JSON.stringify(value));
    } catch {
      // Storage full or blocked (private mode) β€” fail quietly.
    }
  }, [value]);

  // Support the functional-updater form, just like useState.
  const set = useCallback((next) => {
    setValue((prev) => (typeof next === 'function' ? next(prev) : next));
  }, []);

  return [value, set];
}

function ThemePicker() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return (
    <button onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>
      Theme: {theme}
    </button>
  );
}

When to use it

  • A team extracts all WebSocket reconnect and message queue logic into a useRealtimeChannel hook so feature components just call it and receive messages, unaware of the transport.
  • An e-commerce app isolates cart operations (add, remove, persist to localStorage) in a useCart hook so the cart badge, product page, and checkout all share the same logic.
  • An authentication module exposes a useAuth hook returning { user, login, logout, isLoading } so every protected component gets a clean, typed API with no knowledge of the token storage mechanism.

More examples

useDebounce hook

Returns a version of value that only updates after the user stops typing for delay ms β€” a single-job hook with a minimal, obvious API.

Example Β· jsx
import { useState, useEffect } from 'react';

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// Usage
// const debouncedQuery = useDebounce(query, 400);

useAsync for promise management

Wraps any async function and exposes status, data, error, and execute β€” hiding all state management from the caller.

Example Β· jsx
import { useState, useCallback } from 'react';

function useAsync(asyncFn) {
  const [state, setState] = useState({ status: 'idle', data: null, error: null });

  const execute = useCallback((...args) => {
    setState({ status: 'pending', data: null, error: null });
    return asyncFn(...args)
      .then(data => setState({ status: 'success', data, error: null }))
      .catch(error => setState({ status: 'error', data: null, error }));
  }, [asyncFn]);

  return { ...state, execute };
}

// const { status, data, execute } = useAsync(fetchUser);

useOnClickOutside hook

Subscribes to global pointer events and calls the handler when a click lands outside the referenced element.

Example Β· jsx
import { useEffect } from 'react';

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = e => {
      if (!ref.current || ref.current.contains(e.target)) return;
      handler(e);
    };
    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);
    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}

// const ref = useRef(); useOnClickOutside(ref, closeMenu);

Discussion

  • Be the first to comment on this lesson.