“Two components call the same useCart() hook. Do they share the cart?” Candidates who say yes have confused custom hooks with context or a store. The interviewer then usually asks how they would design a reusable hook so other engineers cannot misuse it.
This topic tests two things at once: whether you know how hooks actually work (they are function calls that run inside the calling component’s render), and whether you can design a small API with clear inputs, outputs and guarantees.
Before you start
You should know the built-in hooks (useState, useEffect, useRef, useCallback) and the Rules of Hooks: call hooks only at the top level of a component or another hook, never inside conditions, loops or regular functions. If you have ever seen an invalid hook call error, you have met the consequences of breaking them. Applies to React 18 and 19. React 19’s use API is the one exception that may be called conditionally.
The short answer
A custom hook is a function whose name starts with use and which calls other hooks. It shares logic, not state: each component that calls it gets its own independent state, exactly as if the hook’s body were pasted into that component. To share actual data between components, lift state up, use context, or use an external store. A good custom hook has a clear contract: what it takes, what it returns, which returned values are stable, and what happens when its inputs change.
How it works
React identifies hook state by call order within a component. When Counter calls useToggle, which calls useState, that state slot belongs to Counter’s fiber:
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
return [on, toggle];
}
function A() { const [on, toggle] = useToggle(); /* own state */ }
function B() { const [on, toggle] = useToggle(); /* separate state */ }Toggling in A does nothing to B. The use prefix is not decoration: it tells the linter (eslint-plugin-react-hooks) and the React Compiler that this function follows the Rules of Hooks, so they can check its callers. A function that calls no hooks should not be named useSomething; make it a plain helper.
Step-by-step walkthrough
Step 1: Extract the duplicated logic, not the JSX
Suppose two components track online status with identical effect code. Pull just the stateful part out:
function useOnlineStatus() {
const [online, setOnline] = useState(() => navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
return online;
}For browser stores like this, useSyncExternalStore is even better because it is safe with concurrent rendering and server rendering; see external store subscriptions.
Step 2: Define the inputs and how changes are handled
Decide what the hook should do when an argument changes. A data hook should refetch when the URL changes and ignore stale responses:
function useJson(url) {
const [state, setState] = useState({ status: 'loading', data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(url, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => setState({ status: 'success', data, error: null }))
.catch(error => {
if (error.name !== 'AbortError') setState({ status: 'error', data: null, error });
});
return () => controller.abort();
}, [url]);
return state;
}The contract here is: url is a string, changing it restarts the request, and late responses from old URLs are discarded.
Step 3: Make returned functions and objects stable
Callers will put your return values in dependency arrays. If you return a new function every render, their effects re-run every render:
function useCounter(step = 1) {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount(c => c + step), [step]);
const reset = useCallback(() => setCount(0), []);
return useMemo(() => ({ count, increment, reset }), [count, increment, reset]);
}reset never changes, increment changes only with step, and the object identity changes only when one of them does. With the React Compiler, much of this is generated for you, but documenting the guarantee still matters.
Step 4: Be explicit about callback inputs
If your hook accepts a callback and uses it in an effect, an inline function from the caller would re-run your effect every render. Either document “pass a stable callback”, or store the latest callback so callers do not have to care. In React 19.2 and later, useEffectEvent handles this inside the hook:
function useInterval(callback, ms) {
const onTick = useEffectEvent(callback);
useEffect(() => {
const id = setInterval(() => onTick(), ms);
return () => clearInterval(id);
}, [ms]);
}The interval restarts only when ms changes, yet always calls the caller’s latest callback. On earlier versions, a ref updated in a layout effect serves the same purpose.
Worked scenario
A team writes useCart() to share cart logic between the header badge and the checkout page:
function useCart() {
const [items, setItems] = useState([]);
const add = item => setItems(prev => [...prev, item]);
return { items, add };
}Adding an item on the product page never updates the header badge. Each component called useCart, so each has its own empty items. The hook shared code, not data.
The fix keeps the hook as the public API but moves the state into a provider:
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const add = useCallback(item => setItems(prev => [...prev, item]), []);
const value = useMemo(() => ({ items, add }), [items, add]);
return <CartContext value={value}>{children}</CartContext>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used inside <CartProvider>');
return ctx;
}Components still call useCart(), but now they read one shared cart. The error message turns a confusing silent bug into an obvious setup mistake. (On React 18, use CartContext.Provider.)
Common mistake
- “Custom hooks share state.” They share logic. Each call has independent state.
- Calling a hook conditionally inside the custom hook.
if (enabled) useEffect(...)breaks call order. Put the condition inside the effect instead. - Naming a plain utility
useFormatDate. It misleads readers and the linter. Only functions that call hooks get the prefix. - Returning unstable objects and functions. Callers using them as effect dependencies get infinite re-runs or reconnects.
- Hooks that wrap a single effect with a lifecycle name like
useMount. They hide dependencies and encourage effects that ignore changes.
Verify the behavior
Render two components that use the same hook and confirm state is independent, then test the hook in isolation with React Testing Library:
import { renderHook, act } from '@testing-library/react';
test('useCounter returns stable reset and independent state', () => {
const { result, rerender } = renderHook(() => useCounter(2));
const firstReset = result.current.reset;
act(() => result.current.increment());
expect(result.current.count).toBe(2);
rerender();
expect(result.current.reset).toBe(firstReset);
const other = renderHook(() => useCounter(2));
expect(other.result.current.count).toBe(0);
});The identity assertion turns your stability promise into a test, and the second renderHook proves that calls do not share state.
Follow-up questions
Can a custom hook return JSX? It can, but then it is really a component in disguise. Prefer returning data and handlers and let the component render.
Can custom hooks call other custom hooks? Yes; composition is the point. The Rules of Hooks apply all the way down.
How do you share state without context? Lift it to a common parent, or use an external store read through useSyncExternalStore, which also allows selecting slices.
What changes with React 19’s use? use(promise) and use(context) can be called conditionally, so a hook may branch around them, but other hooks inside it still follow the ordering rule.
Interview exercise
Review this hook and list the contract problems:
function useSearch(query, onResults) {
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) return;
fetch('/api?q=' + query).then(r => r.json()).then(d => {
setResults(d);
onResults(d);
});
}, [query, onResults]);
return { results, clear: () => setResults([]) };
}Answer and reasoning
First, onResults is a dependency, so a caller passing an inline function re-runs the fetch on every render; the hook should either document that it must be stable or wrap it in useEffectEvent (or a latest-ref). Second, there is no cleanup, so a slow response for an old query can overwrite results for a newer one; use an AbortController or an ignore flag. Third, query is not URL-encoded. Fourth, clear and the returned object are new every render, so callers cannot rely on them as dependencies; wrap them in useCallback and useMemo. Finally, clearing query leaves the old results visible, which may or may not be intended and should be documented.
Continue learning
Keep practising with the React interview questions and the React MCQs. Related notes: useCallback and captured values, invalid hook call and context scope. Official reference: Reusing Logic with Custom Hooks on react.dev.