use and can call other hooks inside them. They let you extract reusable stateful logic out of components so multiple components can share the same behavior without duplicating code. Each component that calls a custom hook gets its own independent copy of the state — custom hooks share logic, not state. The use prefix is not just a convention; it signals to React's linting tools that this function follows the Rules of Hooks.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handle = () => setIsOnline(navigator.onLine);
window.addEventListener('online', handle);
window.addEventListener('offline', handle);
return () => {
window.removeEventListener('online', handle);
window.removeEventListener('offline', handle);
};
}, []);
return isOnline;
}
Custom hooks share logic, not state. Each component calling the hook gets its own independent copy of the state.
Why it matters: Custom hooks let you extract and reuse stateful logic across components without duplicating code or using HOCs.
Real applications: useFetch for data loading, useForm for form state, useDebounce for search inputs — all reusable across many components.
Common mistakes: Thinking a custom hook shares state between components — each call gets its own isolated state.
useToggle is a simple but highly useful custom hook that wraps a boolean state value with a flip function. Instead of writing setIsOpen(!isOpen) in every component that needs a toggle, you extract it once and reuse it everywhere. It uses the useState functional updater form (prev => !prev) which is safer than reading the current state directly when batching is involved. This is a perfect example of how custom hooks eliminate repetitive boilerplate across components.
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}
// Usage
function Modal() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<>
<button onClick={toggleOpen}>{isOpen ? 'Close' : 'Open'}</button>
{isOpen && <div className="modal">Content</div>}
</>
);
}
Why it matters: Toggle logic appears everywhere — modals, dropdowns, accordion sections. A custom hook removes the repeated useState + handler pattern.
Real applications: Modal open/close, showing/hiding a password, collapsing a sidebar, toggling a feature flag.
Common mistakes: Reimplementing toggle logic inline in every component instead of reusing a hook — small duplication that adds up fast.
useFetch encapsulates the complete data fetching lifecycle — loading, data, and error states — into a reusable hook that any component can use with a single call. Internally it uses useEffect with the URL as a dependency, runs an async function with an AbortController for cleanup, and manages three state values. Extracting this into a hook means every component doing data fetching gets consistent loading and error handling without duplicating the same useEffect boilerplate. Pass null as the URL to conditionally skip fetching.
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => 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 };
}
The cancelled flag prevents state updates on unmounted components when the URL changes rapidly.
Why it matters: Fetch logic with loading, error, and data states is needed in almost every component that displays server data — wrapping it in a hook saves a lot of repetition.
Real applications: Loading user profiles, fetching a list of posts, getting product details — any data-fetching pattern in your app.
Common mistakes: Not including the cancel/cleanup logic, leading to "state update on unmounted component" warnings when the user navigates away mid-fetch.
useLocalStorage extends React state to be persisted across page reloads by syncing with the browser's localStorage. It reads the initial value from localStorage using a lazy initializer so the parse only runs once. On every setter call it updates both the React state and localStorage simultaneously. A full implementation also listens for the browser's storage event to keep multiple tabs in sync when the value changes in another tab.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');
The lazy initializer in useState avoids reading localStorage on every render — it runs only on mount.
Why it matters: Persisting state to localStorage and reading it back on load is a common need. A hook makes this reusable with any key and value type.
Real applications: Saving a user's theme preference, remembering a sidebar open/closed state, storing a draft message across page refreshes.
Common mistakes: Not JSON-parsing the stored value, getting a string back when you stored an object or number.
useDebounce delays propagating a value until a specified time has passed since the last change, preventing rapid consecutive updates from triggering expensive downstream work. Internally it uses useEffect with a setTimeout and clears it in the cleanup function on every new change — so only the value from after the user stops typing is propagated. This is the standard pattern for search-as-you-type inputs where you want to wait for the user to finish before sending an API request. The delay (300–500ms) is configurable.
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage — search input
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
}
This avoids firing API calls on every keystroke, improving performance and reducing server load.
Why it matters: Debouncing is a common need in search inputs and autocomplete. A reusable hook removes the repeated timer logic.
Real applications: Search-as-you-type, address autocomplete, live filtering of a large dataset.
Common mistakes: Not clearing the timeout in the cleanup, letting stale requests fire after the component unmounts.
use — this is what tells React's lint plugin to enforce hook rules for this function. Second, hooks can only be called at the top level of the custom hook, not inside loops, conditions, or nested functions. Third, custom hooks can only be called from other React function components or custom hooks — not from plain JavaScript utilities. Breaking these rules leads to unpredictable state bugs.
// ✅ Correct custom hook
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handle = () => setWidth(window.innerWidth);
window.addEventListener('resize', handle);
return () => window.removeEventListener('resize', handle);
}, []);
return width;
}
// ❌ Wrong — not prefixed with "use"
function getWindowWidth() { /* hooks inside won't work properly */ }
// ❌ Wrong — conditional hook call
function useData(flag) {
if (flag) { useState(0); } // breaks hook order
}
Why it matters: Custom hooks must follow all the rules of hooks — they call other hooks internally, so the same restrictions apply.
Real applications: Every custom hook you write — always prefix with "use", always call at the top level, never inside conditions or loops.
Common mistakes: Calling a hook conditionally inside your custom hook, breaking the stable hook call order React depends on.
useState multiple times in different components. Two components both calling useCounter(0) each get their own count variable that updates independently. This is a key distinction from Context: Context shares the same value across consumers, while custom hooks give each consumer its own private copy.
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
// Two independent counters
function App() {
const counter1 = useCounter(0);
const counter2 = useCounter(10);
// counter1 and counter2 have completely separate state
}
This is the key distinction from context or global state — custom hooks reuse behavior, not data.
Why it matters: Two components sharing the same custom hook each get their own independent state — the hook is a behavior template, not shared storage.
Real applications: Multiple search inputs on the same page each using useDebounce; multiple forms each using useForm.
Common mistakes: Expecting custom hooks to sync state between components — for shared state you need Context or a state management library.
renderHook from @testing-library/react, which mounts the hook in an isolated test environment without needing a wrapping component. Use act to wrap any state-updating calls so React processes all updates before your assertions run. The result.current property of the returned object holds the hook's current return value. This approach lets you test all the hook's state transitions and side effects in isolation, separate from any UI rendering.
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('should increment counter', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Wrap state updates in act() to ensure React processes them. renderHook lets you test hooks in isolation without creating wrapper components.
Why it matters: Testing custom hooks ensures the reusable logic works correctly, which protects every component that relies on it.
Real applications: Unit-testing useFetch, useLocalStorage, or useDebounce in isolation to verify they behave correctly before using them across the app.
Common mistakes: Not wrapping state-changing calls in act() — React will warn that updates happened outside of act and tests become unreliable.
useWindowSize tracks the browser window's current width and height and triggers a re-render when the user resizes the window. Internally it initializes with window.innerWidth and window.innerHeight, attaches a 'resize' event listener in useEffect, and removes it in the cleanup. This is more reliable than CSS media queries for logic that changes component behavior (like switching between a table and a card layout) rather than just styling, since it gives you the actual numbers in JavaScript.
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Usage
const { width, height } = useWindowSize();
return width < 768 ? <MobileNav /> : <DesktopNav />;
Why it matters: Responsive layout logic based on window size is needed in many components. A shared hook avoids duplicating the resize listener setup everywhere.
Real applications: Showing a mobile or desktop nav, adjusting grid columns, toggling a compact or full view.
Common mistakes: Adding resize listeners directly in every component that needs window size — this creates many duplicate listeners instead of one.
useEventListener wraps the boilerplate of attaching and removing DOM event listeners into a reusable hook. Instead of writing addEventListener and removeEventListener inside useEffect in every component, you call useEventListener('click', handler, element). It uses a ref to store the handler so you can pass an unstabilized function without the listener being re-attached on every render. The cleanup is automatic — the listener is removed whenever the element or event name changes or the component unmounts.
function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef();
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const listener = (event) => savedHandler.current(event);
element.addEventListener(eventName, listener);
return () => element.removeEventListener(eventName, listener);
}, [eventName, element]);
}
// Usage
useEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
The ref pattern ensures the latest handler is always called without needing to re-attach the listener when the handler changes.
Why it matters: Attaching event listeners to window or document consistently without memory leaks is repetitive — a hook handles the cleanup automatically.
Real applications: Global keyboard shortcuts, click-outside detection, listening for online/offline events.
Common mistakes: Not using a ref for the handler — the listener will be stale and use an old closure of the handler.
usePrevious stores the value from the previous render using a ref that updates after each render (inside useEffect). During render, the ref still holds the old value — so the hook correctly returns the previous value even while the current value has already changed. This is useful for comparing current and previous values to detect transitions, animate changes, or implement undo-like behavior. The ref approach is key: updating a ref doesn't trigger a re-render, so it doesn't create an infinite loop.
function usePrevious(value) {
const ref = useRef(undefined);
useEffect(() => {
ref.current = value; // update AFTER render
});
return ref.current; // return OLD value during this render
}
// Usage — show what changed
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>Now: {count}, Before: {prevCount ?? 'N/A'}</p>
);
}
Because useEffect runs after painting, the ref still holds the old value during the render, and updates to the new value for the next render.
Why it matters: Tracking the previous value of a prop or state is useful for animations, comparisons, and debugging what changed.
Real applications: Animating a counter up or down based on whether the value increased or decreased, showing a diff of before/after values.
Common mistakes: Trying to store previous values in useState — it causes extra renders and adds complexity compared to a single ref.
useMediaQuery lets components respond to CSS media query changes by providing the match result as a boolean. Internally it uses the browser's window.matchMedia() API and listens to the 'change' event to update state when the viewport crosses the breakpoint. This enables JavaScript-driven responsive behavior — like conditionally rendering a mobile menu vs. a desktop nav — without relying on CSS alone. Unlike CSS media queries, the boolean is available in component logic, useEffect dependencies, and conditional rendering.
function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileNav /> : <DesktopNav />;
This cleanly abstracts the MediaQueryList API so components stay declarative and unaware of browser API details.
Why it matters: Responding to CSS media queries in JavaScript logic (like showing different components) is needed in many places — a hook avoids repeating the listener setup.
Real applications: Detecting dark mode preference, checking for a touch screen, showing or hiding elements at specific breakpoints.
Common mistakes: Not removing the media query listener in the cleanup function, causing a leak when the component unmounts.
useOnClickOutside detects when the user clicks or taps anywhere outside a specific element, which is the standard way to close dropdowns, modals, popovers, and context menus. Internally it attaches a 'mousedown' or 'touchstart' event listener to the document and checks if the click target is outside the ref using !ref.current.contains(event.target). Using mousedown instead of click ensures the handler fires before the element loses focus, preventing race conditions with onBlur handlers.
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (e) => {
if (!ref.current || ref.current.contains(e.target)) return;
handler(e); // click was outside
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// Usage
const ref = useRef();
useOnClickOutside(ref, () => setOpen(false));
return <div ref={ref}>Dropdown content</div>;
The check ref.current.contains(e.target) ensures clicks inside the element don't trigger the handler.
Why it matters: Click-outside detection is used in almost every dropdown, popover, and modal to close them when the user clicks elsewhere.
Real applications: Closing a dropdown menu, dismissing a tooltip, hiding a color picker when clicking outside it.
Common mistakes: Attaching the listener to the document without checking if the click was inside the element — this closes the element as soon as it opens.
useAsync wraps any async function with consistent loading, error, and data states so the same pattern doesn't need to be re-implemented everywhere. Pass it an async function and a dependency array — it calls the function, tracks the pending/fulfilled/rejected states, and returns them as { data, loading, error }. A critical internal detail: the hook uses a cancellation flag to prevent setting state if the component unmounts before the async function resolves. This is the building block for higher-level data fetching abstractions.
function useAsync(asyncFn, deps = []) {
const [state, setState] = useState({ loading: true, data: null, error: null });
useEffect(() => {
let cancelled = false;
setState({ loading: true, data: null, error: null });
asyncFn()
.then(data => { if (!cancelled) setState({ loading: false, data, error: null }); })
.catch(error => { if (!cancelled) setState({ loading: false, data: null, error }); });
return () => { cancelled = true; };
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
return state;
}
// Usage
const { loading, data, error } = useAsync(() => fetchUser(userId), [userId]);
The cancelled flag prevents state updates after unmount, avoiding memory leak warnings.
Why it matters: A generic async hook that tracks loading, data, and error state can replace repeated boilerplate across every data-fetching component.
Real applications: Wrapping any Promise-returning function — API calls, file reads, database queries — with consistent loading/error state.
Common mistakes: Not handling the error case, leaving the loading state as true forever when the async function throws.