Skip to content
C Codeloom
React

Building Custom React Hooks

Extract stateful logic into reusable custom hooks. The use-prefix rule, three examples (useLocalStorage, useDebounce, useToggle), tuple vs object return values, and how to test them.

·9 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • The "use" naming rule and why it matters to React and ESLint
  • How to extract repeated stateful logic from a component into a hook
  • Three battle-tested examples: useLocalStorage, useDebounce, useToggle
  • When to return a tuple and when to return an object
  • How to test custom hooks with @testing-library/react

Prerequisites

  • Solid grasp of useState and useEffect — see useState and useEffect
  • Familiarity with destructuring tuples and objects

A custom hook is a plain JavaScript function whose name starts with use and which can call other hooks. That is the entire definition. The magic is that, with that one rule, React lets you package up stateful logic and share it the same way you share components.

The “use” naming rule

A hook must start with use. This is not a convention — React relies on it.

function useCounter() { /* ok */ }
function counter()     { /* not a hook — cannot call useState inside */ }

Two things hinge on the prefix:

  1. The ESLint plugin eslint-plugin-react-hooks uses the name to apply the rules of hooks — top-level calls only, no conditionals. Without the prefix, it cannot tell a hook from a regular function and will not check your code.
  2. React DevTools picks up the name to label your hooks in the component panel.

So: any function that calls another hook starts with use. Any function that does not, does not.

Why custom hooks matter

The same stateful pattern shows up again and again in real apps: read from local storage with a fallback, debounce a value, toggle a boolean, watch a media query. Without custom hooks, you copy six lines of useState + useEffect into every component that needs them. With custom hooks, you write the logic once and call a one-liner.

A custom hook does for stateful logic what a component does for markup.

Extracting your first hook

Start with a component that has some logic worth reusing:

function ProductPage({ id }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(`/api/products/${id}`)
      .then((r) => r.json())
      .then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
      .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } });
    return () => { cancelled = true; };
  }, [id]);

  // ...
}

If three other components need the same loading/error/data shape, extract a hook:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(url)
      .then((r) => r.json())
      .then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
      .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } });
    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

The component shrinks:

function ProductPage({ id }) {
  const { data, loading, error } = useFetch(`/api/products/${id}`);
  // render
}

The mechanical extraction is simple: cut the state and effects out, paste them into a function that takes the inputs as arguments and returns the outputs.

Example 1: useLocalStorage

A value that persists across page reloads. Classic.

import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const raw = window.localStorage.getItem(key);
      return raw !== null ? JSON.parse(raw) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota errors, private mode, etc — ignore
    }
  }, [key, value]);

  return [value, setValue];
}

Notice:

  • Lazy useState initialiser. localStorage.getItem runs once, not on every render.
  • JSON serialise on write, parse on read. Local storage only stores strings.
  • try/catch around both sides. Storage can throw in private mode or when the quota is full.

Usage feels identical to useState:

const [name, setName] = useLocalStorage('user-name', '');

Example 2: useDebounce

Useful for search boxes and any input you want to react to after the user has paused typing.

import { useState, useEffect } from 'react';

export function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

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

  return debounced;
}

Use it like this:

function Search() {
  const [query, setQuery] = useState('');
  const debounced = useDebounce(query, 300);

  useEffect(() => {
    if (!debounced) return;
    fetch(`/api/search?q=${encodeURIComponent(debounced)}`);
  }, [debounced]);

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

The component keeps the synchronous query for the input value and reacts to the debounced version for the side effect. Clean separation.

Example 3: useToggle

The smallest useful hook, and a good place to talk about return shapes.

import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle, setOn];
}
const [isOpen, toggle] = useToggle();
return <button onClick={toggle}>{isOpen ? 'Close' : 'Open'}</button>;

useCallback keeps toggle’s identity stable across renders, so passing it to a memoised child does not bust memoisation.

Try it yourself. Write a useCounter(initial = 0, step = 1) hook that returns { count, increment, decrement, reset }. Use it in two components on the same page. Confirm each instance keeps its own count — that is the whole point of hooks-as-state.

Tuples vs objects

Both return shapes work. The choice is about ergonomics.

Tuples mirror useState and let the caller rename freely:

const [name, setName] = useLocalStorage('name', '');
const [age, setAge]   = useLocalStorage('age', 0);

Returning two unrelated values from the same hook with the same destructured names would clash; the tuple sidesteps it.

Objects are better when you return three or more values, when some are optional, or when callers will only want a subset:

const { data, loading, error } = useFetch(url);
// vs the wince-worthy [data, loading, error] = useFetch(url)

Rules of thumb:

  • One state + one setter → tuple. (Mirrors useState.)
  • Two values, often used together → tuple is fine.
  • Three or more named values → object.

Composition: hooks calling hooks

Custom hooks compose. A complex hook is just a small hook calling smaller hooks.

function useSearch(initial = '') {
  const [query, setQuery] = useState(initial);
  const debounced = useDebounce(query, 300);
  const { data, loading, error } = useFetch(
    debounced ? `/api/search?q=${encodeURIComponent(debounced)}` : null
  );
  return { query, setQuery, results: data ?? [], loading, error };
}

A component using useSearch does not see useState, useEffect, debounce timers, or fetch — just the inputs and outputs that matter to it. This is the real reward.

A few common mistakes

Forgetting the use prefix. ESLint stops checking, and the rules of hooks silently no longer apply. The first time you call your hook conditionally, you will get cryptic crashes.

Returning unstable references. If your hook returns { items, addItem } and you do not wrap addItem in useCallback, every render produces a new function. Children memoised with React.memo re-render anyway.

Treating hooks as singletons. Each call to a hook creates its own state. useCounter() in <A /> and useCounter() in <B /> are independent. If you want shared state, lift it to a common parent or to Context.

Hidden side effects. If useFetch(url) fires a request every render because the URL is built inline, the bug lives inside the hook’s useEffect deps. Keep inputs stable at the call site.

Testing a custom hook

You can test a hook directly with @testing-library/react’s renderHook helper.

import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

test('useToggle flips between true and false', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => { result.current[1](); });
  expect(result.current[0]).toBe(true);

  act(() => { result.current[1](); });
  expect(result.current[0]).toBe(false);
});

renderHook mounts a tiny test component that calls the hook; result.current is whatever the hook returned this render. act wraps anything that triggers a state update.

For hooks that depend on Context, pass a wrapper option:

const wrapper = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useTheme(), { wrapper });

Try it yourself. Write a test for useLocalStorage. Stub localStorage (or use jsdom’s built-in version), set an initial value, render the hook, update the value with act, then create a second renderHook call with the same key and confirm it reads the persisted value. This catches the most common bug — forgetting to write to storage on update.

When not to write a custom hook

Resist the temptation to wrap every two-line useState in a hook. A custom hook earns its keep when:

  • The same logic appears in three or more places, or
  • The logic is gnarly enough that hiding it improves readability even once, or
  • You want a clean test boundary for stateful logic.

Premature extraction creates indirection without benefit. Wait for the third use; then extract.

Recap

You now know:

  • A custom hook is any function starting with use that calls other hooks
  • The use prefix is required for the rules of hooks to be enforced
  • Extract repeated useState + useEffect patterns into hooks like useLocalStorage, useDebounce, useToggle
  • Return tuples for useState-shaped hooks, objects when you have three or more named values
  • Wrap returned functions in useCallback so consumers get stable references
  • Test hooks with renderHook and act from @testing-library/react

Next steps

Once you can extract stateful logic, you can build entire features as hooks layered on top of one another. The next step is sharpening the CSS underneath.

→ Next: CSS Variables and Custom Properties

Questions or feedback? Email codeloomdevv@gmail.com.