πͺ Custom Hooks Development
Custom hooks are how React shares logic the way composition shares markup. When two components need the same stateful behavior β toggling, persisting to storage, fetching data β you extract it into a useβ¦ function once and reuse it everywhere. This lesson turns that idea into a practical, testable skill.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a custom hook is and how it differs from a regular function
- Apply the Rules of Hooks and know why they exist
- Write reusable hooks:
useToggle,useLocalStorage, and a modernuseFetch - Cancel in-flight requests safely with AbortController
- Compose hooks from other hooks and test them in isolation
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a useForm hook that manages values, validation, and submission.
In This Lesson
A Quick Hooks Refresher
Hooks are functions that let you "hook into" React state and lifecycle features from function components. Introduced in React 16.8, they replaced class components as the standard way to manage state and side effects. You've already met the built-in ones:
| Hook | What it does |
|---|---|
useState | Adds local state to a component |
useEffect | Runs side effects (fetching, subscriptions, DOM work) |
useContext | Reads a Context value |
useReducer | Manages complex state with a reducer |
useRef | Holds a mutable value that survives renders |
useMemo / useCallback | Memoize values and functions |
π§° A useful analogy: Built-in hooks are the standard tools that come with your toolbox. Custom hooks are the specialized jigs you build yourself for a task you keep repeating β a screwdriver bit ground to exactly the screw you meet every day.
What Are Custom Hooks?
A custom hook is simply a JavaScript function whose name starts with use and that calls one or more other hooks. That's the whole definition. It lets you lift stateful logic out of a component and into a reusable, shareable function.
π The most important subtlety
Custom hooks share stateful logic, not state itself. If two components each call useToggle(), they get two separate toggles. To share the actual value, you'd lift it into Context or a store β a hook alone won't do it.
Why bother?
- Reusability β write the logic once, use it in many components.
- Cleaner components β the component reads like a description of the UI, not a tangle of effects.
- Composition β small hooks combine into more capable ones.
- Testability β logic can be tested without rendering a whole component.
- Encapsulation β implementation details stay hidden behind a tidy return value.
The Rules of Hooks
Hooks work because React tracks them by call order on every render. Break that order and React loses track of which state belongs to which hook. Two rules keep the order stable:
β οΈ Rule 1 β Only call hooks at the top level
Never call a hook inside a loop, condition, or nested function. Hooks must run in the same order on every render.
β οΈ Rule 2 β Only call hooks from React functions
Call hooks from React function components or from other custom hooks β never from ordinary JavaScript functions or event handlers.
A third convention makes both rules enforceable: custom hook names must start with use. That prefix is how React's linter (eslint-plugin-react-hooks) knows to check the rules inside your function.
// β WRONG β hook called conditionally; order changes between renders
function Profile({ userId }) {
if (userId) {
const [name, setName] = useState(''); // sometimes runs, sometimes not
}
// ...
}
// β
RIGHT β hook always runs; put the condition inside
function Profile({ userId }) {
const [name, setName] = useState('');
useEffect(() => {
if (!userId) return;
// ... fetch this user
}, [userId]);
}
Your First Hook: useToggle
The best way to learn is to build the smallest useful hook. Toggling a boolean β for modals, dropdowns, "show more" β is everywhere. Extract it once:
// useToggle.js
import { useCallback, useState } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(prev => !prev), []);
const setTrue = useCallback(() => setOn(true), []);
const setFalse = useCallback(() => setOn(false), []);
return { on, toggle, setTrue, setFalse };
}
// Usage
import { useToggle } from './useToggle';
function FaqItem({ question, answer }) {
const { on, toggle } = useToggle();
return (
<div>
<button onClick={toggle} aria-expanded={on}>{question}</button>
{on && <p>{answer}</p>}
</div>
);
}
Tiny, but it demonstrates the whole shape of a custom hook: call built-in hooks inside, return a clean interface, and use it like any built-in. Wrapping the handlers in useCallback keeps their identity stable across renders β handy when you pass them to memoized children (next lesson's topic).
A Practical Hook: useLocalStorage
Here's a hook that feels exactly like useState but persists to localStorage, so the value survives page reloads β perfect for theme preferences, filters, or a draft form.
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
// Lazy initializer: read storage only on first render
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (err) {
console.error(`useLocalStorage: failed to read "${key}"`, err);
return initialValue;
}
});
// Persist whenever key or value changes
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error(`useLocalStorage: failed to write "${key}"`, err);
}
}, [key, value]);
return [value, setValue];
}
// Usage β drop-in replacement for useState
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme('light')}>Light</button>
<button onClick={() => setTheme('dark')}>Dark</button>
</div>
);
}
π‘ Note the lazy initializer
Passing a function to useState(() => β¦) means the expensive localStorage.getItem read runs only on the first render, not on every one. The try/catch guards against private-mode browsers and corrupt JSON. Real uses: theme, user settings, form drafts, shopping-cart contents.
Data Fetching: useFetch
Data fetching is the classic custom-hook use case. The naive version leaks: if the URL changes or the component unmounts mid-request, the stale response can still land and update dead state. The modern fix is AbortController, which actually cancels the network request.
// useFetch.js
import { useEffect, useState } from 'react';
export function useFetch(url, options) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
async function run() {
setLoading(true);
setError(null);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setData(await res.json());
} catch (err) {
// Ignore the error we caused by aborting
if (err.name !== 'AbortError') setError(err.message);
} finally {
setLoading(false);
}
}
run();
return () => controller.abort(); // cleanup: cancel on unmount / url change
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
β
Why AbortController beats an isMounted flag
An isMounted boolean only ignores a late response β the request still runs and wastes bandwidth. controller.abort() genuinely cancels it and fires the cleanup on every dependency change, preventing race conditions where an older request resolves after a newer one.
ποΈ In production: for real apps, reach for a data-fetching library like TanStack Query or SWR. They give you caching, retries, deduping, and background refetch out of the box. Hand-rolling useFetch is invaluable for understanding what those libraries do for you.
Composing Hooks
Hooks compose just like components. A hook can call other hooks β including your own β to layer behavior. Here usePaginatedFetch builds page navigation on top of useFetch:
// usePaginatedFetch.js
import { useState } from 'react';
import { useFetch } from './useFetch';
export function usePaginatedFetch(baseUrl, perPage = 10) {
const [page, setPage] = useState(1);
const { data, loading, error } = useFetch(
`${baseUrl}?page=${page}&limit=${perPage}`
);
const nextPage = () => setPage(p => p + 1);
const prevPage = () => setPage(p => Math.max(p - 1, 1));
return { data, loading, error, page, nextPage, prevPage, setPage };
}
// Usage
function UserList() {
const { data, loading, error, page, nextPage, prevPage } =
usePaginatedFetch('/api/users', 20);
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<div>
<h2>Users β page {page}</h2>
<ul>{data.items.map(u => <li key={u.id}>{u.name}</li>)}</ul>
<button onClick={prevPage} disabled={page === 1}>Previous</button>
<button onClick={nextPage} disabled={!data.hasMore}>Next</button>
</div>
);
}
Each layer stays small and focused. This is the same "build big things from small pieces" idea you saw with component composition β applied to logic instead of markup.
Hands-on: Build useForm
ποΈ Create a useForm hook
Objective: Write a hook that manages form values, errors, touched state, and submission β so any form component stays clean.
Requirements:
- Accept
initialValuesand avalidatefunction. - Provide
handleChangeandhandleBlurthat update state by fieldname. - Provide
handleSubmit(onSubmit)that validates first and only callsonSubmitwhen there are no errors. - Expose an
isSubmittingflag.
π‘ Hint
Store values, errors, touched, and isSubmitting as separate useState pieces. In handleChange, read e.target.name and e.target.value and update the matching key with the functional form: setValues(v => ({ ...v, [name]: value })). handleSubmit should be a function that returns an event handler so callers write onSubmit={handleSubmit(save)}.
β Sample solution
// useForm.js
import { useState } from 'react';
export function useForm(initialValues = {}, validate = () => ({})) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues(v => ({ ...v, [name]: value }));
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(t => ({ ...t, [name]: true }));
setErrors(validate({ ...values }));
};
const handleSubmit = (onSubmit) => async (e) => {
e.preventDefault();
const validationErrors = validate(values);
setErrors(validationErrors);
setTouched(Object.keys(values).reduce((acc, k) => ({ ...acc, [k]: true }), {}));
if (Object.keys(validationErrors).length === 0) {
setSubmitting(true);
try {
await onSubmit(values);
} finally {
setSubmitting(false);
}
}
};
return { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit };
}
// Usage
function SignupForm() {
const { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit } =
useForm(
{ email: '', password: '' },
(v) => {
const e = {};
if (!v.email) e.email = 'Email is required';
if (!v.password) e.password = 'Password is required';
return e;
}
);
const save = async (v) => { await api.signup(v); };
return (
<form onSubmit={handleSubmit(save)}>
<input name="email" value={values.email}
onChange={handleChange} onBlur={handleBlur} />
{touched.email && errors.email && <span>{errors.email}</span>}
<input name="password" type="password" value={values.password}
onChange={handleChange} onBlur={handleBlur} />
{touched.password && errors.password && <span>{errors.password}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing upβ¦' : 'Sign up'}
</button>
</form>
);
}
π Testing a hook
Because a hook is just logic, you can test it without a full component using @testing-library/react's renderHook:
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
test('useToggle flips the value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.on).toBe(false);
act(() => result.current.toggle());
expect(result.current.on).toBe(true);
});
Best Practices & Quiz
β Do
- Name every custom hook with the
useprefix so the linter can protect you. - Return a stable, minimal interface β an array for
useState-like hooks, an object for many values. - Clean up subscriptions, timers, and requests in the effect's return function.
- Keep each hook focused; compose small hooks rather than writing one giant one.
β Don't
- Don't call hooks conditionally or inside loops β top level only.
- Don't expect a hook to share state between components; it shares logic.
- Don't omit effect dependencies to "make it work" β fix the real cause instead.
- Don't reinvent caching/retries by hand in production; use TanStack Query or SWR.
π― Quick Quiz
Question 1: What makes a function a valid React custom hook?
Question 2: If two components each call useToggle(), what do they share?
Question 3: Why use AbortController in a useFetch hook?
Summary
π Key Takeaways
- A custom hook is a
useβ¦function that calls other hooks to share stateful logic. - The Rules of Hooks β top level only, React functions only β keep call order stable.
- Hooks share logic, not state; each call gets its own independent state.
- Use AbortController to cancel stale fetches and avoid race conditions.
- Hooks compose like components, and can be tested in isolation with
renderHook.
π Further Reading
- React docs β Reusing logic with custom hooks
- React docs β Rules of Hooks
- useHooks.com β a library of ready-made hooks
- TanStack Query β production data fetching
π What's Next?
Some of the hooks you built wrapped handlers in useCallback. Next we'll go deep on why β and on useMemo and React.memo β in Performance Optimization with Memoization.
π Well done!
Your logic is now as reusable as your components. Time to make it fast.