React — state, effects, keys, perf
📖 Walk me through it — plain English
React is a JavaScript library for building user interfaces out of components — small, reusable functions that each return a chunk of UI (a button, a form, a whole page). The big idea: instead of you manually poking the page to update text when data changes, you describe what the UI should look like for a given set of data, and React figures out the minimal changes to make to the real page. The data that can change over time is called state. When state changes, React re-runs your component function and updates the screen. This lesson is the cluster of things interviewers love to probe: where state should live, the right and wrong uses of useEffect, why list keys matter, and when speeding things up with memoization actually helps.
An everyday analogy: think of a component as a recipe card, and state as the ingredients on the counter. You don't rewrite the finished dish bite by bite when an ingredient changes — you just hand the recipe and the current ingredients to a cook (React), and the cook re-makes the dish. Your job is to (1) keep each ingredient on the counter closest to the recipe that actually uses it, and (2) not ask the cook to redo work that didn't change.
Let's walk the four ideas an interviewer will push on, slowly:
- Where state lives. Start by keeping a piece of state local — inside the one component that uses it. Only "lift it up" to a shared parent when two sibling components genuinely need the same value. Hoisting everything into global Context (React's built-in way to share data widely) "just in case" makes the app harder to follow and re-render more than necessary. Rule of thumb: state lives at the lowest place that still covers everyone who reads it.
- useEffect is for syncing with the outside world, not "run after render." An effect is code React runs after it has painted the screen. The right uses are talking to systems React doesn't control: fetching data from a server, subscribing to a websocket, or calling a raw browser API. The classic beginner mistake is using an effect to compute a value from props/state (the inputs your component receives or holds) — if a value can be calculated from what you already have, just calculate it during render, no effect needed. That's the "you might not need useEffect" check.
- The dependency array. When you write an effect, you give it a list of values it depends on, like
[userId]. React re-runs the effect whenever something in that list changes. Forget a value it uses and you get a stale closure — the effect keeps using an old, frozen copy of that value (a "closure" just means a function remembering the variables around it when it was created). List too much and the effect fires constantly. The ESLint rulereact-hooks/exhaustive-depstells you the correct list; treat it as a rule, not a suggestion. - Keys. When you render a list, React needs a way to tell which item is which between renders, so it gives each one a
key. Use something stable and unique, like a database id — not the array index — whenever the list can reorder or have items inserted. With index keys, if you delete the first item every other item's key shifts by one, and React thinks the wrong rows changed: focus jumps, animations break, and per-row state lands on the wrong row.
Why "don't sprinkle memo everywhere"? Each time a parent re-renders, its children re-render too. React.memo (skip re-rendering a child if its inputs are unchanged), useMemo (cache an expensive calculation), and useCallback (cache a function so it stays the same object between renders) all add bookkeeping: React must store the old value and compare. That trade only pays off when the child is genuinely expensive and the parent re-renders often. Used reflexively, the comparison cost is bigger than the work you saved — so reach for them after you've measured a real slowdown, not before.
If you're doing frontend or full-stack interviews in 2026, React is the default. They probe state ownership, useEffect mistakes, list keys, and when memoization actually helps.
The one idea underneath everything: UI as a function of state
Before any of the buzzwords, hold onto one sentence: in React, your screen is a function of your data. Mathematically, UI = f(state). You don't write step-by-step instructions like "find the counter element, read its text, add one, write it back." Instead you write a function that says "given the number count, the screen should show this." When count changes, React calls your function again and reconciles the difference onto the real page. You describe the destination; React handles the journey. Everything below — state, effects, keys, memo — is detail around that single contract.
Let's pin down the vocabulary precisely, because interviewers grade on whether you use these words correctly:
- Component — a JavaScript function whose name is Capitalized and that returns a description of UI. You call it by writing it like an HTML tag:
<Counter />. It is the unit of reuse and the unit of re-rendering. - JSX — the HTML-looking syntax inside a component, e.g.
<button>Click</button>. It is not a string and not real HTML; a build tool compiles it into plain function calls (React.createElement(...)) that return lightweight description objects. Curly braces{ }drop JavaScript values into JSX:<p>{count}</p>. - Props — the inputs a parent passes down to a child, like function arguments:
<Avatar size={40} />. Props are read-only inside the child — a child never edits its own props. - State — data a component owns and can change over time. Unlike props, state survives across re-renders, and changing it is what triggers a re-render.
- Hook — a special function whose name starts with
use(useState,useEffect,useMemo…) that lets a function component "hook into" React features like state and lifecycle. Hooks must be called at the top level of a component, in the same order every render — never inside anifor a loop. - Re-render — React calling your component function again to get a fresh UI description. A re-render does not necessarily touch the DOM; it just recomputes what the DOM should be.
- Virtual DOM — the in-memory tree of those lightweight description objects React builds each render. It is cheap to create and compare, unlike the real browser DOM which is slow to mutate.
- Reconciliation — the diffing step where React compares the new virtual DOM tree against the previous one and computes the minimal set of real-DOM changes (this text changed, that node was added). This is why you can re-render freely without the page thrashing.
useState, line by line: a counter
useState is the hook that gives a component a piece of state. You call it with the initial value, and it hands back an array of exactly two things: the current value, and a function to change it. By convention you destructure them as [value, setValue]. Here is the smallest meaningful example — a button that counts clicks — with every line explained:
import { useState } from 'react';
function Counter() {
// useState(0): start count at 0. Returns [current value, setter function].
const [count, setCount] = useState(0);
// This function runs when the button is clicked.
function handleClick() {
// setCount tells React "the new count is count + 1".
// It does NOT change count immediately — it SCHEDULES a re-render.
setCount(count + 1);
}
// What the screen should look like for the current count:
return (
<button onClick={handleClick}>
Clicked {count} times
</button>
);
}
Trace the lifecycle once: React calls Counter(), useState(0) returns [0, setCount], the button renders "Clicked 0 times." You click. handleClick runs setCount(1), which schedules a re-render — the local count variable in this run is still 0 (it was a snapshot frozen for this render). React then calls Counter() again; this time useState(0) returns [1, setCount] (React remembers the latest value, ignoring the 0 argument after the first render), and the button now reads "Clicked 1 times." That is UI = f(state) in motion: you never wrote DOM code, you just declared the relationship between count and the text.
Updater form for safety: when the new value depends on the old one, prefer setCount(c => c + 1) over setCount(count + 1). The function form receives the freshest value React has, so it stays correct even if several updates batch together in one click. setCount(count + 1) three times in a row only adds one (all three read the same stale snapshot); setCount(c => c + 1) three times adds three.
State is immutable: never mutate, always replace
React decides whether to re-render by checking if the new state value is a different object/reference than the old one. If you mutate (change in place) an existing array or object, the reference is the same, so React may see "nothing changed" and skip the update — a confusing class of bugs where your data changed but the screen didn't. The rule: treat state as read-only and create a new value instead.
const [items, setItems] = useState(['a', 'b']);
// WRONG — mutates the existing array; same reference, React may not re-render
items.push('c');
setItems(items);
// RIGHT — build a brand-new array with the spread operator (...)
setItems([...items, 'c']); // copies old items, adds 'c', new reference
The same applies to objects: use setUser({ ...user, name: 'Tony' }), not user.name = 'Tony'. "Copy, then change the copy" is the React reflex.
useEffect, line by line: an effect with cleanup
An effect is code that reaches outside React's world — network, timers, browser APIs, subscriptions — and React runs it after painting. useEffect takes two arguments: a function (the effect) and a dependency array (the list of values that, when changed, should re-run the effect). The effect function may return a second function — the cleanup — which React runs before the next effect run and when the component unmounts (leaves the screen). Cleanup is how you undo a subscription or cancel a timer so nothing leaks. Here is a timer, fully annotated:
import { useState, useEffect } from 'react';
function Clock() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// EFFECT: runs after the first paint (because deps are []).
const id = setInterval(() => {
setSeconds(s => s + 1); // updater form — always reads the freshest value
}, 1000);
// CLEANUP: returned function runs on unmount (and before a re-run).
// Without this, the interval keeps firing after the component is gone.
return () => clearInterval(id);
}, []); // [] = "no dependencies" = run once on mount, clean up on unmount
return <p>Elapsed: {seconds}s</p>;
}
The dependency array is the whole control panel:
[](empty) — run the effect once after mount; cleanup runs on unmount. Good for "subscribe once."[userId]— run after mount, then again every timeuserIdchanges. Before each re-run, the previous cleanup fires. Good for "refetch when the id changes."- omitted entirely — run after every render. Almost always a mistake; usually means you didn't need an effect.
Fetch with cleanup: when an effect fetches data, cancel it in cleanup so a fast navigation doesn't set state on a component that's already gone. Use an AbortController: create one, pass controller.signal to fetch, and return () => controller.abort(). This is the modern answer to "what if the user leaves mid-fetch?"
Lifting state up
Lifting state up means moving a piece of state from a child into the nearest common parent so two children can share it. Because data flows down through props, two siblings can't see each other's state directly — but if the parent owns it and passes both the value and a setter down as props, they stay in sync. Concretely: a search box and a results list both need the query string, so the query lives in their shared parent; the box gets value + onChange, the list gets the filtered data. Lift only when sharing is real — keeping state local until then keeps re-renders small and components easy to read.
Controlled vs uncontrolled inputs
A controlled component is a form input whose value is driven by React state: you set value={text} and update state in onChange. React is the single source of truth, so you can validate, format, or disable a button on every keystroke. An uncontrolled input instead lets the DOM hold its own value, and you read it only when needed via a ref (a handle to the underlying DOM node). Rule of thumb: forms that validate or react as the user types should be controlled; a simple "grab the value on submit" input is fine uncontrolled.
// Controlled: React state IS the value
const [text, setText] = useState('');
<input value={text} onChange={e => setText(e.target.value)} />
Memoization: useMemo, useCallback, React.memo
Memoization is caching a result so you don't recompute it when the inputs haven't changed. React gives you three flavors, and the interview gold is knowing they are opt-in optimizations, not defaults:
- useMemo(fn, deps) — caches the return value of an expensive calculation; recomputes only when something in
depschanges. Use for genuinely heavy work (sorting/filtering a big list), nota + b. - useCallback(fn, deps) — caches a function so it keeps the same identity (reference) across renders. Matters because a freshly-created function counts as "a new prop," which would defeat a memoized child.
- React.memo(Component) — wraps a component so it skips re-rendering when its props are unchanged (compared shallowly, by reference). Pair it with
useCallback/useMemofor the props, or the comparison never sees "unchanged."
Why not memo everything? Each of these stores the old value and runs a comparison on every render. For cheap work that comparison costs more than just recomputing. The honest workflow: write it plainly, profile with React DevTools, and apply memo only at the specific boundary you measured to be slow.
Four pitfalls interviewers fish for
An effect or callback "remembers" the value of a variable from the render it was created in. If you read count directly inside a long-lived callback with [] deps, it stays frozen at the initial value. Fix: use the updater form setCount(c => c + 1), or add the value to the dependency array.
No key on list items (or using the array index) breaks React's identity tracking. On reorder/insert, focus jumps, inputs keep the wrong text, animations glitch. Fix: a stable unique id, e.g. key={item.id}.
Lying about deps (e.g. [] when the effect reads userId) freezes stale data; listing unstable objects fires the effect every render. Fix: trust react-hooks/exhaustive-deps; stabilize functions/objects with useCallback/useMemo.
arr.push(x) or obj.k = v changes state in place; the reference is unchanged so React may skip the re-render. Fix: copy first — [...arr, x], { ...obj, k: v }.
Start with local state. Lift only when two siblings need it. Don't hoist to Context "just in case."
It's for syncing external systems (fetch, subscription, DOM API). Not for "run after render." If the value depends on props/state, derive it during render.
Missing dep = stale closure. Excess deps = effect fires too often. ESLint react-hooks/exhaustive-deps is the rule, not a suggestion.
Stable, unique, NOT array index when the list can reorder/insert. Wrong key = lost focus, broken animations, mis-applied state.
React.memo / useMemo / useCallback help when a child is expensive AND the parent re-renders often. Sprinkling them everywhere costs more than it saves.
Controlled = value+onChange flow through React. Uncontrolled = use a ref, let the DOM hold state. Forms with validation usually controlled; "set once" inputs OK uncontrolled.
In Next.js 13+ everything is a server component by default. "use client" opts in. Reduces shipped JS — useful interview talking point.
Two effects setting each other = render storm. Combine, or compute derived state during render.
Takeaway: the screen is a function of state — you describe the destination and React reconciles the journey. Use useState for data a component owns (and the updater form when the next value depends on the last). Use useEffect only to sync with the outside world, give it an honest dependency array, and return a cleanup. Keep state local, lift it only when siblings truly share it, key lists by stable ids, never mutate state in place, and reach for memoization only after you've measured a real slowdown.
Go deeper (optional): the official React docs essay "You Might Not Need an Effect" and the "Thinking in React" guide at react.dev/learn both reinforce the UI = f(state) mindset and the effect pitfalls covered above.