React rests on one idea: the screen is a function of state, and you never write to the screen yourself. You describe what should be there; React works out the difference. Almost every beginner confusion comes from forgetting that indirection — expecting a setter to assign, a re-render to mean a repaint, an Effect to be a lifecycle hook. This guide gives the shape of the system rather than a tour of its functions: what runs when, what React compares, where identity comes from, and which escape hatches exist. Every behavior below was executed against React 19 first.
Each chapter opens with the short version. Tap one to read the detail.
Components, markup, and props
~2 min
A component is a function returning markup, and that markup is an ordinary value rather than a template read later. Props travel one way and are read-only: a child can never write to them, and re-rendering a parent re-renders its children.
Markup written in JSX compiles to plain JavaScript objects, which explains most of its rules. A component returns one object, so siblings need a wrapper — an element, or a fragment (<>…</>) that adds no node of its own. Every tag closes. Attribute names become object keys, so they are camelCased (class becomes className); only aria-* and data-* keep their dashes.
Props are that function's input, and every render receives a fresh read-only set. The mistake is treating a prop as a variable you can update: a child wanting different props must ask its parent, which is what a callback prop is for. Spreading {...props} everywhere is worth rationing — once a component takes all of them, nothing says which it reads.
children is what makes composition work: nested markup arrives under that name, so a layout carries content it knows nothing about — reach for it before threading a prop through components that do not care.
Rule: markup is a value and props are a read-only snapshot — to change what a child shows, change what its parent passes.
State is a snapshot, which is why setters look asynchronous
~2 min
A setter does not assign; it requests another render. Inside the render already running the variable keeps its old value, which is why setAge(age + 1) three times adds one, not three. Use an updater to compose, and never mutate state in place.
State is not asynchronous so much as frozen. Every render gets its own copy of each state variable, and the setter never touches that copy — it schedules another render in which the variable holds something new. So three increments computed from one snapshot all compute the same number, batched into one re-render.
const [age, setAge] = useState(42);
function handleClick() {
setAge(age + 1); // 42 + 1
setAge(age + 1); // still 42 + 1
setAge(age + 1); // 43, in one re-render
}
// setAge(a => a + 1) three times queues three transformations → 45
An updater receives the pending value rather than the snapshot, which is why it composes. The same freeze explains the other half: reading state right after setting it still gives the old value, because the function closed over that render's variables.
Mutation fails differently. Assigning obj.count = 1 and passing obj back hands React the same object; an Object.is comparison sees no change, so nothing re-renders though the data has already moved. Copy, then set — and a spread copies one level only, so a nested field means copying every object on the path to it.
One more trap: the argument to useState is used on the first render and ignored after, so initializing state from a prop mirrors it once and then stops tracking it.
Rule: if the next value depends on the current one, pass a function — and if you are about to write into something already in state, copy it first.
Identity across renders: keys, Hook order, and refs
~3 min
React commits only differences, so a re-render is not a repaint. What it matches to what is positional: a component's identity is its type and slot, overridden by key; a Hook's is its position in the call order. Refs are the box that survives renders without causing one.
A render happens on mount or a state update: React calls your component, then whatever it returned, down the tree. It then commits, applying only the changes it computed into the Document Object Model (DOM) and leaving untouched nodes alone; the browser paints after that. A re-render is therefore neither a repaint nor a wipe — an <input> in the same place with the same props is never touched, so typing, focus and scroll survive it.
Identity is what makes that work, and it is positional: the same type in the same slot keeps its state, a different type there destroys the subtree. Inside a list a key overrides position, which is why the index is the wrong key:
{items.map((item, i) => (
<li key={i}> // 🚩 identity is "row 0", not "banana"
<input defaultValue={item} />
</li>
))}
// Prepend an item and row 0's typed text stays on row 0, now attached
// to something else. key={item.id} follows the data instead.
Keys must be unique among siblings, come from the data, and never be generated while rendering — a fresh random key recreates everything every time. Changing one deliberately is how you destroy a component's state on purpose.
Hooks are the same idea one level down. React keeps an array of state per component and a cursor starting at zero; each useState hands back the next entry, so nothing but a stable call order links a Hook to its value. Every rule follows: none in conditions, loops, nested functions or try/catch, none after an early return, none in handlers or classes. Skip one on a later render and React reports that it rendered fewer Hooks than expected. A custom Hook therefore shares logic, not state.
Refs are the deliberate exception: a box that persists across renders and holds what the interface never shows.
const ref = useRef(null);
// ref.current is null here; nothing has been committed yet
useEffect(() => { ref.current.focus(); }, []);
return <input ref={ref} />;
React fills ref.current during the commit, so it is empty while you render, and writing to it schedules nothing. Do not mutate nodes React manages; it will undo you. In development every component also renders twice: StrictMode calls them an extra time to expose impure rendering, and production renders once.
Rule: a key is an identity claim rather than a loop counter, and a Hook call is an unconditional declaration — both are how React matches this render to the last one.
Where state lives, and three ways to share it
~2 min
Most state bugs are structure bugs. Anything computable during render should not be stored, and two booleans that can contradict each other should be one status string. Lift what is shared to the closest common ancestor, and remember that context re-renders every consumer.
Group values that always change together. Avoid states that can contradict each other — one status string beats isSending plus isSent, because the impossible combination stops being expressible. Never store what you can derive, and keep an id rather than a second copy that can drift.
const [status, setStatus] = useState('typing'); // 'typing' | 'sending' | 'sent'
const isSending = status === 'sending'; // derived — never stored
const [selectedId, setSelectedId] = useState(null);
const selected = items.find(i => i.id === selectedId); // one source of truth
When two components need the same value, move it to their closest common ancestor — but keep it as low as it can go, since state held above its only consumer re-renders that subtree for nothing.
When one object has many transitions, a reducer — (state, action) => nextState — collects them into a pure function you can test without rendering. Dispatching behaves exactly like a setter, and dispatch has a stable identity, so it never has to be a dependency.
Context carries a value that would otherwise be threaded through components that do not care. The misconception is that it is a store. It is not: React re-renders every consumer whenever the value is not Object.is-equal to the last one, and memo does not stop it — so an object literal written inline as the value re-renders the whole subtree on every parent render. Memoize it. And the createContext default applies only when no provider sits above at all, so a provider passing undefined passes undefined.
Rule: compute what you can, store the smallest thing you cannot, and put shared state at the lowest ancestor that needs it.
Effects synchronize with the outside world, and most of yours should not exist
~2 min
An Effect connects a component to something outside React and needs a cleanup that undoes it. Development deliberately runs setup, cleanup, setup so a missing cleanup shows itself at once. Deriving data, handling a click and resetting state on a prop change are not Effects.
An Effect runs after the commit, and its dependency array decides when it runs again: no array means after every commit, [] means it does not re-run when props or state change, and [a] means React compares a with Object.is. When a dependency changes, the previous cleanup runs first with the old values, then the new setup. Think "start syncing, stop syncing", not "on mount, on update".
useEffect(() => {
let stale = false;
loadProfile(userId).then(data => { if (!stale) setProfile(data); });
return () => { stale = true; }; // a slow old response can no longer win
}, [userId]);
// development: connect → disconnect → connect. production: connect.
The famous "my Effect runs twice" is development on purpose — an extra setup and cleanup cycle proving your cleanup works — so the fix is a correct cleanup, never a flag suppressing the second run. And you cannot choose the dependencies: every reactive value the Effect reads belongs in the array, and omitting one leaves it holding values from the render it last ran in, which is the stale-closure bug.
Most Effects should not exist at all. Derived data belongs in the render body, because an Effect that sets it renders, commits, runs, then renders again — two passes for a value you could compute in one. Logic that runs because the user clicked belongs in the click handler. Resetting state when a prop changes is a key change, and a chain of Effects each waking the next is one calculation in disguise.
Rule: if you cannot say what the cleanup undoes, it is probably not an Effect.
Speed: memoization, the Compiler, and non-urgent updates
~2 min
memo compares props with Object.is, so one inline object or arrow makes it do nothing at all, and it never blocks a context update. The React Compiler writes that memoization for you. Transitions solve a different problem: responsiveness rather than fewer renders.
memo skips a re-render only when every prop is Object.is-equal to last time. A single value that is new on every render defeats it entirely, and an inline arrow is exactly that — which is why so much hand-added memoization does nothing measurable. A memoized child still re-renders when its own state changes and when a context it reads changes. It is an optimization React may ignore, so code that only works with it has a different bug.
const Row = memo(function Row({ onPick }) { /* … */ });
<Row onPick={() => pick(id)} /> // 🚩 new function each render, memo does nothing
const onPick = useCallback(() => pick(id), [id]);
<Row onPick={onPick} /> // now it is skipped
The React Compiler automates that whole pattern: with it enabled, hand-written memoization becomes mostly redundant.
The second lever differs in kind, and confusing the two wastes time. When a render is genuinely expensive, caching it does not help — you want it to stop blocking the user. An update inside startTransition is non-urgent, so a keystroke arriving mid-render interrupts it and React restarts the expensive work; useTransition also hands you an isPending flag. useDeferredValue is the same idea from the other side: it renders the previous value first, then retries in the background with the new one, with no debounce delay to guess at and nothing blocked meanwhile.
Which you use depends on what you hold: transition an update when you own the setter, defer a value that arrived as a prop.
Rule: measure first — a memo with an inline prop is decoration, and genuinely slow work should be deferred rather than cached.
The browser layer: events, forms and accessible markup
~2 min
React normalizes events and attaches the real listener at the root, so stopPropagation stops the walk up the React tree. A form given an action function hands you its FormData and needs no state per input. Attribute spelling is camelCase with exactly two exceptions.
Handlers receive a synthetic event, a normalized object whose preventDefault cancels the browser's default behavior and whose stopPropagation stops the event walking up the React tree. Underneath React listens at the root container while the event object hides that, which matters when you mix React with hand-attached listeners. One trap costs everyone an hour once: onClick={handleClick} registers the function, while onClick={handleClick()} calls it during render and hands React the result.
The reflex with forms is to hold every input in state and submit by hand. Give <form> an action function instead and React calls it with the form's data.
<form action={(formData) => search(formData.get('query'))}>
<input name="query" /> {/* without a name it never reaches FormData */}
<button type="submit">Go</button>
</form>
The function may be async and runs inside a Transition, so a pending state can span the whole await, and uncontrolled fields reset when it succeeds. useActionState wraps the action so its return value becomes state — with one trap worth learning early, because it makes the action's first argument the previous state and pushes the form data to second.
React offers two styling channels: className for classes, and style for a camelCased object of runtime values, where a bare number becomes pixels unless the property is unitless.
const id = useId();
<label htmlFor={id}>Password</label>
<input id={id} aria-describedby={`${id}-hint`} style={{ marginTop: 20 }} />
<p id={`${id}-hint`}>At least 12 characters</p>
Accessibility attributes keep their exact spelling: aria-* and data-* are the two families React does not camelCase. useId exists because a component can appear on a page twice: it generates an id that survives server rendering, so htmlFor lines up. It is never a valid list key.
Rule: let the form own its values and read them from FormData, and reach for a real element and a real label before reaching for aria-*.
Where React runs: roots, hydration, and Server Components
~3 min
createRoot clears the container it mounts into, so server-produced markup needs hydrateRoot and a first client render that matches. Server Components go further: they run before bundling and never ship. Suspense is the pending primitive, and it ignores a fetch inside an Effect.
A root connects a React tree to one browser node. Calling root.render again updates rather than remounts; root.unmount tears everything down for good. The catch is that first call, which clears the container's existing HTML: point createRoot at markup a server produced and React rebuilds all of it, resetting focus, scroll and anything already typed. Server-rendered markup wants hydrateRoot, which attaches handlers to the nodes already there.
Hydration then demands determinism, which is where the bugs are.
// 🚩 the two sides disagree, so hydration mismatches
<p>{typeof window !== 'undefined' ? 'client' : 'server'}</p>
// deterministic: render the same thing, fill the browser-only value in after mount
const [locale, setLocale] = useState(null);
useEffect(() => setLocale(navigator.language), []);
Rendering different data on each side, checking typeof window, or reading a browser-only capability during render all produce mismatches. React recovers from some, but they are bugs — at worst a handler lands on the wrong element. suppressHydrationWarning covers the unavoidable case such as a rendered timestamp, one level deep. On the server, renderToPipeableStream and renderToReadableStream stream into Node and Web Streams, while renderToString and renderToStaticMarkup do less. Effects never run there, so mount-only work is client work by definition.
Server Components are a separate idea, routinely confused with server rendering. They render before bundling, on a build machine or per request, and their code and dependencies stay out of what the browser downloads — generating HTML on a server is a further step you may or may not take. Never being sent, they cannot hold state or attach handlers — but they can be async and await data directly. 'use client' marks where the client bundle begins, and everything it imports is client code too, which is how that boundary quietly grows.
Suspense is the pending-state primitive underneath all of it, and it is picky. Lazy-loaded code, use on a promise, framework-integrated data and streamed markup activate a boundary; a fetch inside an Effect does not, so the component renders once with empty state and the fallback never appears. A boundary reveals everything inside it together, so nest them where you want the fast part first — and pair them with an error boundary, since Suspense covers pending, not rejection.
Rule: the first client render must be a pure function of data both sides already have — and when a fallback never appears, ask what the boundary is actually waiting on.
When rendering throws: error boundaries
~2 min
An error boundary is the one thing React still needs a class for, and it catches only errors thrown while rendering its children. Event handlers, asynchronous callbacks, server rendering and the boundary's own code all fall outside its reach.
A boundary is a class implementing static getDerivedStateFromError, which returns the state that renders a fallback, plus optionally componentDidCatch for logging. React documents no way to write one as a function component today.
What it does not catch is the part people get wrong. An error thrown in an event handler goes straight past it, and the boundary carries on rendering its children as though nothing happened — no fallback, no clue. So do errors from setTimeout and similar callbacks (with an exception for the function useTransition gives you), server rendering, and the boundary's own code. Handlers need their own try/catch.
Boundary placement is a product decision, because a boundary replaces everything below it: one around a widget degrades a panel, one at the root blanks the page. Recovery is a state reset — clear the error, or change the boundary's key. Root-level reporting is easy to miss: createRoot accepts onCaughtError, onUncaughtError and onRecoverableError.
Rule: a boundary protects the render path only — wrap risky handlers yourself and report everything else through the root's error callbacks.
Typing a React app
~2 min
Props are one object type on the parameter, which replaces runtime prop checking entirely. Two decisions carry most of the value: ReactNode for children, and an explicit type parameter on useState when the initial value is narrower than the real set.
Props are typed as one object: an interface or type alias on the destructured parameter, replacing the runtime prop checks older React code used.
Inference handles most Hooks, so the exceptions are worth memorizing. useState takes its type from the initial value, which is right until that value cannot express the full set — a union whose first member is a string literal, or a value starting as null.
type Status = 'idle' | 'loading' | 'error';
const [a, setA] = useState('idle'); // inferred string: setA('nope') compiles
const [b, setB] = useState<Status>('idle'); // setB('nope') is an error
const Ctx = createContext<Session | null>(null);
function useSession() {
const s = useContext(Ctx);
if (!s) throw new Error('useSession must be used inside a SessionProvider');
return s; // Session, never null
}
That context pattern is worth copying wholesale: type it nullable, default it to null, and put the guard in one custom Hook that throws.
For children, ReactNode covers everything renderable — elements, strings, numbers, arrays, null — while ReactElement accepts a single element and rejects a plain string, which is usually not what you meant. A reducer's actions want a discriminated union so each case narrows and an unhandled one is a compile error. Events are React's own generic types such as ChangeEvent<HTMLInputElement>.
Rule: annotate the parameter object, then the two places inference cannot reach — state whose first value is narrower than its type, and a context with no meaningful default.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.