A React custom hook is the official way to move repeated logic out of your components and into a single reusable function. If you ever notice that you have copied the same useState + useEffect combination into three separate components, that is exactly the moment to extract it into a hook. In this guide I will explain what a custom hook is, how to write one, and when it actually pays off, using real examples.
What a custom hook is — and is not
A custom hook is an ordinary JavaScript function whose name begins with use and that may call other hooks inside it (useState, useEffect, useRef, even other custom hooks). It has no magic property; React simply expects any function with the use prefix to follow the rules of hooks.
- It shares logic, not state. If two components use the same hook, each gets its own independent state.
- It returns data, functions, or a combination of both — never JSX.
- It is not a component; it does not render, it is only called from inside a component (or another hook).
The clearest distinction is this: if your reason for splitting something out is "to reuse the UI," extract a component; if it is "to reuse the logic," extract a hook.
The two rules: where you may call them
Hooks, custom or built-in, obey two rules, and breaking them makes your app behave unpredictably:
- Only call them at the top level. Never call a hook inside a loop, condition, or nested function. This lets React rely on hooks running in the same order on every render.
- Only call them from React functions. That means from components or from other custom hooks. Do not call a hook from a plain helper function.
To enforce these rules automatically, install eslint-plugin-react-hooks; it catches misuse while you are still typing.
Our first custom hook: useToggle
Let us start with something simple that you nonetheless need constantly. Dropdowns, modals, "show more" — everywhere you have to flip a boolean:
import { useState, useCallback } from 'react';
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
Using it in a component is wonderfully clean:
function Sidebar() {
const { value: isOpen, toggle, setFalse } = useToggle();
return (
<>
<button onClick={toggle}>Menu</button>
{isOpen && <nav onMouseLeave={setFalse}>...</nav>}
</>
);
}
The reason we wrap the returned functions in useCallback is to keep them from being recreated as a new reference on every render; that way you can safely use them as useEffect dependencies.
A hook with side effects: useLocalStorage
The real power appears once useEffect and browser APIs enter the picture. A hook that keeps a value in sync with localStorage collapses logic that would otherwise be repeated across dozens of components into one place:
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// quota exceeded or private mode: silently ignore
}
}, [key, value]);
return [value, setValue];
}
Two details matter here. First, we passed a function to useState (a lazy initializer) so the localStorage read runs only on the first render, not on every one. Second, we wrapped both JSON.parse and localStorage access in try/catch, because corrupt data or a private tab denying access is a very real source of bugs in production.
A data-fetching hook with cleanup
The most common mistake with async fetching is trying to update state after the component has unmounted. AbortController solves this cleanly:
import { useState, useEffect } from 'react';
function useFetch(url) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, error: null, loading: true });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setState({ data, error: null, loading: false }))
.catch(error => {
if (error.name !== 'AbortError') {
setState({ data: null, error, loading: false });
}
});
return () => controller.abort();
}, [url]);
return state;
}
The cleanup function returned from the effect aborts the previous request whenever url changes or the component unmounts. This prevents both race conditions and the familiar "Can't perform a React state update on an unmounted component" warning.
When to extract a hook, and when to wait
Premature abstraction is a genuine trap. A practical set of rules:
- Extract a hook once you use the same logic in at least two places (or are confident you will).
- If the effect and state logic inside one component grows long enough to hurt readability, extracting it is worthwhile for testability alone.
- Writing a
useFetchis educational, but if you need caching, retries, and invalidation in production, a mature library such as TanStack Query is usually the better choice. - Design the shape your hook returns as a clear contract: decide deliberately whether you return an array (positional, like
useState) or an object (named, when there are several fields).
Frequently Asked Questions
What is the difference between a custom hook and a normal function?
A custom hook may call other hooks like useState and useEffect inside it and hooks into React's render lifecycle; a plain helper function cannot. In practice the distinction is signaled by the use prefix, and the linter enforces its rules based on that prefix.
If two components use the same hook, do they share state?
No. The hook shares only the logic; each component gets its own isolated state. If you genuinely want to share state, you need Context or a state-management solution.
How do I test custom hooks?
With React Testing Library's renderHook helper you can render the hook in isolation and assert on its returned values and on updates triggered inside act. If the hook contains pure logic, you can also split that out into a separate function and test it directly.
Want to move your repeated React logic into clean hooks? Let us work together on your frontend architecture and React projects — get in touch with me.