Back to all notes

Custom Hooks: Extract and Reuse Stateful Logic in React

Stop duplicating logic across components. Learn how to design focused, reusable custom hooks with real-world examples.

3 min read

The first few times I needed a counter somewhere else, I pasted the same state and handler into the new component. Copy-paste holds until a fix lands in one copy and not the others. Counters, timers, fetch logic, form state. They all drift the same way, and the tests drift with them.

The Same Counter, Written Twice

Two components, one identical block of logic.

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1); // duplicated everywhere

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

function AnotherCounter() {
  // Same logic duplicated - hard to maintain
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);

  return (
    <div>
      <p>Another Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Without Custom Hooks</h1>
      <Counter />
      <AnotherCounter />
    </div>
  );
}

export default App;

Neither component can borrow the other’s logic. Whoever needs a counter next reimplements it, and each component is stuck doing two jobs, managing state and rendering.

Pull the State Into useCounter

A custom hook is just a function that calls other hooks. Move the state and the handler in, return a small API, and the component goes back to rendering.

import { useState } from "react";

function useCounter() {
  const [count, setCount] = useState(0);

  function increment() {
    setCount(count + 1);
  }

  return { count, increment };
}

function Counter() {
  const { count, increment } = useCounter();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

function AnotherCounter() {
  // Reuse the same hook - no duplication
  const { count, increment } = useCounter();

  return (
    <div>
      <p>Another Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>With Custom Hooks</h1>
      <Counter />
      <AnotherCounter />
    </div>
  );
}

export default App;

State stays local to each call, so the two counters keep independent counts while sharing one implementation. The behavior lives in the hook now, which means I can reuse useCounter anywhere and test it in isolation with a test renderer, no UI attached.

When Updates Depend on the Previous Value

There’s a trap in that first version. increment closes over count, and once updates depend on the previous value, that’s stale state waiting to happen. The functional updater avoids it.

setCount(c => c + 1);

The API deserves some protection too, since consumers shouldn’t care how the hook is implemented. A decrement, a reset, a configurable initial value and step all fit later without touching a single caller.

function useCounter(initial = 0, step = 1) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + step);
  const decrement = () => setCount(c => c - step);
  const reset = () => setCount(initial);
  return { count, increment, decrement, reset };
}

On Naming and Extracting

I keep hooks single-purpose. The moment one does two jobs, it splits. Names follow intent. useCounter, useUser, useDebouncedValue. A hook named useUtils tells me nothing about what it does. I document the return values and any invariants, mostly for future me. And I don’t extract early. A hook earns its own file when duplication has actually appeared or the logic has become hard to test.