React Fundamentals and Hooks
Most frontend interviews include React. The questions rarely test API trivia; they test whether you have the right model of rendering: what triggers a render, what React compares, when effects run, why keys matter, and what makes a component slow. This chapter builds that model. React snippets are shown as JSX and are illustrative; the pure-logic helpers are executed and checked.
1. The core model
UI is a function of state. A component is a function that takes props and state and returns a description of the UI (a tree of elements). React compares the new description with the previous one and updates the DOM minimally.
- Render means calling your component function to compute the description. It is not the same as updating the DOM.
- Commit is when React applies the differences to the DOM.
- A render must be a pure calculation: the same props and state give the same output, with no side effects (no fetching, no mutating external variables, no subscriptions) inside the function body. Side effects belong in event handlers and effects.
What triggers a re-render
<!--fig:renderloop-->- The component's state changes (via a state setter or reducer dispatch).
- Its parent re-renders (children re-render by default, even when their props are unchanged).
- A context it consumes changes.
Props changing is not a separate trigger: props change because a parent re-rendered. A change to a plain variable or a ref does not trigger a render.
2. JSX and components
JSX is syntax for React.createElement calls. Components are functions whose names start with a capital letter; lowercase names are DOM tags. A component returns a single tree (use a fragment <>...</> to avoid an extra wrapper). className and htmlFor replace class and for; handlers are camelCase (onClick). Expressions go in braces; JSX escapes interpolated strings, which protects against injection (until you use dangerouslySetInnerHTML).
3. State
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Clicked {count}</button>;
}
- State is a snapshot: within one render,
counthas a single value. CallingsetCount(count + 1)three times in one handler adds 1, not 3, because each call sees the samecount. - Use the functional update
setCount(c => c + 1)when the next state depends on the previous one: updates queue and each receives the result of the last. - Never mutate state. Create a new object or array so React sees a changed reference.
- Updates are batched: several
setStatecalls in one event produce one render. - Calling a setter with the same value (by
Object.is) bails out of re-rendering.
The queue semantics can be modelled and checked outside React:
// how React applies a queue of updates to one state value
function applyQueue(initial, queue) {
return queue.reduce((state, update) => (typeof update === "function" ? update(state) : update), initial);
}
assert.equal(applyQueue(0, [1, 1, 1]), 1); // setCount(count + 1) three times, with count = 0: lands on 1
assert.equal(applyQueue(0, [c => c + 1, c => c + 1, c => c + 1]), 3); // functional updates accumulate
assert.equal(applyQueue(5, [c => c + 1, 10]), 10); // a later plain value overrides
Immutable updates
const state = { user: { name: "Asha", tags: ["a"] }, count: 1 };
const next = { ...state, user: { ...state.user, tags: [...state.user.tags, "b"] } };
assert.equal(state.user.tags.length, 1); // the original is untouched
assert.notEqual(next.user, state.user);
assert.equal(next.count, state.count); // unchanged parts are shared by reference
const list = [1, 2, 3];
assert.deepEqual([...list, 4], [1, 2, 3, 4]); // add
assert.deepEqual(list.filter(x => x !== 2), [1, 3]); // remove
assert.deepEqual(list.map(x => (x === 2 ? 20 : x)), [1, 20, 3]); // update
4. Keys and reconciliation
React matches elements between renders by type and position, using a heuristic diff: different types mean tearing down and rebuilding the subtree; the same type means updating in place.
For lists, keys identify items across renders. A stable, unique key lets React move, reuse and preserve the state of the right component. Using the array index as a key breaks when items are inserted, removed or reordered: state (such as an input's text) stays with the position, not the item, so the wrong row keeps the wrong data.
{todos.map(t => <TodoRow key={t.id} todo={t} />)} // good: stable id
Keys also work as a reset button: changing a component's key makes React discard its state and mount a fresh instance (<Profile key={userId} />).
5. useEffect
useEffect synchronises a component with something outside React (a subscription, a network request, a timer, the DOM, a third-party widget). It runs after the commit.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // cleanup: runs before the next effect and on unmount
}, [tick]); // dependencies
- Dependencies: the effect re-runs when any listed value changes. Empty
[]means after mount only; omitted means after every render. List everything the effect reads (the lint rulereact-hooks/exhaustive-depsenforces it); missing dependencies cause stale closures. - Cleanup prevents leaks and stale responses.
- In development, StrictMode mounts, unmounts and remounts components to expose effects that are not idempotent or lack cleanup. Effects running twice in dev is deliberate, not a bug to suppress.
- You may not need an effect: derive values during render (
const fullName = first + " " + last), handle user actions in event handlers, reset state with akey, and fetch data with a data library or the framework's mechanism rather than hand-rolled effects.
Fetching in an effect, correctly
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${q}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(e => { if (e.name !== "AbortError") setError(e); });
return () => controller.abort(); // ignore the response if q changes or the component unmounts
}, [q]);
Stale closures
A function captures the values from the render in which it was created. An interval created with [] dependencies keeps reading the first render's count.
// a closure frozen at creation: what a stale effect sees
function makeStaleReader(count) { return () => count; }
let count = 0;
const reader = makeStaleReader(count); // created in "render 0"
count = 5; // later renders have a newer value
assert.equal(reader(), 0); // the captured value never updates
Fixes: add the dependency, use a functional update (setCount(c => c + 1)), or keep the latest value in a ref.
6. Other hooks
| Hook | Use |
|---|---|
useState | local state |
useReducer | complex state transitions as a pure (state, action) => state function |
useRef | a mutable value that persists across renders without triggering one; DOM element handles; previous values |
useMemo | cache an expensive computed value between renders |
useCallback | cache a function identity between renders |
useContext | read a context value without prop drilling |
useLayoutEffect | run synchronously after DOM mutation, before paint (measure and adjust layout) |
useId | stable unique IDs for accessibility attributes |
useTransition / useDeferredValue | mark updates as non-urgent so input stays responsive |
useSyncExternalStore | subscribe to external stores safely |
Custom hooks extract reusable stateful logic (useDebounce, useLocalStorage, useFetch). They are plain functions that call hooks, and each use has its own state.
Rules of hooks: call hooks only at the top level of a component or custom hook (not in loops, conditions or nested functions), because React identifies hooks by call order.
useReducer
function todoReducer(state, action) {
switch (action.type) {
case "add": return [...state, { id: action.id, text: action.text, done: false }];
case "toggle": return state.map(t => (t.id === action.id ? { ...t, done: !t.done } : t));
case "remove": return state.filter(t => t.id !== action.id);
default: throw new Error("unknown action " + action.type);
}
}
let s = [];
s = todoReducer(s, { type: "add", id: 1, text: "write" });
s = todoReducer(s, { type: "add", id: 2, text: "test" });
s = todoReducer(s, { type: "toggle", id: 1 });
assert.deepEqual(s.map(t => t.done), [true, false]);
assert.equal(todoReducer(s, { type: "remove", id: 2 }).length, 1);
assert.throws(() => todoReducer(s, { type: "nope" }));
Reducers are pure and trivially testable, and a good fit when several fields change together or the next state depends on an action type.
Custom hook: debounced value
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
7. Memoisation and performance
React.memo(Component) skips re-rendering when props are shallowly equal. useMemo and useCallback keep references stable so memoised children and effect dependencies behave.
// React.memo compares props shallowly, key by key, with Object.is
function shallowEqual(a, b) {
const ka = Object.keys(a), kb = Object.keys(b);
return ka.length === kb.length && ka.every(k => Object.is(a[k], b[k]));
}
const handler = () => {};
assert.ok(shallowEqual({ n: 1, onClick: handler }, { n: 1, onClick: handler }));
assert.ok(!shallowEqual({ n: 1, onClick: () => {} }, { n: 1, onClick: () => {} })); // a new function every render defeats memo
assert.ok(!shallowEqual({ list: [1] }, { list: [1] })); // so does a new array literal
Guidance:
- Measure first with the React DevTools Profiler. Memoisation has a cost and adds complexity.
- Typical real fixes: move state down to the component that needs it, lift content up (pass children as props so they do not re-render with the parent), split context, virtualise long lists, avoid creating large objects in render for memoised children, and defer expensive work with transitions.
- Do not memoise everything. Do memoise when a stable reference is genuinely required (an effect dependency, a memoised child's prop) or a calculation is measurably expensive.
- The React Compiler can insert memoisation automatically, reducing the need for manual
useMemoanduseCallback; check whether your codebase uses it.
8. Controlled and uncontrolled inputs
- Controlled: React state is the single source of truth (
valueandonChange). Easy validation and derived UI; re-renders on each keystroke. - Uncontrolled: the DOM holds the value; read it with a ref or
FormDataon submit. Less code and fewer renders for simple forms.
<input value={text} onChange={e => setText(e.target.value)} /> // controlled
<input defaultValue="hi" ref={inputRef} /> // uncontrolled
9. Context
Context passes values deep without prop drilling. Every consumer re-renders when the context value changes (by Object.is), so passing a new object literal each render re-renders all consumers. Memoise the value, split contexts by update frequency, or use a store with selectors for fast-changing state. Context suits low-frequency data: theme, locale, the current user.
10. Error boundaries, portals and Suspense
- Error boundaries (class components or libraries) catch errors in rendering below them and show a fallback. They do not catch errors in event handlers or async code.
- Portals render children into a different DOM node (modals, tooltips) while keeping React event bubbling and context.
- Suspense shows a fallback while children wait for code or data; combined with lazy loading (
React.lazy) and framework data APIs. - Server Components and hydration (framework-level): components that render on the server send HTML and little JavaScript; client components handle interactivity.
11. Common mistakes
- Mutating state or using state that is derivable (duplicated state that drifts out of sync).
- Index as key in dynamic lists.
- Missing or wrong effect dependencies, creating stale closures or infinite loops.
- Setting state in an effect that depends on that state without a guard, which loops.
- Effects for derived data or event-driven work.
- Expecting state updates to be immediate (
setCount(1); console.log(count)still shows the old value). - Overusing memoisation without measuring.
- Creating components inside components, which remounts them on every render and loses state.
- Missing cleanup for timers, listeners and requests.
12. Practice questions
- What causes a component to re-render? Does a state change always update the DOM?
- Why do keys matter, and why is the index a poor key for dynamic lists?
- Explain the dependency array. What is a stale closure and how do you fix it?
- Why does
setCount(count + 1)three times add only one? - When would you use
useReducer,useRef,useMemoanduseCallback? - How do you fetch data in an effect without race conditions?
- A parent re-renders often and a large child is slow. What are your options before reaching for
memo? - Controlled versus uncontrolled inputs: trade-offs?
- Why did my effect run twice in development?