Custom Hooks
A custom hook is simply a JavaScript function whose name starts with use and that calls other hooks inside it. Custom hooks let you extract stateful logic — a data fetch, a subscription, a piece of form handling — out of a component and reuse it anywhere, without duplicating code or introducing extra wrapper components. They are the primary way React encourages sharing behavior between components in the hooks era, replacing older patterns like render props and higher-order components.
Overview / How it works
Every hook you already know — useState, useEffect, useRef, useContext — is just a function that plugs into React’s internal per-component “hook list.” React tracks, for each component instance, an ordered list of hook calls and their associated state. When your component re-renders, React walks that list in the exact same order and matches each hook call to its stored state by position, not by name. This is why hooks must always run in the same order on every render, and why you can never call a hook inside a condition, loop, or nested function — doing so would shift the positions and corrupt the mapping between hook calls and their stored state.
A custom hook doesn’t add any new capability to React — it’s just a function that happens to call built-in hooks (or other custom hooks) internally, and returns whatever value(s) are useful to the caller (a value, a tuple, or an object). Because it is a plain function, calling it from a component is exactly like calling useState or useEffect directly: React doesn’t distinguish between “built-in” and “custom” hooks. The use prefix is a naming convention, not a language feature — but it’s essential, because it’s what eslint-plugin-react-hooks uses to recognize the function as a hook and enforce the Rules of Hooks on it. Skip the prefix and the linter (and other developers) can no longer tell it needs to follow those rules.
Because each call to a custom hook happens inside its own calling component’s render, each call gets its own independent state. If two different components call useToggle(), they each get their own value and setValue — the state is not shared between them unless you explicitly share it (for example, by lifting it up or using useContext).
Syntax
function useSomething(arg1, arg2) {
const [state, setState] = useState(initialValue);
useEffect(() => {
// side effect logic
}, [dependencies]);
return state; // or [state, setState], or { state, helperFn }
}
- Function name — must start with
use(e.g.useToggle,useFetch) so React’s linter and other developers recognize it as a hook. - Parameters — ordinary arguments; often an initial value, a URL, or configuration options.
- Internal hook calls — any mix of built-in hooks (
useState,useEffect,useRef, etc.) or other custom hooks, always called unconditionally at the top level of the function. - Return value — whatever shape is convenient: a single value, an array tuple (like
useStateitself), or an object with named fields.
Examples
Example 1: A simple useToggle hook
import { useState, useCallback } from "react";
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue((prev) => !prev);
}, []);
return [value, toggle];
}
function Light() {
const [isOn, toggleLight] = useToggle(false);
return (
<button onClick={toggleLight}>
Light is {isOn ? "ON" : "OFF"}
</button>
);
}
export default Light;
Output:
Renders a button reading "Light is OFF". Each click flips the text between "Light is ON" and "Light is OFF".
This hook wraps a single boolean useState call and a memoized toggle function. Any component that needs an on/off flag — a modal, a sidebar, a checkbox — can call useToggle() instead of repeating the same three lines of state and handler logic. Notice the tuple return shape, mirroring useState itself, which keeps the calling code idiomatic (const [isOn, toggleLight] = useToggle(false)).
Example 2: A useFetch hook for data loading
import { useState, useEffect } from "react";
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
return res.json();
})
.then((json) => {
setData(json);
setLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(
`https://api.example.com/users/${userId}`
);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h3>{data.name}</h3>;
}
export default UserProfile;
Output:
While the request is pending, renders "Loading...". Once the response resolves, renders an h3 with the user's name, e.g. "Ada Lovelace". If the request fails, renders "Error: Request failed with status 404" (or the relevant message).
This is a more realistic hook: it manages three related pieces of state (data, loading, error) and an effect with a proper cleanup function. The AbortController cancels the in-flight request if url changes or the component unmounts before the fetch resolves, preventing a state update on an unmounted component. Any component that needs to fetch JSON from a URL can now call useFetch(url) instead of duplicating this loading/error/data dance.
Example 3: A useLocalStorage hook
import { useState, useEffect } from "react";
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];
}
function NameForm() {
const [name, setName] = useLocalStorage("name", "");
return (
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
);
}
export default NameForm;
Output:
Renders a text input, pre-filled with any previously saved value from localStorage (empty on first visit). Typing updates the input and persists the new value to localStorage under the key "name", so it survives a page reload.
Here the hook mirrors useState‘s exact return shape ([value, setValue]) so it’s a near drop-in replacement, but adds persistence behind the scenes. The lazy initializer function passed to useState reads from localStorage only once, on the first render, instead of on every render.
How it works step by step / Under the hood
Take the useFetch example and trace what happens on mount, update, and unmount:
- Mount — React calls
UserProfile, which callsuseFetch(url). Inside it, threeuseStatecalls register three new state slots for this component instance, anduseEffectqueues its callback to run after the DOM commit. React commits the initial render (showing "Loading…"), then runs the effect, which starts thefetchcall. - State update — when the fetch resolves,
setDataandsetLoadingare called. Each triggers React to schedule a re-render ofUserProfile. On that re-render,useFetchruns again from the top, and its threeuseStatecalls reconnect to the same three state slots (by call order), returning the freshly updated values. React diffs the new output against the previous render (reconciliation) and commits only the changed DOM — here, replacing the loading paragraph with theh3. - Dependency change — if
userIdchanges,UserProfilere-renders with a newurl, and becauseurlis in the effect’s dependency array, React first runs the previous effect’s cleanup (aborting the old request), then runs the new effect (starting a fresh fetch). - Unmount — when
UserProfileis removed from the tree, React runs the effect’s cleanup one last time, aborting any still-pending request sosetData/setErrornever fire on a component that no longer exists.
The key insight: none of this behavior is special-cased for custom hooks. React doesn’t know or care that useFetch is “custom” — it just sees a component that happens to call useState three times and useEffect once, in that order, every render.
Common Mistakes
Mistake 1: Not naming the function with a "use" prefix
// Wrong: linter can't tell this is a hook
function toggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
return [value, () => setValue((v) => !v)];
}
Because the function isn’t named useToggle, eslint-plugin-react-hooks treats it as an ordinary function and no longer enforces the Rules of Hooks on its body or on components that call it. Always prefix hook functions with use:
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
return [value, () => setValue((v) => !v)];
}
Mistake 2: Calling a hook conditionally inside a custom hook
// Wrong: useState is called only when userId is truthy
function useUserData(userId) {
if (!userId) {
return null;
}
const [data, setData] = useState(null);
return data;
}
If userId is falsy on one render and truthy on the next, the number of hook calls changes between renders, which corrupts React’s internal hook list and throws an error (or silently returns wrong state). Keep the hook call unconditional, and push the conditional logic inside the hook’s body instead:
function useUserData(userId) {
const [data, setData] = useState(null);
useEffect(() => {
if (!userId) return;
// fetch user data here
}, [userId]);
return data;
}
Mistake 3: An incomplete dependency array causes a stale closure
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, []); // callback from the first render is captured forever
}
Because callback is omitted from the dependency array, the effect only ever “sees” the callback function that existed on the very first render — any variables that function closed over are frozen at their initial values. Include every reactive value the effect uses:
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]);
}
Best Practices
- Always prefix custom hook names with
useso the linter can enforce the Rules of Hooks and other developers instantly recognize the function’s purpose. - Keep a custom hook focused on one concern (data fetching, a toggle, a subscription) rather than bundling unrelated logic into one giant hook.
- Return the shape that’s most convenient for callers: a tuple (
[value, setter]) for state-like hooks, an object with named fields for hooks returning multiple unrelated values. - Always clean up subscriptions, timers, and in-flight requests in the effect’s cleanup function to avoid updating state on an unmounted component.
- List every reactive value the hook’s effect reads in its dependency array — don’t suppress the exhaustive-deps lint rule without a very good reason.
- Extract logic into a custom hook only once you see it duplicated in two or more components — don’t pre-emptively abstract logic used in just one place.
- A custom hook can call other custom hooks; compose small hooks into bigger ones rather than writing one large hook that does everything.
Practice Exercises
- Write a
useWindowWidthhook that returns the currentwindow.innerWidthand updates it on the browser’sresizeevent, cleaning up the event listener on unmount. - Write a
useDebouncehook that takes a value and a delay, and returns a debounced version of that value that only updates after the delay has passed without the input changing again. - Refactor the
useFetchexample from this lesson into a more generaluseAsynchook that accepts any async function (not justfetch) and returns the same{ data, loading, error }shape.
Summary
- A custom hook is a plain JavaScript function, named starting with
use, that calls other hooks internally. - Custom hooks let you extract and reuse stateful logic between components without duplicating code or wrapping components.
- Each call to a custom hook creates its own independent state — state is never shared between components unless explicitly lifted or passed through context.
- The
usenaming convention is what enables ESLint’s Rules of Hooks checks — it is required for tooling, not just style. - Custom hooks must follow the same rules as built-in hooks: called unconditionally at the top level, in the same order every render.
- Return whatever shape best fits the caller — a tuple for state-like values, an object for multiple named fields.
