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 that use browser APIs need 'use client'. Feature-specific hooks live in the feature folder, shared hooks in shared/. See the starter: github.com/sindev08/react-principles-nextjs → src/features/users/hooks/

features/users/hooks/useUsers.ts — from react-principles-nextjs starter
'use client';

import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';

// Feature hook — encapsulates query key, service call, and caching
// Components just call useUsers() and get typed data back
export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),
  });
}

// Also in the starter:
// features/users/hooks/useCreateUser.ts — mutation with cache invalidation
// shared/hooks/useDebounce.ts — generic debounce (logic extraction)
// shared/hooks/useMediaQuery.ts — browser API sync (useSyncExternalStore)
// shared/hooks/useLocalStorage.ts — storage sync with cross-tab support
open_in_new

View custom hooks in starter

View the real implementation in react-principles-nextjs

arrow_forward
menu_book
React Patterns

Helping developers build robust React applications since 2026.

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