Difficulty: Intermediate
Explain React hooks. When would you use useMemo vs useCallback? What are the rules of hooks?
Let's talk about hooks, because they fundamentally changed how we write React code, and understanding them deeply is one of the biggest differentiators between a junior and a mid-level React developer.
Before hooks existed (pre-React 16.8), if you wanted to use state or lifecycle methods, you had to write a class component. Function components were just "dumb" presentational components that took props and returned JSX. Hooks changed all that - they let you "hook into" React's state management and lifecycle features from plain functions. No more class syntax, no more this-binding confusion, no more splitting related logic across componentDidMount, componentDidUpdate, and componentWillUnmount.
Let's go through the key hooks one by one.
useState is the most basic hook. It gives you a state variable and a function to update it. When you call the setter, React schedules a re-render of that component with the new value. Simple as that. But there's a subtlety - state updates are asynchronous and batched. You don't get the new value immediately after calling the setter. And if you call the setter multiple times in the same event handler, React batches them into a single re-render.
useEffect is for side effects - anything that reaches outside the pure world of rendering. Data fetching, setting up event listeners, manually changing the DOM, logging, timers - all side effects. The mental model here is crucial: useEffect runs after the component renders and the browser has painted. It does not block the screen update. You give it a function to run, and optionally a dependency array that tells React when to re-run it. Empty array means run once on mount. No array means run after every single render. Specific dependencies means run only when those values change. And the cleanup function (the function you return from useEffect) runs before the effect re-runs and when the component unmounts. This is where you remove event listeners, cancel subscriptions, clear timers - basically undo whatever your effect set up.
useRef gives you a mutable container that persists across renders without triggering re-renders when you change it. Two primary uses: grabbing a reference to a DOM element (like focusing an input), and storing any mutable value that you need to survive re-renders but shouldn't cause re-renders when it changes (like a timer ID, a previous value, or a render count).
Now, useMemo and useCallback - these are the ones that confuse people, so let's be really clear. They're both about memoization, but they memoize different things.
useMemo memoizes a computed value. You give it a function that computes something expensive, and a dependency array. React runs that function and remembers the result. On future renders, if the dependencies haven't changed, React skips the computation and gives you the cached result. Think of it like caching the answer to a math problem. The classic example is computing a filtered or sorted list from a large dataset - you don't want to redo that work on every keystroke in an unrelated input field.
useCallback memoizes a function reference. This one's trickier to understand why it matters. In JavaScript, every time a component renders, any function defined inside it is recreated as a brand new function object. That's fine most of the time. But if you pass that function as a prop to a child component wrapped in React.memo, the child will re-render every time because it sees a "new" function prop (different reference, even though the code is identical). useCallback solves this by giving you a stable function reference that only changes when its dependencies change.
Here's a practical way to think about it: useMemo is "remember this value," useCallback is "remember this function." In fact, useCallback(fn, deps) is literally equivalent to useMemo(() => fn, deps).
But here's critical advice: don't reach for useMemo and useCallback by default everywhere. They have their own cost - React has to store the cached value, compare dependencies on every render, and manage the cache. For simple computations or components that rarely re-render, the memoization overhead can actually be worse than just re-computing the value. Use them when you have a measurable performance problem, when a computation is genuinely expensive, or when you need referential stability for React.memo or useEffect dependencies.
useReducer is another important one. It's an alternative to useState for when you have complex state logic. Instead of calling a setter directly, you dispatch actions to a reducer function that computes the next state. If you've used Redux, the pattern is identical. It shines when multiple state values are related and need to update together, or when the state transition logic gets complex.
Now, the Rules of Hooks. These are not suggestions - they're requirements that React enforces, and violating them leads to subtle, hard-to-debug bugs.
Rule 1: Only call hooks at the top level. Never inside loops, conditions, or nested functions. This is because React relies on the order in which hooks are called to match up state with the right hook. If you put a useState inside an if-block, the number of hooks called might differ between renders, and React's internal bookkeeping falls apart completely.
Rule 2: Only call hooks from React function components or custom hooks. Don't call them from regular JavaScript functions, class components, or event handlers. The eslint-plugin-react-hooks package enforces both rules and should always be enabled in your project.
One more thing worth mentioning: the stale closure problem. This is the single most common bug with hooks, and it comes up in interviews constantly. When you create a function inside useEffect with an empty dependency array, that function captures the initial values of all variables from the surrounding scope. If state changes later, the function still sees the old values. The fix is either to add the variable to the dependency array (so the effect re-runs with fresh values) or use functional updates (passing a function to the setter, like setCount(prev => prev + 1), which always gets the latest value without needing the variable in scope).
import { useState, useMemo, useCallback } from 'react';
function ProductList({ products, tax }) {
// useMemo: memoize a COMPUTED VALUE
const total = useMemo(() => {
console.log('Computing total...');
return products.reduce((sum, p) => sum + p.price, 0) * (1 + tax);
}, [products, tax]);
// useCallback: memoize a FUNCTION REFERENCE
const handleSort = useCallback((field) => {
console.log('Sorting by', field);
// sort logic here
}, []); // empty deps = stable reference
return (
<div>
<p>Total: ${total.toFixed(2)}</p>
<SortButton onSort={handleSort} />
</div>
);
}
// useCallback matters when passing to memoized children
const SortButton = React.memo(({ onSort }) => {
console.log('SortButton rendered');
return <button onClick={() => onSort('price')}>Sort</button>;
});
useMemo returns a memoized value (avoids expensive recomputation). useCallback returns a memoized function (prevents child re-renders when used with React.memo).
import { useState, useEffect } from 'react';
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);
// Cleanup function - runs on unmount
// and before each re-run of the effect
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // empty deps = only run once
return size;
}
The cleanup function prevents memory leaks. It runs when the component unmounts AND before the effect re-runs (if deps change).
function Timer() {
const [count, setCount] = useState(0);
// BUG: count is always 0 in the callback
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always 0!
setCount(count + 1); // always sets to 1
}, 1000);
return () => clearInterval(id);
}, []); // empty deps = stale closure
// FIX: use functional update
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // always correct
}, 1000);
return () => clearInterval(id);
}, []);
return <p>Count: {count}</p>;
}
With empty deps, the effect closure captures count = 0 forever. Functional updates (prev => prev + 1) don't need the current value in the closure.
useState, useEffect, useRef, useMemo, useCallback, Rules of Hooks