Custom Hooks in React: Extracting Reusable Logic the Right Way

1. Overview

When multiple components in a React application need the same piece of logic — calling APIs, reading localStorage, debouncing input — you easily end up copy-pasting state and useEffect back and forth. This article shows you how to wrap that logic in a custom hook: a function starting with use, leveraging built-in hooks (useState, useEffect, useRef…) for reuse. After reading, you’ll be able to write useFetch, useLocalStorage, useDebounce and know how to test them with renderHook.

2. The problem: repeated logic between components

Suppose you have two components that both need to call an API and manage loading/error:

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

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

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

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

CustomerList and OrderList contain nearly identical logic, differing only in the URL. If tomorrow you want to add retry or cancel request handling, you have to fix it everywhere — this is a sign you need a custom hook.

3. What is a custom hook

A custom hook is a plain JavaScript function, whose name starts with use, that calls other React hooks inside. It doesn’t return JSX — it returns state and functions, like a “mini store” attached to the component using it.

Note: Each time a component calls a custom hook, React creates a separate state instance. Two components using useFetch don’t share state with each other — they only share logic.

4. Writing the first custom hook: useFetch

// useFetch.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

Explanation:

  • cancelled is used to avoid race condition: if url changes before the old request finishes, the old result won’t be set into state anymore.
  • The cleanup function (return () => { cancelled = true }) runs when the component unmounts or url changes — matching the lifecycle of useEffect.
  • The hook returns an object { data, loading, error } for easy destructuring at the call site.

Now your component becomes much simpler:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // render logic is similar, no more repeated fetch logic
}

5. Second example: useLocalStorage

Syncing state with localStorage is another piece of logic that gets copy-pasted a lot:

// useLocalStorage.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

Explanation:

  • useState(() => ...) uses a lazy initializer — the function only runs once when mounting, doesn’t read localStorage again on each render.
  • The hook returns a tuple [value, setValue] just like useState, helping users feel familiar right away.

Using it in a component:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

5.1. When state depends on the previous value

If you need to update based on the old value (like a counter), use functional updates to avoid stale closure:

setValue((prev) => prev + 1); // safer than setValue(value + 1)

6. Third example: useDebounce

Debouncing a search input is a very common problem:

// useDebounce.js
import { useState, useEffect } from 'react';

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

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

  return debounced;
}

Explanation:

  • Each time value changes, a new setTimeout is created; the old setTimeout is cleared in the cleanup.
  • Only when the user stops typing within delayMs does debounced update.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

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

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

7. Naming rules and Rules of Hooks

Custom hooks must follow Rules of Hooks like built-in hooks:

  1. Name starts with use — the ESLint plugin eslint-plugin-react-hooks relies on this prefix to check the rule; if there’s no use, it won’t recognize this as a hook.
  2. Only call hooks at the top-level of the function, not inside if, for, or callbacks.
  3. Only call hooks inside function components or other custom hooks, not inside regular functions.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Enable exhaustive-deps to help you not forget dependencies in your hook’s useEffect — the most common cause of “stale data” bugs.

8. Testing custom hooks

Since custom hooks don’t return JSX, you can’t render them like components. The @testing-library/react library provides renderHook to call hooks in a test environment.

<!-- Not applicable for React projects; dependency installed via npm, see below -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('only updates value after delay ends', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // hasn't changed yet

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce complete
    vi.useRealTimers();
  });
});

Explanation:

  • renderHook takes a function calling a hook, returns result.current — the value the hook returns in the current render.
  • rerender simulates component re-render with new props (like value changing when the user types).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) lets you control time without waiting for real time; tests run fast and stable.
✓ useDebounce > only updates value after delay ends

9. Common mistakes and when not to use

Common mistakes:

  • Forgetting cleanup in useEffect: with useFetch, if there’s no cancelled flag, an old request returning later could overwrite new data — causing incorrect display.
  • Missing dependency: if useEffect in the hook uses an external variable but doesn’t declare it in the dependency array, the hook will use the “old” value (stale closure).
  • Returning a new object each render unnecessarily: if a hook returns { data, loading, error } as an object literal each time, a component using React.memo below might re-render wastefully because the reference changes. Can use useMemo if you measure a real problem.

When NOT to write a custom hook:

  • Logic is used in only one place and has no reuse potential — extracting it only makes code harder to follow (have to jump to another file to understand).
  • Logic is purely computation (pure function), unrelated to state/effect — should write a regular JS function, no need for a hook.
  • When you need to share state (not just logic) between multiple components in different tree positions — custom hooks can’t do this, you need Context or a state management library (Zustand, Redux…).

Note: Custom hooks solve the problem of duplicate logic, not the problem of sharing state between components. Don’t confuse these two problems.

10. Conclusion

Custom hooks are a tool to extract stateful logic from components, making code cleaner, easier to test, and reusable. You just need to remember: name starts with use, follow Rules of Hooks, and test with renderHook instead of render. With useFetch, useLocalStorage, useDebounce above, you already have a framework to write more hooks like useMediaQuery, useForm in the same spirit of “write once, use everywhere”.

reactcustom-hookjavascriptfrontendtesting