Skip to content

Custom Hooks Design Patterns

intermediate14 min read

Custom Hooks Extract Reusable Logic

Once you get comfortable with hooks, you'll start noticing the same patterns showing up across components. A custom hook is simply a function that starts with use and calls other hooks. It extracts logic that multiple components share — without sharing state. Each component that calls the hook gets its own independent copy.

// Repeated logic in two components:
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => { /* fetch user */ }, []);
  // ... same pattern in 10 more components
}

// Extracted into a custom hook:
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    setLoading(true);

    fetch(url)
      .then(res => res.json())
      .then(data => { if (!ignore) { setData(data); setLoading(false); } })
      .catch(err => { if (!ignore) { setError(err); setLoading(false); } });

    return () => { ignore = true; };
  }, [url]);

  return { data, loading, error };
}

// Clean usage:
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
}
Mental Model

Think of a custom hook as a recipe card that you can hand to any cook (component). The recipe describes the steps (hooks to call, logic to run) but each cook has their own ingredients (independent state). Giving the same recipe card to two cooks produces two independent meals — they do not share a pot. Custom hooks share logic, not state.

Naming Conventions

// Hook that returns state: use + noun
function useOnlineStatus() { /* ... */ }
function useWindowSize() { /* ... */ }
function useLocalStorage(key) { /* ... */ }

// Hook that handles a behavior: use + verb/action
function useFetch(url) { /* ... */ }
function useDebounce(value, delay) { /* ... */ }
function useClickOutside(ref, handler) { /* ... */ }

// Hook that combines both: descriptive phrase
function useFormValidation(schema) { /* ... */ }
function usePaginatedQuery(url) { /* ... */ }

The use prefix is not just convention — it tells React's linter to enforce the Rules of Hooks inside the function, and it tells developers that calling order matters.

Return Value Patterns

Tuple (for single state + setter)

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle]; // Array destructuring allows custom names
}

const [isOpen, toggleOpen] = useToggle();
const [isActive, toggleActive] = useToggle(true);
function useFetch(url) {
  // ... fetch logic
  return { data, loading, error, refetch };
}

const { data, loading, error } = useFetch('/api/users');

When to Choose Which

PatternWhenWhy
Tuple [value, setter]Two values, mirroring useStateAllows renaming via destructuring
Object { data, loading }Three or more valuesNamed fields are self-documenting
Single valueOne computed valueSimplest possible API

Composition: Hooks That Use Hooks

This is where custom hooks get really powerful.

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

function useDebouncedSearch(query) {
  const debouncedQuery = useDebounce(query, 300); // Composes useDebounce
  const { data, loading, error } = useFetch(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  ); // Composes useFetch

  return { results: data, loading, error };
}

// Usage — clean, declarative:
function SearchPage() {
  const [query, setQuery] = useState('');
  const { results, loading } = useDebouncedSearch(query);
}

Each hook is a small, focused building block. Compose them into higher-level hooks that solve specific problems.

Pattern: Configuration Object

When your hook starts accepting more than 2-3 parameters, it's time to switch to a configuration object:

function useInfiniteScroll({
  fetchFn,
  pageSize = 20,
  threshold = 200,
  enabled = true,
}) {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(0);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore || !enabled) return;
    setLoading(true);
    const newItems = await fetchFn({ page: page + 1, pageSize });
    setItems(prev => [...prev, ...newItems]);
    setPage(p => p + 1);
    setHasMore(newItems.length === pageSize);
    setLoading(false);
  }, [loading, hasMore, enabled, page, pageSize, fetchFn]);

  useEffect(() => {
    if (!enabled) return;

    function handleScroll() {
      const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
      if (scrollHeight - scrollTop - clientHeight < threshold) {
        loadMore();
      }
    }

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, [loadMore, threshold, enabled]);

  return { items, loading, hasMore, loadMore };
}

Pattern: Hook with Reducer

For complex internal state, combine useReducer with the custom hook:

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

  const execute = useCallback(async (...args) => {
    dispatch({ type: 'PENDING' });
    try {
      const data = await asyncFn(...args);
      dispatch({ type: 'RESOLVED', data });
      return data;
    } catch (error) {
      dispatch({ type: 'REJECTED', error });
      throw error;
    }
  }, [asyncFn]);

  return { ...state, execute };
}

function asyncReducer(state, action) {
  switch (action.type) {
    case 'PENDING': return { status: 'pending', data: null, error: null };
    case 'RESOLVED': return { status: 'resolved', data: action.data, error: null };
    case 'REJECTED': return { status: 'rejected', data: null, error: action.error };
    default: return state;
  }
}
The useEvent pattern for stable callbacks

A common pattern in custom hooks: accepting a callback that should not be in the dependency array:

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

The ref holds the latest callback without adding it to the effect's deps. This prevents re-creating the interval every time the callback changes — only delay changes trigger a new interval.

function useForm({ initialValues, validate, onSubmit }) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [submitting, setSubmitting] = useState(false);

  const handleChange = useCallback((field, value) => {
    setValues(prev => ({ ...prev, [field]: value }));
    if (touched[field]) {
      const fieldError = validate?.({ ...values, [field]: value })?.[field];
      setErrors(prev => ({ ...prev, [field]: fieldError || '' }));
    }
  }, [values, touched, validate]);

  const handleBlur = useCallback((field) => {
    setTouched(prev => ({ ...prev, [field]: true }));
    const fieldError = validate?.(values)?.[field];
    setErrors(prev => ({ ...prev, [field]: fieldError || '' }));
  }, [values, validate]);

  const handleSubmit = useCallback(async (e) => {
    e?.preventDefault();
    const formErrors = validate?.(values) || {};
    setErrors(formErrors);
    setTouched(Object.fromEntries(Object.keys(values).map(k => [k, true])));

    if (Object.values(formErrors).some(Boolean)) return;

    setSubmitting(true);
    try {
      await onSubmit(values);
    } finally {
      setSubmitting(false);
    }
  }, [values, validate, onSubmit]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    submitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    getFieldProps: (field) => ({
      value: values[field],
      onChange: (e) => handleChange(field, e.target.value),
      onBlur: () => handleBlur(field),
    }),
  };
}
Common Trap

Custom hooks share logic, not state. If two components call useToggle(), they get independent toggle states. If you need shared state between components, use Context or an external store. A common mistake is expecting custom hooks to act like singletons.

What developers doWhat they should do
Creating hooks that do too much — 200+ lines with 10 state variables
Large hooks are hard to test, hard to reuse, and hard to understand. Break them into composable pieces.
Compose small, focused hooks. Each hook has one responsibility.
Expecting two components using the same hook to share state
Hooks are functions. Each call creates new state objects. There is no hidden singleton.
Each hook call creates independent state. Use Context or external stores for shared state.
Not memoizing callbacks returned from hooks
Without memoization, consumers get new function references every render, breaking their own memoization.
Wrap returned functions in useCallback if consumers might use them in deps or pass to memo components
Naming hooks without the 'use' prefix: fetchData(), toggle()
The 'use' prefix enables the linter to enforce Rules of Hooks. Without it, conditional calls and loop calls would not be caught.
Always prefix with 'use': useFetchData(), useToggle()
Quiz
What happens when two components call the same custom hook?
Quiz
Why should custom hook callbacks be wrapped in useCallback?
Quiz
When should you return a tuple [value, setter] vs an object { value, setter }?
Key Rules
  1. 1Custom hooks share logic, not state — each call creates independent instances
  2. 2Always prefix with 'use' — it enables linting for Rules of Hooks
  3. 3Compose small hooks into complex ones — each hook has one responsibility
  4. 4Return tuples for 2 values, objects for 3+ values
  5. 5Memoize returned callbacks with useCallback so consumers can depend on them safely

Challenge: Build a useMediaQuery Hook

Challenge: Media Query Subscription

// Build a custom hook that subscribes to a CSS media query
// and returns whether it currently matches.
// It should update when the viewport changes.

function useMediaQuery(query) {
  // Your implementation here
}

// Usage:
function Layout() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <div>
      {isMobile ? <MobileNav /> : <DesktopNav />}
    </div>
  );
}
Show Answer
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mql = window.matchMedia(query);

    function handleChange(e) {
      setMatches(e.matches);
    }

    // Set initial value (handles SSR hydration)
    setMatches(mql.matches);

    mql.addEventListener('change', handleChange);
    return () => mql.removeEventListener('change', handleChange);
  }, [query]);

  return matches;
}

Design decisions:

  • Lazy initializer for SSR compatibility (returns false on server)
  • Updates on change event, not on resize (more efficient — matchMedia fires only when the query crosses the threshold)
  • Effect re-runs when query changes (different media query string)
  • Returns a simple boolean — the simplest possible API
  • Could also use useSyncExternalStore for concurrent safety