Problem Solved

React · custom hook

Home › Courses › React › Problems › Custom hook
⚛️ Problem · 6 min read

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.

use-counter.tsx
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 (

Counter A: {count}

); } // ─── Independent state ─────────────────────── function TwoCounters() { // Each call gets its OWN state — not shared const a = useCounter(0); const b = useCounter(100); return (

A: {a.count} / B: {b.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);
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.

use-localstorage.tsx
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 (

Current theme: {theme}

); } // ─── Usage: user preferences ───────────────── interface Preferences { fontSize: number; notifications: boolean; } function Settings() { const [prefs, setPrefs] = useLocalStorage('prefs', { fontSize: 16, notifications: true }); return (

Font size: {prefs.fontSize}

); } // ─── Key points ────────────────────────────── // // 1. Generic — 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."

useful-hooks.tsx
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] = useStatenull>(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) return

Error: {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.

1. Not starting with use

function counter() { useState(...) } — hooks called from a non-hook. Fix: function useCounter().

2. Calling a hook conditionally

if (x) { useState(...) } — breaks hooks. Fix: call unconditionally, gate the logic inside.

3. Calling a hook from a regular function

const handleClick = () => { useState(...) } — hooks must be at the top level. Fix: extract the hook to the component top level.

4. Expecting shared state

Two calls to useCounter() have separate counters. Fix: use Context or lift the state up.

5. Making the hook do too much

A hook that fetches, caches, debounces, and formats. Fix: compose small hooks. Each hook does one thing.

6. Forgetting cleanup in a custom effect

The hook's useEffect has no cleanup — component leaks. Fix: every effect that starts something must clean up.

7. Hooks inside loops

items.map(i => useCustomHook(i)) — rules-of-hooks violation. Fix: extract an inner component, or use one hook with an array.

8. Async custom hooks that aren't hooks

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.

🔗 Related

← All React problems
Problem solved · React