How to write a custom hook.
The rules and three full examples.
A custom hook is how you extract stateful logic from a component and reuse it. It's not a new API — it's function composition. But the rules, the naming, and the patterns matter. This page covers the rules, three complete examples (useLocalStorage, useDebounce, useFetch), and the one thing that trips up almost everyone: custom hooks don't share state.
Short answer: A custom hook is a function whose name starts with use and that calls other hooks. function useCounter(init = 0) { const [count, setCount] = useState(init); ... }. The use prefix is required — it tells React and lint rules the function is a hook. Custom hooks share logic, not state — each call gets its own state. For shared state, use Context or a state library.
✅ Solution 1 — The simplest custom hook: useCounter
Extract stateful logic that would otherwise be duplicated.
A custom hook is just a function that starts with use and calls other hooks. The simplest useful one: useCounter. It bundles state, an increment function, a decrement function, and a reset into one call. Every component that uses it gets its own independent state.
import { useState } from 'react'; // ─── The custom hook ───────────────────────── interface UseCounterReturn { count: number; increment: () => void; decrement: () => void; reset: () => void; set: (value: number) => void; } function useCounter(initial: number = 0): UseCounterReturn { const [count, setCount] = useState(initial); return { count, increment: () => setCount(c => c + 1), decrement: () => setCount(c => c - 1), reset: () => setCount(initial), set: (value: number) => setCount(value) }; } // ─── Usage ─────────────────────────────────── function CounterA() { const { count, increment, decrement, reset } = useCounter(0); return (); } // ─── Independent state ─────────────────────── function TwoCounters() { // Each call gets its OWN state — not shared const a = useCounter(0); const b = useCounter(100); return (Counter A: {count}
); } // ─── Custom hook vs regular function ───────── // // Custom hook (useCounter): // - Can call useState, useEffect, other hooks // - Name MUST start with 'use' // - Only callable inside components or other hooks // // Regular function (increment): // - Cannot call hooks // - Any name // - Callable anywhere // // Rule: if it uses hooks, it's a hook. Add 'use'. console.log(useCounter, CounterA, TwoCounters);A: {a.count} / B: {b.count}
import { useState } from 'react'; function useCounter(initial = 0) { const [count, setCount] = useState(initial); return { count, increment: () => setCount(c => c + 1), decrement: () => setCount(c => c - 1), reset: () => setCount(initial), set: (value) => setCount(value) }; } function CounterA() { const { count, increment, decrement, reset } = useCounter(0); return React.createElement('div', null, React.createElement('p', null, 'Counter A: ', count), React.createElement('button', { onClick: increment }, '+1'), React.createElement('button', { onClick: decrement }, '-1'), React.createElement('button', { onClick: reset }, 'Reset') ); } function TwoCounters() { const a = useCounter(0); const b = useCounter(100); return React.createElement('div', null, React.createElement('p', null, 'A: ', a.count, ' / B: ', b.count), React.createElement('button', { onClick: a.increment }, 'A +1'), React.createElement('button', { onClick: b.increment }, 'B +1') ); } console.log(useCounter, CounterA, TwoCounters);
Name starts with use
Required. React and ESLint use it to identify hooks. counter() is a regular function; useCounter() is a hook.
Calls other hooks
Inside, you use useState, useEffect, etc. That's what makes it a hook instead of a plain function.
Returns what's useful
The return can be an object (named fields), an array (tuple — like useState), or a single value. Whatever's cleanest.
State is per-call
Every call to useCounter() gets its own independent state. Two components — two counters. No sharing.
The use prefix is not a convention — it's a requirement. React doesn't enforce it at runtime, but ESLint's exhaustive-deps and rules-of-hooks checks only run on functions starting with use. Without the prefix, you lose all hook-related linting, and bugs like "called useState conditionally" won't be caught. Every custom hook starts with use — no exceptions. This one rule prevents whole categories of subtle bugs.
✅ Solution 2 — useLocalStorage (side-effect hook)
State that persists across reloads.
A more realistic custom hook: useLocalStorage. It works like useState but persists the value to localStorage and reads it back on mount. This is a hook with both state and effects — the pattern that makes custom hooks powerful.
import { useState, useEffect } from 'react'; // ─── The custom hook ───────────────────────── function useLocalStorage( key: string, initial: T ): [T, (value: T | ((prev: T) => T)) => void] { // Lazy init — reads localStorage once const [value, setValue] = useState (() => { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initial; } catch { return initial; } }); // Persist on change useEffect(() => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { console.warn(Failed to save ${key}`); } }, [key, value]); return [value, setValue]; } // ─── Usage: theme preference ───────────────── function ThemeToggle() { const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light'); return ( ); } // ─── Usage: user preferences ───────────────── interface Preferences { fontSize: number; notifications: boolean; } function Settings() { const [prefs, setPrefs] = useLocalStorageCurrent theme: {theme}
('prefs', { fontSize: 16, notifications: true }); return ( ); } // ─── Key points ────────────────────────────── // // 1. GenericFont size: {prefs.fontSize}
— works for any type // 2. Lazy initial state — reads once, not every render // 3. Effect syncs to localStorage on change // 4. Error handling — localStorage might throw (quota, private mode) // 5. Same interface as useState — setter accepts value or function // // This is the pattern: hide complexity, expose a simple API. console.log(useLocalStorage, ThemeToggle, Settings);
import { useState, useEffect } from 'react'; function useLocalStorage(key, initial) { const [value, setValue] = useState(() => { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initial; } catch { return initial; } }); useEffect(() => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { console.warn(Failed to save${key}`); } }, [key, value]); return [value, setValue]; } function ThemeToggle() { const [theme, setTheme] = useLocalStorage('theme', 'light'); return React.createElement('div', null, React.createElement('p', null, 'Current theme: ', theme), React.createElement('button', { onClick: () => setTheme(t => t === 'light' ? 'dark' : 'light') }, 'Toggle') ); } function Settings() { const [prefs, setPrefs] = useLocalStorage('prefs', { fontSize: 16, notifications: true }); return React.createElement('div', null, React.createElement('p', null, 'Font size: ', prefs.fontSize), React.createElement('button', { onClick: () => setPrefs(p => ({ ...p, fontSize: p.fontSize + 2 })) }, 'Larger') ); } console.log(useLocalStorage, ThemeToggle, Settings);
Generic hook
useLocalStorage works for any type. The caller specifies T — hook works the same way regardless.
Lazy init
useState(() => readStorage()) — the initializer runs only on mount. Not on every render. Saves reads.
Effect syncs
The effect writes to localStorage whenever value changes. Automatic persistence.
Same API as useState
Returns [value, setter]. The setter accepts a value or function. Callers swap useState for useLocalStorage without other changes.
✅ Solution 3 — useDebounce and useFetch
The two hooks you'll write repeatedly.
Two custom hooks that appear in almost every codebase. useDebounce delays a value (classic for search inputs). useFetch bundles the fetch-and-validate pattern. Both are examples of "hide the effect, expose the value."
import { useState, useEffect } from 'react'; // ─── useDebounce ───────────────────────────── function useDebounce(value: T, delay: number = 300): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); }, [value, delay]); return debounced; } // Usage: type-ahead search function SearchBox() { const [query, setQuery] = useState(''); const debounced = useDebounce(query, 500); useEffect(() => { if (debounced) { fetch(/api/search?q=${debounced}`); } }, [debounced]); // only fires after 500ms of quiet return setQuery(e.target.value)} />; } // ─── useFetch ──────────────────────────────── interface UseFetchResult { data: T | null; loading: boolean; error: string | null; } function useFetch (url: string): UseFetchResult { const [data, setData] = useState null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const controller = new AbortController(); let cancelled = false; setLoading(true); setError(null); fetch(url, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(HTTP${r.status}`); return r.json(); }) .then(d => { if (!cancelled) { setData(d); setLoading(false); } }) .catch(err => { if (err.name !== 'AbortError' && !cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; controller.abort(); }; }, [url]); return { data, loading, error }; } // Usage: display a user interface User { id: string; name: string } function UserProfile({ id }: { id: string }) { const { data, loading, error } = useFetch (/api/users/${id}`); if (loading) return Loading...
; if (error) returnError: {error}
; return{data?.name}
; } // ─── Composition — hooks calling hooks ─────── function useDebouncedFetch(query: string, delay = 300): UseFetchResult { const debounced = useDebounce(query, delay); const url = debounced ? /api/search?q=${debounced}` : ''; return useFetch (url); } // ─── Hooks compose ──────────────────────────── // // useDebouncedFetch uses: // - useDebounce (which uses useState + useEffect) // - useFetch (which uses useState + useState + useState + useEffect) // // That's 6 built-in hooks under one custom hook. // Each call to useDebouncedFetch gets its own 6 state slots. // No conflict, no leakage. That's the power of hooks. console.log(useDebounce, SearchBox, useFetch, UserProfile, useDebouncedFetch);
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; } function SearchBox() { const [query, setQuery] = useState(''); const debounced = useDebounce(query, 500); useEffect(() => { if (debounced) { fetch(/api/search?q=${debounced}`); } }, [debounced]); return React.createElement('input', { value: query, onChange: e => setQuery(e.target.value) }); } function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); let cancelled = false; setLoading(true); setError(null); fetch(url, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(HTTP${r.status}`); return r.json(); }) .then(d => { if (!cancelled) { setData(d); setLoading(false); } }) .catch(err => { if (err.name !== 'AbortError' && !cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; controller.abort(); }; }, [url]); return { data, loading, error }; } function UserProfile({ id }) { const { data, loading, error } = useFetch(/api/users/${id}`); if (loading) return React.createElement('p', null, 'Loading...'); if (error) return React.createElement('p', null, 'Error: ', error); return React.createElement('p', null, data?.name); } function useDebouncedFetch(query, delay = 300) { const debounced = useDebounce(query, delay); const url = debounced ? /api/search?q=${debounced}` : ''; return useFetch(url); } console.log(useDebounce, SearchBox, useFetch, UserProfile, useDebouncedFetch);
useDebounce
Delays a value — perfect for search inputs. The effect resets the timer on every change. Only the last value survives.
useFetch
Bundles fetch + abort + loading + error. Every component that fetches uses this — no repeated boilerplate.
Composition
Custom hooks can call each other. useDebouncedFetch uses useDebounce + useFetch. Each call still has independent state.
Hide the complexity
The point of a custom hook: the component doesn't see the effects, the abort controllers, the timers. Just clean data.
Custom hooks share logic, not state. Two components calling useFetch('/api/users') make two separate fetches. Two useLocalStorage('theme') calls share the underlying storage (both read/write the same key) but have independent React state — they can drift until one writes. If you need shared React state, use Context or a state library. Custom hooks are the wrong tool for sharing state; they're the right tool for sharing stateful behavior.
⚠️ Common mistakes
The custom hook mistakes that cost real time.
usefunction counter() { useState(...) } — hooks called from a non-hook. Fix: function useCounter().
if (x) { useState(...) } — breaks hooks. Fix: call unconditionally, gate the logic inside.
const handleClick = () => { useState(...) } — hooks must be at the top level. Fix: extract the hook to the component top level.
Two calls to useCounter() have separate counters. Fix: use Context or lift the state up.
A hook that fetches, caches, debounces, and formats. Fix: compose small hooks. Each hook does one thing.
The hook's useEffect has no cleanup — component leaks. Fix: every effect that starts something must clean up.
items.map(i => useCustomHook(i)) — rules-of-hooks violation. Fix: extract an inner component, or use one hook with an array.
async function useX() { ... } — hooks can't be async. Fix: define an async function inside, or use effects for async work.
❓ Frequently asked
How do I write a custom hook in React?
A custom hook is a function that starts with 'use' and calls other hooks. Example: function useCounter(initial = 0) { const [count, setCount] = useState(initial); const increment = () => setCount(c => c + 1); return { count, increment }; }. The 'use' prefix is required.
What is the difference between a custom hook and a regular function?
A custom hook can call other hooks (useState, useEffect, etc.). A regular function cannot — hooks can only be called from within components or other hooks. The 'use' prefix is how React and ESLint know a function is a hook, so its body can contain hook calls.
Do custom hooks share state between components?
No — custom hooks share LOGIC, not state. Each call to useCounter() creates its own independent state. Two components calling useCounter() have separate counters. For shared state, use Context, state management (Zustand, Redux), or lift state to a common parent.
What are the rules for custom hooks?
Same rules as built-in hooks. (1) Name starts with 'use'. (2) Call only at the top level of a component or another hook. (3) Call from React components or other hooks only. (4) No conditional hook calls.