GitHub

Custom Hooks

The boundary between logic and rendering. When to extract a hook, what the rules are, and how to avoid the most common mistake.

01

Principle

A custom hook is not just a function that starts with 'use' — it is a boundary between logic and rendering. The component handles what the user sees. The hook handles how data gets there. When you separate these two concerns, components become easier to read, logic becomes easier to test, and both become easier to change independently.

lightbulb

If you would write a unit test for the logic, it belongs in a hook. If you would write a component test for it, it belongs in the JSX.

02

Rules

  • check_circle
    Name starts with 'use'This is not just a convention — React uses it to enforce the rules of hooks. A function starting with 'use' is treated as a hook.
  • check_circle
    Extract when logic repeatsIf the same stateful logic appears in two components, extract it to a hook. Do not copy-paste hooks between components.
  • check_circle
    Extract when logic is complexIf a component has more than one useEffect, multiple useState calls, or complex derived state — that logic belongs in a hook.
  • check_circle
    Hooks are not global stateEach component that calls a hook gets its own isolated instance. Hooks do not share state between components unless backed by a store or context.
03

Pattern

hooks/useDebounce.ts — logic extracted from component
// ❌ Before — logic mixed into component
function SearchInput() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);
    return () => clearTimeout(timer);
  }, [query]);

  // Component is doing too much
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// ✅ After — logic extracted to a hook
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState<T>(value);

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

  return debounced;
}

// Component is now focused on rendering only
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
04

Implementation

info

Version Compatibility

Requires React 19+ and the latest stable versions of all dependencies shown.

In Next.js, hooks can only run in Client Components. If a hook uses browser APIs, add 'use client' to the component that calls it — not to the hook file itself.

shared/hooks/useDebounce.ts
// The hook itself has no 'use client' — it is framework-agnostic
import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState<T>(value);

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

  return debounced;
}

// The component that calls it gets 'use client'
// features/cookbook/components/SearchInput.tsx
'use client';

import { useDebounce } from '@/shared/hooks/useDebounce';

export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    onSearch(debouncedQuery);
  }, [debouncedQuery, onSearch]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      placeholder="Search recipes..."
    />
  );
}
menu_book
React Patterns

Helping developers build robust React applications since 2025.

© 2025 React Patterns Cookbook. Built with ❤️ for the community.
react-principles