React & Next.js — Concept Notes

Interview Q&A

Explained in plain language, but going all the way down to how things actually work. Print this page for a PDF.

Part 1 — React Basics

1. Why React Exists

In plain words

React is a JavaScript library for building the parts of a web page people see and click — the UI (user interface).

You tell React what the screen should show. React works out how to change the page to match.

Think of a sat-nav. You type the destination; it works out every turn. You don't give it turn-by-turn instructions yourself.

Before React, you updated the page by hand. If a number changed, you found the element on the page and changed its text yourself. With a few elements, that's fine. With a hundred, you lose track of which parts of the screen need updating. The bugs are always the same kind: "the data changed, but the screen didn't".

Example: a shopping cart. When you add an item, the item count in the header, the total price, and the "Checkout" button all need to change. Forget one of them, and the screen lies to the user.

The one idea behind React

You stop writing steps to update the screen. Instead, you write a description of what the screen should look like for the current data. React figures out the steps.

People write this as UI = f(state). In words: "the screen is the result of a function, and the input to that function is your data". State just means "the data right now". Same data in, same screen out — every time.

THE OLD WAY — you do the work count = count + 1 el.textContent = count badge.style.display = … you must remember every place that needs changing REACT — you describe the result setCount(count + 1) …and somewhere you wrote: <span>{count}</span> React finds what changed and updates only that UI = f(state) give it the same data, you always get the same screen
You change the data. React changes the screen. That split is the whole reason the library exists.

What you get from that

  • Fewer bugs — you can't forget to update part of the screen, because you never update the screen directly.
  • Reusable pieces — a component is a function you can use in many places.
  • Easy to read — to know what the screen shows, look at the data. You don't have to replay a history of changes in your head.
The trade: React does extra work to find what changed. For most apps that cost is tiny and worth it. For a page that's mostly static text (text that never changes after the page loads), plain HTML is faster and simpler — React is not always the right answer.

2. Components & JSX

In plain words

A component is one reusable piece of the screen — a button, a card, a whole page. In code, it's a function.

JSX is the HTML-looking syntax you write inside JavaScript. It is not real HTML. A tool turns it into normal JavaScript before the browser runs it.

Components are like LEGO bricks. You build small bricks, then snap them together into bigger things.

A component is just a function that returns what should appear on screen. That's the whole definition.

function Welcome() {
  return <h1>Hello</h1>;
}

// use it like a tag — the capital letter is required
<Welcome />

The capital letter matters. React treats lowercase tags as real HTML elements (<div>). It treats capitalised ones as your own components (<Welcome>).

JSX is not HTML — it's a shortcut for function calls

JSX looks like HTML sitting inside JavaScript. But a build tool (a program such as Babel or the one inside Vite, which rewrites your code before it ships) converts it before the browser ever sees it:

// what you write
<div className="card">Hi</div>

// what it becomes
React.createElement("div", { className: "card" }, "Hi")

// which just produces a plain object
{ type: "div", props: { className: "card", children: "Hi" } }

So in the end, JSX is just a plain object that says "I want a div, with this class, containing this text". Knowing this explains several rules that otherwise seem random:

  • One parent element only. A function can only return one value. Use <>…</> (a fragment — an invisible wrapper) to group things without adding a real <div> to the page.
  • className not class, htmlFor not for — class and for are reserved words in JavaScript (words the language already uses for its own features).
  • { } holds any JavaScript expression — an expression is any piece of code that produces a value. So {a + b} and {list.map(…)} work. But {if (x) …} doesn't, because if is a statement (an instruction that does something), not a value.

Showing things conditionally

{isLoggedIn && <Dashboard />}            {/* show only if true */}
{isLoggedIn ? <Dashboard /> : <Login />}  {/* either / or */}

// Careful with && and numbers:
{items.length && <List />}      // when length is 0, React prints "0" on screen!
{items.length > 0 && <List />}  // ✓ force it to a true/false value

That 0 trap catches almost everyone once. Here's why. && returns the left value when it's falsy (a value JavaScript treats as "no", like 0, "" or null). React shows the number 0 on screen — but it shows nothing for false, null and undefined.

3. Props — passing data down

In plain words

Props (short for "properties") are the inputs a component receives from the component that uses it. The one using it is the parent; the one being used is the child.

They work like arguments to a function. The child reads them but never changes them.

A parent gives a child a packed lunch. The child can eat what's inside, but it doesn't get to repack the lunchbox and send it back as if the parent had packed it.

Props are the values a parent gives to a child. They arrive as one object, and the child must not change them.

function Greeting({ name, age = 18 }) {   // pull out values, give age a default
  return <p>{name} is {age}</p>;
}

<Greeting name="Ada" age={36} />    {/* strings in quotes, everything else in braces */}

Why props are read-only

The value belongs to the parent. If a child could change it, the parent's data and the screen would disagree. You'd have no idea which component changed what. So data flows one way: down (from parent to child). This is called one-way data flow.

That's what makes a bug easy to trace. Your components form a tree (App at the top, its children below it, their children below them). To find where a wrong value came from, you walk up the tree, parent by parent.

Sending data back up

A child can't change props. So the parent passes down a function instead. The child calls it; the parent decides what happens. This kind of function is often called a callback — "call me back when something happens".

function Parent() {
  const [count, setCount] = useState(0);
  return <Child onAdd={() => setCount(count + 1)} />;
}

function Child({ onAdd }) {
  return <button onClick={onAdd}>Add</button>;
}

The child doesn't know what onAdd does — it just reports "the button was pressed". That's what makes the child reusable anywhere.

The children prop

Whatever you put between a component's tags arrives as children:

function Card({ children }) {
  return <div className="card">{children}</div>;
}

<Card><h2>Title</h2><p>Body</p></Card>

This is how you build wrappers — a card, a modal (a pop-up box over the page), a layout — that don't care what goes inside them. Like a picture frame: the frame is the same whatever photo you put in it.

4. State — data that changes

In plain words

State is a component's memory — data it keeps and can change, like "is the menu open?" or "what's in the search box?".

When state changes, React re-renders the component. That means React calls your component function again to get the new picture of the screen, then updates the page to match.

Props are what someone hands you. State is what you write in your own notebook. You can change your notebook whenever you like — and every time you do, React redraws your part of the screen.

State is data a component owns and can change. When it changes, React re-runs the component and updates the screen.

const [count, setCount] = useState(0);
//     ↑ value  ↑ the only way to change it    ↑ starting value

Why not a normal variable?

function Broken() {
  let count = 0;                      // ✗
  return <button onClick={() => count++}>{count}</button>;
}

Two things go wrong:

  1. React doesn't know anything changed, so it never re-runs the function. The screen stays at 0.
  2. Even if React did re-run it, let count = 0 would run again and reset the value to 0.

useState solves both. Calling setCount tells React "please re-render". And React stores the value outside your function, so it survives between runs.

State is a snapshot, not a live value

A snapshot is a photo of a moment in time. This is the single most confusing thing about React when you start:

const [count, setCount] = useState(0);

function handleClick() {
  setCount(count + 1);
  console.log(count);      // still 0! not 1
}

Inside this run of the function, count is a fixed number — a photo of the value at the moment React called your component. setCount doesn't edit that photo. It asks React to re-run the function later with a new photo.

RENDER 1 count = 0 handleClick sees 0 — frozen for this run — setCount(0 + 1) RENDER 2 count = 1 a brand-new run of your function, with a new frozen value React re-runs The two runs don't share variables. Each has its own count, its own handleClick. This is why reading count right after setCount gives you the old number — you are still inside render 1.
Every render is a fresh, independent run of your function with its own frozen copy of the state.

Updating based on the previous value

setCount(count + 1);
setCount(count + 1);       // both read the same frozen 0 → ends up 1

setCount(c => c + 1);
setCount(c => c + 1);      // each gets the latest value → ends up 2 ✓

Pass a function whenever the new value depends on the old one. React runs those functions in order, and each one receives the result of the one before. This is called an updater function.

Never change state directly

// ✗ React compares the old and new value. Same object = "nothing changed."
user.name = "New";  setUser(user);
items.push(newItem); setItems(items);

// ✓ make a new object or array
setUser({ ...user, name: "New" });
setItems([...items, newItem]);
setItems(items.filter(i => i.id !== id));
setItems(items.map(i => i.id === id ? { ...i, done: true } : i));

React only checks whether the value is the same thing as before (the same object in memory). It does not check whether the contents differ — comparing every property of every object on every update would be far too slow. This "same thing?" check is called comparing by reference. So you must hand React a new object.

Changing an object in place is called mutating it. Making a fresh copy with the change is called an immutable update. React wants immutable updates. The ... (spread) syntax copies everything from the old object into the new one.

Analogy: imagine React checks a letter by looking at the envelope, not by reading it. If you scribble a change on the same letter and put it back in the same envelope, React thinks nothing arrived. Send a new envelope and React opens it.

Where should state live?

Put it in the closest component that needs it. If two components need the same data, move it up to their nearest shared parent and pass it down as props. This is called lifting state up.

Example: a filter box and a product list both need the search text. Keep the text in their parent page, and pass it to both.

Don't store what you can calculate. If you have items, don't also keep count in state — write const count = items.length. Two copies of the same fact will eventually disagree, and that's a bug you can't reproduce.

5. Events & Forms

In plain words

An event is something the user does — a click, a key press, submitting a form. An event handler is the function you give React to run when that happens.

A controlled input is a text box whose text lives in React state, not inside the box itself.

An event handler is like leaving a note with a receptionist: "when the delivery arrives, call this number". You hand over the number — you don't phone it yourself right now.

<button onClick={handleClick}>Save</button>        {/* pass the function */}
<button onClick={handleClick()}>Save</button>      {/* ✗ calls it immediately! */}
<button onClick={() => remove(id)}>Delete</button> {/* wrap it to pass arguments */}

The difference between the first two lines: handleClick hands React the function to call later. handleClick() calls it right now, while the button is being drawn, and hands React whatever it returned.

Controlled inputs

Normally an input box keeps its own text. In React you usually put that text in state instead, so the input shows exactly what your state says. React becomes the single source of truth — the one place that holds the real value.

const [email, setEmail] = useState("");

<input value={email} onChange={e => setEmail(e.target.value)} />

Here is the loop, step by step: the user presses a key → onChange runs → state is updated → React re-renders → the input shows the new value. It feels roundabout. But it means you can validate (check the input is allowed), format it, or disable a button while the user types.

Common error: "You provided a value prop without an onChange handler." You told React the input's value but gave it no way to update — so typing does nothing. Add onChange, or use defaultValue if you don't want to control it. (An input React doesn't control is called uncontrolled; you read its value with a ref when you need it — see the useRef section.)

A whole form

const [form, setForm] = useState({ name: "", email: "" });

function update(e) {
  const { name, value } = e.target;
  setForm(f => ({ ...f, [name]: value }));   // one handler for every field
}

<form onSubmit={e => { e.preventDefault(); save(form); }}>
  <input name="name"  value={form.name}  onChange={update} />
  <input name="email" value={form.email} onChange={update} />
  <button>Save</button>
</form>

e is the event object — information about what just happened (which input, what text). e.target is the element the event came from. [name]: value uses the input's name as the key, so one function can update any field.

e.preventDefault() stops the browser's default behaviour. By default, submitting a form reloads the whole page — which would wipe out your React app's state.

6. Lists & Keys

In plain words

To show a list, you turn an array of data into an array of elements with .map().

A key is a unique name tag you give each item, so React can recognise the same item next time — even if it moved.

A teacher taking the register by name knows who's missing. A teacher who only counts desks ("desk 3 is empty") gets confused the moment anyone changes seats.

{users.map(user => <Row key={user.id} user={user} />)}

What a key is actually for

When a list changes, React compares the old list with the new one. Without a name for each item, it can only go by position — "the third one changed". With a key, it can say "the item with id 7 moved from position 3 to position 1".

key={index} ✗ before: 0:Ada 1:Bob 2:Cy delete Ada after: 0:Bob 1:Cy React: "key 0 changed Ada→Bob, key 1 changed Bob→Cy, key 2 gone" key={user.id} ✓ before: a:Ada b:Bob c:Cy delete Ada after: b:Bob c:Cy React: "a was removed. b and c are untouched." If each row had a checkbox, React keeps the DOM node at position 0 and only swaps text — Ada's tick is now on Bob. React removes row "a" entirely, including its checkbox state. Bob and Cy keep their own. Everything stays with its row. Index keys are only safe when the list never reorders, filters, or has items added or removed.
A key is a name that sticks to an item. An index is just a position, and positions shift.

Rules for keys

  • Use something stable and unique — stable means it stays the same for that item forever. A database id is ideal.
  • It only needs to be unique among its siblings (the other items in the same list), not across the whole page.
  • Never use Math.random() — a new key every render means React throws the row away and rebuilds it every time.
  • Put the key on the outermost element inside map.
A trick worth knowing: changing a component's key deliberately destroys it and builds a fresh one, wiping its state. <Form key={userId} /> resets the form whenever you switch user — much simpler than clearing every field by hand.
Part 2 — How React Works Inside

7. The Render Cycle

In plain words

In React, render means "call the component function and see what it returns". It does not mean "draw pixels".

Commit is the separate, later step where React actually changes the real page. The real page is called the DOM (Document Object Model) — the browser's live tree of elements that you see.

Render is an architect drawing a new plan and marking what's different from the old plan. Commit is the builders actually moving the walls. Drawing plans is cheap; moving walls is expensive.

"Rendering" in React does not mean drawing on screen. It means React runs your component function to find out what it wants the screen to look like. Actually changing the page is a separate step.

① TRIGGER setState was called ② RENDER React runs your function ③ COMMIT React touches the page • run the function body • get the returned tree • compare with last time • build a to-do list of changes nothing visible yet • apply the changes to the real page • browser paints • run useEffect the user sees it now also triggered by: • the parent re-rendering • a context value changing Key point: a render that finds no differences produces NO page changes. Re-rendering is cheap. Touching the page is the expensive part — and React skips it when it can.
Render = "work out what it should look like". Commit = "make it so". People blame slow apps on re-renders, but the commit is what actually costs.

What makes a component re-render

  1. Its own state changed.
  2. Its parent re-rendered — this happens by default, even if the child's props are identical.
  3. A context it reads changed.

Point 2 surprises people. React doesn't check whether props changed before re-running a child. Checking is itself work, and usually just re-running the function is cheaper. If a child is genuinely slow to render, you can ask React to check first, with React.memo (section 20).

A context (point 3) is a way to share a value with many components without passing it as props through every level — covered in the useReducer & useContext section.

8. The Virtual DOM & Diffing

In plain words

The virtual DOM is a lightweight copy of the page, kept as plain JavaScript objects. Diffing means comparing the old copy with the new copy to find the differences (the "diff").

"Spot the difference" puzzle: you compare two small drawings on paper, circle the changes, then repaint only those spots on the real wall — instead of repainting the whole wall.

The "virtual DOM" sounds mysterious. It's just a plain JavaScript object describing what the page should look like — the object we saw JSX compile into in section 2.

Why a copy is faster than the real thing

Real DOM elements are heavy. Each has hundreds of properties. Touching them can force the browser to recalculate the position of everything on the page (this is called layout or reflow). A plain object is tiny.

So React keeps two descriptions — the previous one and the new one. It compares those cheaply, and then makes only the handful of real changes needed.

OLD tree (kept from last render) div h1 "Cart" span "2" button "Add" NEW tree (just produced) div h1 "Cart" span "3" button "Add" compare Difference found: span text "2" → "3" …and nothing else realSpan.textContent = "3" ← the ONLY page change The h1 and button were untouched, so their real elements were never even looked at.
Compare cheap objects, then make expensive changes only where they're genuinely needed.
Honest framing: the virtual DOM is not magically fast — carefully hand-written DOM code can beat it. What it buys you is near-best updates without writing that code yourself, and without the bugs that come with it.

9. Batching & How Updates Queue

In plain words

Batching means React collects several state changes and handles them together in one re-render, instead of one re-render per change.

A queue is a waiting line: updates join it in order, and React processes them in that order.

A waiter doesn't run to the kitchen after each dish you order. They write down the whole order, then go once.

If you call setState three times in one function, React doesn't re-render three times. It collects them and does one render at the end. This is called batching.

function handleClick() {
  setName("Ada");
  setAge(36);
  setCity("London");
  // ONE re-render, not three
}

Without batching, the user could briefly see a half-updated screen (new name, old age). And you'd do three times the work. Since React 18 this happens everywhere — including inside setTimeout, promises (code that runs after an async task such as a fetch finishes) and event handlers added outside React.

How the queue works

const [n, setN] = useState(0);

setN(n + 1);        // queue: "replace with 1"  (n is frozen at 0)
setN(n + 1);        // queue: "replace with 1"  ← same frozen 0
// result: 1

setN(x => x + 1);   // queue: "take what's there, add 1"
setN(x => x + 1);   // queue: "take what's there, add 1"
// result: 2 — each function receives the previous result

Plain values are instructions to replace ("set it to 1"). Functions are instructions to transform ("add 1 to whatever is there"). When updates depend on each other, you need transform.

When you need the update to land immediately

This is rare. But if you must measure the page right after an update, flushSync forces React to render and commit synchronously — right now, before the next line of your code runs. It's an escape hatch (an emergency exit from React's normal rules), and it gives up the speed benefit of batching.

10. Reconciliation — the matching rules

In plain words

Reconciliation is how React decides, element by element, "is this the same thing as last time, or something new?".

If it's the same, React keeps it and its state. If it's new, React throws the old one away. Throwing away is called unmounting; creating fresh is called mounting.

Renovating a room: if the new plan still has a sofa in the same spot, you keep the sofa and just change the cushions. If the plan says "bed" where the sofa was, the sofa goes out entirely.

Reconciliation is React's process for matching up old elements with new ones. It follows two simple rules, and knowing them explains a whole category of strange bugs.

Rule 1 — a different type means throw it all away

// before
<div><Profile /></div>
// after
<section><Profile /></section>

div became section. So React destroys the entire subtree (that element plus everything inside it) and rebuilds it. Profile is unmounted and mounted again, losing all its state. React doesn't try to be clever here, because in practice a changed type almost always means genuinely different content.

Rule 2 — same type means keep it and update what differs

<div className="a" title="x" />
<div className="b" title="x" />
// same type → React keeps the element and changes only className

The bug this causes

// ✗ a new component TYPE is created on every render
function Page() {
  function Inner() { return <input />; }   // defined inside!
  return <Inner />;
}

Every render creates a brand-new Inner function. React compares the old and new types and sees they're different functions (even though the code looks identical). So it throws away the subtree. The result: the input loses focus and its text on every keystroke. Always define components at the top level of a file, never inside another component.

Same idea, different symptom

// ✗ the input is remounted whenever isEditing flips
{isEditing ? <div><input /></div> : <span><input /></span>}

// ✓ same structure, so the input survives
<div>{isEditing ? <input /> : <input readOnly />}</div>
Rule of thumb: if a component is unexpectedly losing its state, ask "did its type or its position in the tree change?" That's nearly always the cause.
Part 3 — Hooks

11. useState, Deeply

In plain words

A hook is a special React function whose name starts with use. Hooks let a plain function component "hook into" React features like memory (state) and side jobs (effects).

useState gives your component one piece of memory, plus a function to change it.

A coat check: you hand in your coat (the value) and get a ticket. Each time you come back (each render), you show the ticket and get your coat back — even though you left the building in between.

const [value, setValue] = useState(initial);

Where the value is actually kept

Your function runs and finishes — its local variables disappear. So React stores the state outside your function, in its own internal record for that component instance (one particular copy of the component on the page). Each time React calls your function, it hands back the current value.

This is why two copies of the same component have separate state. Put two <Counter />s on a page and each counts on its own: they're separate instances, each with its own record.

Expensive starting values

useState(buildHugeList());        // ✗ runs on EVERY render, result thrown away
useState(() => buildHugeList());  // ✓ runs only on the first render

The first version calls buildHugeList() every time, just to hand React a value it ignores after the first render. The second passes the function itself (without calling it), so React can call it only when it needs to. This is called lazy initialisation — "don't do the work until it's actually needed".

Objects and arrays in state

const [user, setUser] = useState({ name: "", address: { city: "" } });

setUser({ ...user, name: "Ada" });                          // one level
setUser({ ...user, address: { ...user.address, city: "X" }});// spread each level you change

Deep nesting (objects inside objects inside objects) gets painful fast, because you have to copy every level you pass through. Two ways out: keep state flatter, or use useReducer (section 16) to put the update logic in one place.

Resetting state when a prop changes

// ✗ the old way — an extra render, and easy to get wrong
useEffect(() => { setDraft(""); }, [userId]);

// ✓ give the component a key — React rebuilds it fresh
<Editor key={userId} />

When userId changes, the key changes. React sees a "different" Editor, throws the old one away and mounts a fresh one with empty state. No effect needed.

12. useEffect, Deeply

In plain words

A side effect is anything your component does besides returning what to show — calling a server, starting a timer, listening to window resize.

useEffect runs that kind of code after React has updated the page. The optional function it returns is the cleanup: code that undoes or stops what the effect started.

Moving into a hotel room: setup is unpacking and plugging in your charger. Cleanup is unplugging and packing before you leave — and you always pack before moving to the next room.

useEffect is for talking to things outside React — a server, a timer, the browser, a subscription (a connection that keeps sending you updates, like a chat socket). It is not a general "run this code" box, and that misunderstanding causes most effect bugs.

useEffect(() => {
  // setup — runs after React has updated the page
  const id = setInterval(tick, 1000);

  return () => clearInterval(id);   // cleanup
}, [deps]);

When it runs

The second argument, [deps], is the dependency array: the list of values the effect uses. React re-runs the effect only when one of them has changed since the last render.

Dependency arrayRuns
left out entirelyAfter every single render
[]Once, after the first render
[a, b]First render, then whenever a or b becomes a different value

Cleanup, and why it matters more than people think

id = 1 setup: fetch user 1 cleanup cancel user 1 request id = 2 setup: fetch user 2 id changed Cleanup ALWAYS runs before the next setup Without cleanup: user 1's slow response can arrive AFTER user 2's — and overwrite the screen with the wrong person. Cleanup also runs one final time when the component is removed from the page.
Every effect that starts something — a request, a timer, a listener — needs a cleanup that stops it.
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(e => { if (e.name !== "AbortError") setError(e); });

  return () => controller.abort();   // cancel if id changes or we unmount
}, [id]);

AbortController is a built-in browser tool for cancelling a fetch. Calling controller.abort() stops the request, and the fetch fails with an "AbortError" — which we ignore on purpose.

Why your effect runs twice in development

StrictMode is a wrapper component (<StrictMode>) that turns on extra checks while you develop. It deliberately runs each effect twice — setup, cleanup, setup — the moment a component appears. It's not a bug; it's a test.

If your component breaks when this happens, your cleanup is missing or incomplete. That would have been a real bug in production (the live app real users see). Fix the cleanup rather than turning StrictMode off. It only does this in development.

Effects you shouldn't be writing

// ✗ calculating during an effect — causes a second render and can flash old data
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(items.reduce((s,i) => s + i.price, 0)); }, [items]);

// ✓ just calculate it while rendering
const total = items.reduce((s, i) => s + i.price, 0);


// ✗ reacting to a click inside an effect
useEffect(() => { if (submitted) sendRequest(); }, [submitted]);

// ✓ do it in the event handler, where the thing actually happened
function handleSubmit() { sendRequest(); }
Test to apply: ask "is this synchronising (keeping in step) with something outside React?" If yes, an effect fits. If it's just calculating a value, or responding to a user action, it doesn't.

The dependency array is not optional

// ✗ leaving deps out to "stop it running" — it now uses a stale count forever
useEffect(() => { console.log(count); }, []);

The effect captures the values from the render it belonged to (remember: each render is a snapshot). Removing a dependency doesn't stop the effect needing that value — it just makes it use an old one. An old, out-of-date value is called stale.

Let the linter guide you (a tool, here the eslint-plugin-react-hooks rule, that reads your code and warns about missing dependencies). If an effect runs too often, the answer is usually to move a function inside the effect or wrap it in useCallback, not to lie about the deps.

13. React Component Lifecycle

In plain words

The lifecycle is the list of stages every component goes through, from appearing to disappearing:

Mount — the component appears on the page for the first time. Update — it re-renders because something it depends on changed. Unmount — it is removed from the page, and its state is thrown away.

Effects are how you hook into those stages: setup code runs after the component appears or changes; cleanup code runs before it changes again or disappears.

Think of a new employee. Day one (mount): they get a desk, a laptop and a login (setup). Normal days (updates): their tasks change, so they swap out old files for new ones — tidying the old first (cleanup, then setup). Last day (unmount): they hand back the laptop and the login is switched off (final cleanup). Forgetting that last step is how "leaks" happen.

Class components (the older style) had a separately named method for each stage — componentDidMount, componentDidUpdate, and so on. Function components use hooks instead, mostly useEffect. The stages are the same; only the way you write them changed. Interviewers love asking you to map one to the other, so both are covered below.

The three stages

  • Mounting — React calls your function for the first time, creates the real DOM elements, puts them on the page, and then runs your effects' setup code.
  • Updating — something changed, so React calls your function again, works out the differences, patches the page, and then re-runs only the effects whose dependencies changed (cleanup of the old one first, then setup of the new one).
  • Unmounting — the component is no longer in the returned JSX (for example, a {show && <Modal />} where show became false). React removes its elements and runs every remaining cleanup. Its state is gone for good — if it comes back later, it mounts fresh with starting values.
① MOUNT appears for the first time ② UPDATE state / props / context ③ UNMOUNT removed from the page repeats many times • render: run function • commit: create DOM • refs are filled in • useLayoutEffect setup • browser paints • useEffect setup user sees it • render: run again • commit: patch the DOM • layout: cleanup → setup • browser paints • effect: old cleanup • effect: new setup only if deps changed • layout cleanups run • DOM elements removed • effect cleanups run • state thrown away comes back later? = a fresh mount RENDER PHASE — "work it out" call functions, compare old vs new tree pure — no DOM work; may run twice COMMIT PHASE — "make it so" change the DOM, set refs, run effects runs once, can't be interrupted
Every stage has the same two halves: first React works out the new picture (render), then it changes the page and runs your effects (commit).

What triggers a re-render (an update)

  1. Its own state was set to a new value. (If you set it to the exact same value — checked with Object.is, which is basically === — React can skip the re-render.)
  2. Its parent re-rendered. By default the child re-renders too, even if its props are the same. React.memo can stop this.
  3. A context it reads changed.

Notice what's not on the list: "its props changed". Props only change because the parent re-rendered, so that's really trigger 2. Also, changing a normal variable or a ref never triggers anything.

One special case: if a component's key or its type changes, that's not an update at all. React unmounts the old one and mounts a brand-new one (see section 10).

Render phase vs commit phase

Render phase — React calls your component functions and compares the new result with the old one. Nothing on the screen changes yet. Your function must be pure here: given the same props and state, it returns the same JSX and doesn't change anything outside itself (no fetching, no timers, no editing global variables). This matters because React is allowed to call it twice, pause it half-way to handle something more urgent (this is concurrent rendering, added in React 18), or throw the result away.

Commit phase — React applies the changes to the real DOM in one go, fills in refs, runs useLayoutEffect, lets the browser paint, and then runs useEffect. The commit is never done half-way, so the user never sees a half-updated screen.

Example: typing a letter in a search box. Render phase: React calls SearchBox and Results and notices one list item disappeared. Commit phase: it removes that one <li>, the browser repaints, then your "save search to URL" effect runs.

When effects and cleanups run

useLayoutEffect is a twin of useEffect with different timing. It runs right after the DOM is updated but before the browser paints, so the user never sees the in-between state. Use it to measure something and fix the position before it shows — like placing a tooltip. It blocks painting, so keep it rare and fast. useEffect runs after the paint, so it never delays what the user sees; that's why it's the default.

MomentuseLayoutEffectuseEffect
MountSetup runs after the DOM is created, before paintSetup runs after paint
Update, a dependency changedOld cleanup, then new setup — before paintOld cleanup, then new setup — after paint
Update, dependencies all the sameSkippedSkipped
Update, no dependency arrayCleanup + setup every timeCleanup + setup every time
UnmountFinal cleanup runsFinal cleanup runs
On the server (SSR)Never runsNever runs

Two ordering details that come up in interviews:

  • In one commit, React runs all the cleanups first, then all the setups. Cleanups always see the values from the render they were created in (the old ones).
  • Children's effects run before their parent's. A parent's useEffect can rely on its children already being on the page.

Class lifecycle methods → hooks

Class methodWhen it ranHooks equivalent
constructorOnce, before the first render — set up starting stateuseState(initial), or useState(() => expensive()) for costly setup; useRef for non-visual values
renderEvery render — return the UIThe function body itself (whatever it returns)
static getDerivedStateFromPropsBefore every render — copy props into stateUsually not needed: calculate the value during render. To reset state when a prop changes, use a key. (Rare: call setState during render with a guard.)
componentDidMountOnce, after first appearing on the pageuseEffect(() => { … }, [])
shouldComponentUpdateBefore a re-render — return false to skip itReact.memo(Component). Careful: memo's optional compare function returns true when props are equal (skip) — the opposite meaning
getSnapshotBeforeUpdateJust before the DOM changes — read something (like scroll position)No hook equivalent. Keep a small class, or get close with a ref plus useLayoutEffect
componentDidUpdateAfter each updateuseEffect(() => { … }, [deps]) — note it also runs on mount
componentWillUnmountJust before removalThe cleanup function returned from useEffect(() => { return () => { … }; }, [])
componentDidCatch (+ getDerivedStateFromError)When a child throws while renderingNo hook equivalent — still needs a class error boundary (section 21) or the react-error-boundary package

The big mindset change: with classes you thought "what should happen at mount, at update?". With hooks you think "what outside thing must stay in sync with these values?". One effect with cleanup covers mount, update and unmount together — which is why code that used to be split across three methods now lives in one place.

Seeing it happen

function Profile({ userId }) {
  const [count, setCount] = useState(0);

  // [] → setup once after mount, cleanup once on unmount
  useEffect(() => {
    console.log("mounted");
    return () => console.log("unmounted");
  }, []);

  // [userId] → after mount, and again whenever userId changes
  useEffect(() => {
    console.log("setup for user", userId);
    return () => console.log("cleanup for user", userId);  // sees the OLD userId
  }, [userId]);

  // no array → after every render (mount + every update)
  useEffect(() => {
    console.log("committed, count =", count);
  });

  console.log("render phase, count =", count);   // runs in the render phase

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

/* Console output (production build):

   — MOUNT (userId = 1) —
   render phase, count = 0
   mounted
   setup for user 1
   committed, count = 0

   — UPDATE: button clicked —
   render phase, count = 1
   committed, count = 1          ← only the no-array effect re-ran

   — UPDATE: parent passes userId = 2 —
   render phase, count = 1
   cleanup for user 1            ← all cleanups first…
   setup for user 2              ← …then the setups
   committed, count = 1

   — UNMOUNT —
   unmounted
   cleanup for user 2
*/

StrictMode: why everything happens twice in development

When your app is wrapped in <StrictMode> (Next.js and Vite templates do this for you), React adds two deliberate tests in development only:

  1. It calls your component function twice for each render (also state initialisers, updater functions, reducers and useMemo callbacks) and throws one result away. If the two runs differ, your render isn't pure. Newer React DevTools show the second log greyed out.
  2. On first mount, it mounts, unmounts, and mounts again. Every effect runs setup → cleanup → setup. This checks that your cleanup really undoes your setup.
/* Same component, development + StrictMode, first mount:
   render phase, count = 0
   render phase, count = 0      ← double render
   mounted
   setup for user 1
   committed, count = 0
   unmounted                    ← simulated unmount
   cleanup for user 1
   mounted                      ← and mount again
   setup for user 1
   committed, count = 0
*/

If this double run causes a bug — two chat connections, a request sent twice that you can't cancel, a counter that jumps by 2 — then a real user would hit the same bug by navigating away and back. Fix the cleanup; don't remove StrictMode. A production build runs everything once.

Interview one-liner: "A component mounts, updates any number of times, and unmounts. Each time is a render phase (pure calculation, can be repeated) followed by a commit phase (DOM changes, then layout effects, paint, then effects). Cleanups run before the next setup and on unmount. [] is mount/unmount, [deps] is did-update for those deps, and StrictMode doubles it all in development to catch missing cleanups."

14. useRef

In plain words

A ref (reference) is a small box with one slot, .current. You can put anything in it and it stays there between renders.

Unlike state, changing a ref is silent — React is not told, so the screen does not update.

State is a whiteboard in the office: change it and everyone looks up. A ref is a sticky note in your own pocket: you can scribble on it all day and nobody reacts.

useRef gives you a box whose contents survive re-renders, and changing it never causes a re-render. That second part is the whole point.

const ref = useRef(initialValue);
ref.current;          // read or write freely

Job 1 — reaching a real page element

const inputRef = useRef(null);

useEffect(() => { inputRef.current.focus(); }, []);

<input ref={inputRef} />

When you write ref={inputRef}, React puts the real input element into inputRef.current after it creates it. Use this for things React has no built-in way to do: focusing, scrolling, measuring size, playing a video, or handing a DOM node (a real page element) to a chart library.

Job 2 — remembering a value without re-rendering

const timerRef = useRef(null);

function start() { timerRef.current = setInterval(tick, 1000); }
function stop()  { clearInterval(timerRef.current); }

setInterval returns an id number that you need later to stop the timer. Nobody sees that number on screen. If you stored it in state, every start would re-render for no visual reason.

state vs ref

useStateuseRef
Changing it re-rendersYesNo
Shown on screenYes — that's the pointShould not be
Read during renderSafeAvoid — may be stale
Use forAnything the user seesTimers, DOM nodes, previous values
Simple test: if changing the value should update the screen, use state. If it shouldn't, use a ref. Putting display data in a ref means the screen won't update; putting a timer id in state means pointless re-renders.

15. useMemo & useCallback

In plain words

Memoising (from "memo", a note to self) means saving a result so you can reuse it instead of working it out again.

useMemo saves a calculated value. useCallback saves a function. Both only recompute when their dependencies change.

A student who writes the answer to a long sum on a sticky note, and just reads the note next time — unless the numbers in the question changed.

Both do the same thing — remember a result between renders so it isn't recreated. useMemo remembers a value; useCallback remembers a function.

const sorted = useMemo(() => [...items].sort(byName), [items]);
const handler = useCallback(() => save(id), [id]);

// they're the same tool:
useCallback(fn, deps)  ≡  useMemo(() => fn, deps)

Reason 1 — skip expensive work

// runs on every render, even when only an unrelated field changed
const filtered = hugeList.filter(complexCheck);

// runs only when hugeList or query changes
const filtered = useMemo(() => hugeList.filter(complexCheck), [hugeList, query]);

Reason 2 — keep a value's identity stable

A value's identity means "which exact object in memory is this?" — not what's written inside it. This is the one people miss. In JavaScript, two objects with identical contents are still different objects (like two identical twins are still two people):

{ a: 1 } === { a: 1 }     // false
() => {}  === () => {}     // false

So every render creates brand-new objects and functions. If you pass one to a child wrapped in React.memo (a wrapper that skips re-rendering when props haven't changed), the child's check ("did my props change?") says yes every time — and the memo does nothing.

// ✗ new function each render → Child re-renders every time anyway
<Child onSave={() => save(id)} />

// ✓ same function identity between renders → memo can actually skip
const onSave = useCallback(() => save(id), [id]);
<Child onSave={onSave} />

The same applies when an object or function is in a useEffect dependency array — an unstable identity makes the effect run every render.

Don't wrap everything. Memoising isn't free: it stores values and compares dependencies on every render. For cheap calculations that costs more than it saves. Use it when you've seen a real problem, or when you specifically need stable identity. React's newer compiler (the React Compiler, a build step that adds memoisation for you) does much of this automatically.

16. useReducer & useContext

In plain words

A reducer is a function that takes the current state and a message describing what happened (an action), and returns the new state. You send actions with dispatch.

Context is a way to make a value available to every component below a certain point, without passing it down as props by hand.

useReducer is a bank teller: you don't open the vault yourself, you fill in a slip ("deposit 50") and the teller applies the rules. Context is the office Wi-Fi: once the router is on, every room can use it — no cable to each desk.

useReducer — when state changes come in patterns

When several pieces of state always change together, three separate useState calls get repetitive. Worse, they let you create impossible combinations — like loading and error both being true at once.

function reducer(state, action) {
  switch (action.type) {
    case "loading": return { loading: true,  data: null,        error: null };
    case "success": return { loading: false, data: action.data, error: null };
    case "error":   return { loading: false, data: null,        error: action.error };
    default: return state;
  }
}

const [state, dispatch] = useReducer(reducer, { loading: false, data: null, error: null });

dispatch({ type: "loading" });

Now every valid state is written down in one place. The component just says what happened ("loading started"); the reducer decides what the state becomes. It's also easy to test, because the reducer is a plain function — give it the same input and it always returns the same output, with no React involved.

useContext — sending data deep without passing it through everything

Passing a value down five levels means every component in between accepts a prop it doesn't care about, only to hand it on. That's called prop drilling. Context lets a component read a value directly, however deep it is.

The Provider is the component that holds the value and makes it available. Any component inside it that calls useContext is a consumer.

const ThemeContext = createContext("light");

function App() {
  return (
    <ThemeContext.Provider value={theme}>
      <Page />      {/* any depth below can read it */}
    </ThemeContext.Provider>
  );
}

function DeepButton() {
  const theme = useContext(ThemeContext);
}

What context is not

It's not a performance tool. Every component reading a context re-renders whenever its value changes — so a context holding fast-changing data re-renders half your app.

// ✗ a new object every render → every consumer re-renders every time
<Ctx.Provider value={{ user, setUser }}>

// ✓ stable identity
const value = useMemo(() => ({ user, setUser }), [user]);
<Ctx.Provider value={value}>
Good for context: theme, language, the logged-in user — things that rarely change. Bad for context: form text, mouse position, anything updating many times a second. For that, use a state library (Zustand, Redux Toolkit) that lets components subscribe (listen) to only the slice (the small part of the data) they need.

17. Custom Hooks

In plain words

A custom hook is your own function that bundles some hooks together so you can reuse them. The name must start with use.

A recipe card. Two cooks can follow the same recipe, but each ends up with their own cake — they don't share one cake.

A custom hook is just a function whose name starts with use and which calls other hooks. There's no special machinery — the naming convention (agreed habit) is how React's linter knows to check the rules.

The example below is a debounce hook. Debouncing means "wait until the value stops changing for a moment, then use it" — so a search box searches once when you pause typing, not on every key.

function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);      // cancel if value changes again first
  }, [value, delay]);

  return debounced;
}

// use it: only search when typing pauses
const query = useDebounced(input);
useEffect(() => { if (query) search(query); }, [query]);

They share logic, not state

Two components calling useDebounced each get their own independent state. The hook is a recipe, not a shared box. If you want shared data, that's context or a state library.

function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    try { return JSON.parse(localStorage.getItem(key)) ?? initial; }
    catch { return initial; }
  });

  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
  }, [key, value]);

  return [value, setValue];
}

This second hook works like useState, but also saves the value to localStorage (small storage inside the browser that survives a page reload).

Good reasons to extract one: the same useState + useEffect pair appears in several components, or a component has so much setup logic that the actual markup (the JSX it returns) is hard to find.

18. The Rules of Hooks

In plain words

React remembers your hooks by their order — first hook, second hook, third hook — not by their names. So the order must be exactly the same on every render.

A cloakroom with numbered hooks and no names. If one day you skip hook 1, everyone's coat is handed to the wrong person.

Two rules, and one explanation that makes both obvious.

  1. Only call hooks at the top level — never inside an if, a loop, or a nested function.
  2. Only call them from a component or another custom hook.

Why rule 1 exists

React doesn't know your hooks by name. It stores their values in a list and matches them up by the order they were called. First useState → slot 1, second → slot 2, and so on.

RENDER 1 — condition true useState("a") → slot 1 useState("b") → slot 2 useEffect(…) → slot 3 everything lines up ✓ RENDER 2 — condition false (useState "a" skipped) useState("b") → slot 1 ✗ useEffect(…) → slot 2 ✗ everything shifted up one "b" now reads the value stored for "a" and the effect gets the wrong dependency list The order must be identical on every single render — which is why conditions are banned.
Hooks are matched by position, not by name. Skip one and everything after it reads the wrong stored value.
// ✗ breaks the order
if (isLoggedIn) { const [name, setName] = useState(""); }

// ✓ hook always runs; put the condition inside
const [name, setName] = useState("");
useEffect(() => { if (isLoggedIn) load(); }, [isLoggedIn]);

Same for early returns (leaving the function before reaching the end): don't return null before calling your hooks. If you do, some renders skip hooks and the order breaks. Call every hook first, then return.

Part 4 — React in Practice

19. Data Fetching

In plain words

Data fetching means asking a server for data (usually JSON over HTTP) and showing it. Every request has three possible states to show: loading, error, and success.

Ordering food by phone: while you wait, show "on its way" (loading); if the restaurant is closed, say so (error); when it arrives, serve it (success). If you change your order, cancel the first one.

The basic version, done properly

function User({ id }) {
  const [user, setUser]       = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true); setError(null);

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(setUser)
      .catch(e => { if (e.name !== "AbortError") setError(e); })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [id]);

  if (loading) return <Spinner />;
  if (error)   return <Error message={error.message} />;
  return <Profile user={user} />;
}

Three things people forget:

  1. fetch does not throw an error on a 404 ("not found") or 500 ("server error"). It only throws when the network itself fails. So you must check r.ok yourself.
  2. You need to cancel the request in the cleanup.
  3. You need to reset error and loading when the id changes.

Why people use a library instead

That code handles one request in one component. Real apps need more:

  • Caching — keeping a saved copy of data you already fetched, so you don't fetch it again.
  • Refreshing stale (old) data quietly in the background.
  • Retrying when a request fails.
  • Deduping — if two components ask for the same thing at once, sending only one request.

Writing all that yourself is where bugs pile up.

// TanStack Query — the above, plus caching, dedupe, retries, background refresh
const { data, isLoading, error } = useQuery({
  queryKey: ["user", id],
  queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
});

queryKey is the name the cache stores this data under. Same key in two components = same cached data, one request.

Server data is not like other state. It's a copy of something that lives elsewhere and can change without telling you. That's why it needs its own tools — caching and staleness (how out-of-date your copy is) are the whole problem, and plain useState has no concept of either.

20. Performance

In plain words

Performance work means making the app feel fast: fewer wasted re-renders, less JavaScript to download, fewer elements on the page.

Rule one: measure first, then fix the slow part you actually found.

A doctor runs tests before prescribing. Guessing at a cure often adds side effects without fixing anything.

First, find the real problem

Install React DevTools (a browser extension) and use its Profiler tab to record an interaction, like typing in a box. It shows which components rendered and how long each took. Optimising without measuring usually makes code more complex and no faster.

React.memo

const Row = React.memo(function Row({ item, onSelect }) { … });

This tells React: before re-rendering this component, check whether its props actually changed. If they're all the same, skip it.

The check is shallow — it compares each prop with === and doesn't look inside objects. So it works for strings and numbers. It fails for objects and functions created fresh each render (they're new objects every time). That's why memo usually needs useCallback/useMemo alongside it to be worth anything.

Often better: move state down or pass children

// ✗ typing in the input re-renders the expensive chart
function Page() {
  const [text, setText] = useState("");
  return <><input value={text} onChange={…} /><Chart /></>;
}

// ✓ isolate the state in its own small component
function SearchBox() { const [text, setText] = useState(""); return <input … />; }
function Page() { return <><SearchBox /><Chart /></>; }
// ✓ or pass the expensive part in as children — it isn't re-created here
function Page({ children }) {
  const [n, setN] = useState(0);
  return <><button onClick={() => setN(n+1)}>{n}</button>{children}</>;
}
<Page><Chart /></Page>

children was created by the parent of Page, not by Page itself. So when Page re-renders, that element object is the same one as before. React sees no change and skips it. Restructuring like this beats memoising, because there's nothing to keep in sync.

Loading less code

The code below uses lazy loading (download a piece of code only when it's needed) and code splitting (cutting your app's JavaScript into several smaller files). Suspense shows the fallback (a stand-in, like a spinner) while that file downloads.

const Dashboard = lazy(() => import("./Dashboard"));

<Suspense fallback={<Spinner />}>
  <Dashboard />
</Suspense>

This splits Dashboard into a separate file the browser downloads only when it's needed. Split at page boundaries first, then at heavy components — charts, editors, maps.

Long lists

Rendering 10,000 rows creates 10,000 real elements and the page crawls. Virtualisation (react-window, TanStack Virtual) renders only the ~20 rows on screen and swaps them as you scroll. It's like a train window: the view changes as you move, but the window itself is always the same size.

21. Patterns Worth Knowing

In plain words

A pattern is a common, proven way of arranging components to solve a problem that comes up again and again.

Like standard furniture layouts for a kitchen — you don't invent a new one each time; you pick the one that fits.

Container and presentational

One component (the container) fetches and holds data. Another (the presentational one) just takes props and renders them. The second is easy to test and reuse, because it knows nothing about where the data came from.

Compound components

<Tabs defaultValue="a">
  <Tabs.List>
    <Tabs.Trigger value="a">First</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="a">…</Tabs.Panel>
</Tabs>

Compound components are a group of components designed to be used together, like <select> and <option> in HTML. The pieces share state through context, so the developer using them can arrange them freely without wiring anything up.

Error boundaries

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error, info) { logToService(error, info); }
  render() {
    return this.state.hasError ? <p>Something went wrong.</p> : this.props.children;
  }
}

An error thrown while rendering unmounts your whole app and leaves a blank page. An error boundary is a component that catches that error and shows a fallback message instead — like a circuit breaker that trips one room's lights instead of blacking out the whole house.

This is still the one thing that requires a class component (the older way of writing components, using class … extends React.Component) — there's no hook version. Note it catches render errors, not errors inside event handlers or async code (code that finishes later, like a fetch). Use try/catch for those.

Portals

createPortal(<Modal />, document.body);

A portal renders the modal at the bottom of the page (document.body) instead of where it sits in your component tree. So a parent's overflow: hidden (which cuts off anything sticking out) or stacking context (the rules that decide what sits on top of what) can't clip or hide it. It still behaves like a child for props and events.

22. Common Mistakes

In plain words

Most React bugs come from a small set of repeat mistakes. Learn this table and you'll recognise them on sight — in your own code and in interview "what's wrong with this code?" questions.

MistakeWhat happensFix
Changing state directly (arr.push)Screen doesn't updateCreate a new array/object
key={index} on a changing listState sticks to the wrong rowUse a stable id
Component defined inside anotherState lost on every renderMove it to the top level
Missing effect dependenciesUses stale valuesAdd them; restructure if it loops
setState in renderInfinite loopMove it to an event or effect
onClick={fn()}Runs immediately on renderonClick={fn} or {() => fn()}
{count && …}Prints "0" on screen{count > 0 && …}
No cleanup in effectsLeaks, stale data overwritesReturn a cleanup function
Storing derived data in stateTwo copies drift apartCalculate during render
Reading state right after setting itGets the old valueUse the value you just computed

The infinite loop, explained

// ✗ effect sets state → state change re-renders → effect runs again → forever
useEffect(() => { setCount(count + 1); }, [count]);

// ✗ also a loop: a new object every render is always "different"
useEffect(() => { … }, [{ id }]);

// ✓ depend on plain values
useEffect(() => { … }, [id]);

In words: an infinite loop is code that never stops repeating. Here, the effect changes state, the state change causes a render, the render runs the effect again — round and round. Depend on simple values like numbers and strings, which compare equal when they're the same.

Part 5 — Next.js

23. Why Next.js Exists

In plain words

Next.js is a framework built on React — a bigger toolkit that decides how your project is organised and adds the missing pieces: server rendering, routing, and a backend.

Server rendering means the server builds the page's HTML before sending it, so the browser can show it straight away.

Hydration is React, in the browser, attaching click handlers and state to HTML the server already sent — making the finished-looking page actually work.

Plain React sends a flat-pack wardrobe and makes the visitor build it. Next.js sends it already built; then hydration is just screwing the handles on.

Plain React ships an almost-empty HTML file plus a JavaScript bundle (all your app's code packed into one or a few files). The browser downloads the JS, runs it, and only then does anything appear.

PLAIN REACT (client only) <div id="root"></div> ↓ download 300KB of JS ↓ run it ↓ fetch the data ↓ finally render user stares at white screen NEXT.JS (rendered on the server) <h1>Products</h1>… visible immediately ✓ ↓ JS arrives in the background ↓ "hydration" — React attaches event handlers to that HTML now it's interactive too Search engines and link previews read HTML. An empty div tells them nothing — which is why plain React is a poor fit for public, searchable pages. Hydration = taking server-made HTML and wiring up the interactivity, without rebuilding it.
Next.js sends a finished page first, then makes it interactive. The user sees content while the JavaScript is still loading.

What Next.js adds

  • Server rendering — HTML built before it reaches the browser.
  • Routing from your folder structure — routing means "which page shows for which URL". Next.js works it out from your folders, so there's no route config file to maintain.
  • A backend in the same project — API endpoints (URLs that return data instead of pages) and server code beside your components.
  • Automatic optimisation — code splitting per page, image resizing, font handling.
  • Caching (saving results to reuse them) at several levels, built in.
When you don't need it: an internal dashboard behind a login has no SEO requirement (Search Engine Optimisation — being found on Google) and no first-visit-speed pressure. Plain React with Vite (a fast build tool) is simpler and perfectly good. Next.js earns its keep on public, content-heavy, or SEO-sensitive sites.

24. Rendering Strategies — the core idea

In plain words

A rendering strategy is simply your answer to "when is this page's HTML made?" — once at deploy, now and then, on every visit, or in the browser.

The short names: SSG = Static Site Generation, ISR = Incremental Static Regeneration, SSR = Server-Side Rendering, CSR = Client-Side Rendering. A CDN (Content Delivery Network) is a set of servers around the world that keep copies of your files close to users.

A bakery. SSG: bake all the bread before opening. ISR: bake ahead, but bake a fresh batch every hour. SSR: bake each loaf when the customer orders it. CSR: hand the customer flour and a recipe and let them bake at home.

The real question Next.js asks is: when should this page's HTML be built? There are three answers, and one variation.

STATIC (SSG) Built once, when you deploy. Same HTML for everyone, served instantly from a CDN. Blog posts · docs · marketing INCREMENTAL (ISR) Static, but rebuilt in the background every N seconds. Fast, with fresh-enough data. Product lists · news SERVER (SSR) Built fresh on every request. Always current, can be personal. Slower — the user waits for it. Dashboards · search results CLIENT (CSR) Built in the browser after load. No SEO, slower first paint, but fully interactive. Charts · editors · maps
Static is fastest but can go stale. Server is always fresh but makes the user wait. ISR is the practical middle ground for most content sites.

How you choose in code

// STATIC — the default in the App Router when nothing is dynamic
async function Page() {
  const posts = await fetch("https://api.site.com/posts").then(r => r.json());
  return <List posts={posts} />;
}

// ISR — rebuild at most once a minute
fetch(url, { next: { revalidate: 60 } });

// SSR — never cache, build fresh every time
fetch(url, { cache: "no-store" });

// CSR — a component marked "use client" that fetches in useEffect

Mixing them

You don't pick one per site. You pick per page, and even per part of a page. A product page can be static, while the "3 people are viewing this" badge inside it is client-rendered. That's the main advantage over the old all-or-nothing approach.

In the code above, revalidate: 60 means "this saved copy is good for 60 seconds; after that, rebuild it in the background". cache: "no-store" means "never save a copy".

25. The App Router & File Structure

In plain words

The App Router is Next.js's current routing system. It lives in the app/ folder. (The older one, in pages/, is called the Pages Router.)

Each folder is a piece of the URL. Special file names inside a folder — page, layout, loading, error — each have one job.

A building: folders are floors and rooms, the URL is the room's address, layout is the corridor you walk through to reach every room on that floor, and page is the room itself.

In Next.js, folders are your URLs and specific filenames have special jobs.

app/
  layout.tsx          → wraps everything (required at the root)
  page.tsx            → the page at  /
  loading.tsx         → shown automatically while the page loads
  error.tsx           → shown if the page throws
  not-found.tsx       → shown for 404s

  about/
    page.tsx          → /about

  blog/
    page.tsx          → /blog
    [slug]/
      page.tsx        → /blog/anything   (a dynamic segment)

  (marketing)/        → grouping only — NOT part of the URL
    pricing/page.tsx  → /pricing

  api/
    users/route.ts    → an API endpoint at /api/users
FilenameWhat it does
page.tsxMakes the folder a real, visitable URL
layout.tsxShared shell (nav, footer). Keeps its state when you navigate between child pages
loading.tsxInstant loading UI while the page's data is fetched
error.tsxCatches errors in that part of the tree; must be a client component
template.tsxLike a layout, but rebuilt on every navigation (state resets)
route.tsAn API endpoint instead of a page

Layouts nest

app/layout.tsx            → wraps every page
app/shop/layout.tsx       → also wraps everything under /shop

Visiting /shop/items renders root layout → shop layout → the page, nested inside each other like boxes inside boxes. Because the layout isn't rebuilt when you move between pages inside it, a sidebar's scroll position and open menus survive navigation (moving from page to page).

Dynamic segments

A dynamic segment is a folder name in square brackets, like [slug]. It matches any value in that part of the URL, and gives you that value in params. (A slug is the readable part of a URL, like my-first-post.)

// app/blog/[slug]/page.tsx
export default async function Post({ params }) {
  const { slug } = await params;        // params is a promise in Next 15+
  const post = await getPost(slug);
  return <article>{post.title}</article>;
}

Navigation

import Link from "next/link";
<Link href="/about">About</Link>      {/* prefetches automatically */}

"use client";
const router = useRouter();
router.push("/dashboard");

Use <Link> rather than <a>. It navigates without a full page reload. It also prefetches — quietly downloads the target page in advance when the link scrolls into view — so the click feels instant. router.push does the same navigation from code, e.g. after a form is saved.

26. Server Components — the big idea

In plain words

A Server Component is a React component that runs only on the server. It can read a database directly. Its code is never sent to the browser — only its result is.

A Client Component (a file starting with "use client") is one whose code is sent to the browser, so it can use state, effects and click handlers.

A restaurant: the kitchen (server components) cooks and sends out finished plates — you never see the kitchen's knives or recipes. The salt shaker on your table (a client component) is the bit you can actually pick up and use yourself.

This is the biggest change in modern React, and the one most worth understanding properly. By default in the App Router, your components run on the server and never reach the browser at all.

SERVER COMPONENT (default) Runs: on the server only Sent to browser: HTML only JS cost to the user: zero CAN: read the database directly, use secrets, use async/await CLIENT COMPONENT ("use client") Runs: server first, then browser Sent to browser: HTML + its JS JS cost to the user: yes NEEDED FOR: useState, useEffect, onClick, browser APIs, any interactivity The rule that follows from this: Keep pages as server components. Push "use client" DOWN to the small interactive leaves — a button, a form — not the whole page.
A server component's code never ships to the browser. That's why moving data-heavy work to the server makes the bundle smaller.

What server components let you do

// app/users/page.tsx — no "use client", so this runs on the server
import { db } from "@/lib/db";

export default async function Users() {
  const users = await db.user.findMany();   // straight to the database!
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

No API route, no useEffect, no loading state, no fetch. The component is the backend for this page. Your database password stays on the server, because none of this code is sent to the browser.

Strictly, what the server sends is HTML for the first load plus a compact description of the rendered result called the RSC payload (RSC = React Server Components). The browser uses it to fill in the page — no component code included.

When you need "use client"

"use client";                       // must be the first line of the file

import { useState } from "react";

export default function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Add it when you need state, effects, event handlers, or anything that only exists in the browser (window, localStorage). If you forget, you get an error telling you exactly this.

Note: client components still render once on the server to produce the first HTML. Then they hydrate in the browser. "Client" means "also runs in the browser", not "only runs in the browser".

How they combine

// Server component — fetches data, ships no JS
export default async function Page() {
  const product = await getProduct();

  return (
    <div>
      <h1>{product.name}</h1>       {/* static — server rendered */}
      <AddToCart id={product.id} />  {/* the only interactive bit — client */}
    </div>
  );
}

Only AddToCart's JavaScript is sent to the browser. The heading, description and images cost nothing.

Important rule: once a component is a client component, everything it imports becomes client too. "use client" marks a boundary — a line where server code ends and browser code begins. That's why you push it as far down the tree as possible. But a client component can receive a server component through children — that stays on the server.

What server components can't do

  • No useState, useEffect, or any hook that involves interactivity.
  • No event handlers (onClick, onChange).
  • No browser objects — there is no window on a server.

27. Next.js Request Lifecycle

In plain words

The request lifecycle is everything that happens between a user typing a URL and a page that works — plus what happens on the next click and the next form submit.

In short: the server checks the request, finds the right files, runs your Server Components to build the page, and streams it to the browser. The browser shows the HTML at once, downloads JavaScript, and hydrates (attaches click handlers and state to) the Client Components. After that, clicking a link only fetches the changed part of the page.

A restaurant visit. The host at the door checks your booking (middleware). A waiter finds your table (route matching). The kitchen cooks, using anything already prepped (Server Components + cache). Dishes arrive as each one is ready — starters first (streaming). You can see the food straight away (HTML), but you can't eat until the cutlery arrives (hydration). When you order dessert, they bring only the dessert — they don't reset the whole table (client-side navigation). Asking the waiter to change your order is a Server Action: one trip to the kitchen and back with the updated plate.

This section ties together pieces from the previous sections — rendering (section 24), the App Router (25), Server Components (26) — and the next ones: caching (28), Server Actions (29) and middleware (30). "Walk me through what happens when a user opens a page" is a very common senior-level interview question.

BROWSER SERVER ① Request user opens /shop/shoes ② Middleware check cookie · redirect or continue waiting — nothing on screen yet ③ Route matching app/shop/[slug]/page.tsx + its layouts ④ Render Server Components await db / fetch · use the cache if fresh ⑤ Stream the response HTML + RSC payload · slow parts later ⑥ Show the HTML visible — but buttons don't work yet ⑦ Load JavaScript React + your Client Components' code (Suspense parts keep streaming in) ⑧ Hydration attach handlers to Client Components only ⑨ Click a <Link> no reload · shared layouts are kept Render only the new segment send RSC payload (often prefetched) ⑩ Submit a form (Server Action) POST: action id + form data Run the action validate · save · revalidate → fresh UI First visit: HTML first, then hydrate. Later clicks: only the RSC payload travels. Middleware runs for every matching request — page loads, link clicks and actions.
Solid arrows are requests; dashed arrows are what comes back. The server does the heavy lifting once; the browser only wakes up the interactive parts.

Step by step

  1. The browser sends a request — for example GET /shop/shoes. Nothing is on screen yet.
  2. Middleware runs first (if middleware.ts exists and its matcher matches). It can redirect (not logged in → /login), rewrite (quietly serve a different page without changing the URL), or set headers and cookies. It runs even when the page itself is cached, which is why it must be fast.
  3. Route matching. The App Router turns the URL into folders: /shop/shoes → app/shop/[slug]/page.tsx with slug = "shoes". It collects every layout.tsx on the way down (root → shop), plus the nearest loading.tsx, error.tsx and not-found.tsx.
  4. Server Components render on the server. Layouts and the page run as async functions. They await the database or fetch. Caching can skip work here:
    • Full Route Cache — a static page was already built at deploy time (or by ISR), so the finished result is sent straight away.
    • Data Cache — a saved fetch result is reused until it's revalidated.
    • Request memoization — the same fetch called in several components during one request only really happens once.
    Client Components in the tree are rendered to HTML too (that's SSR), but their code is also marked "send this to the browser".
  5. The response is streamed. The server sends two things together:
    • HTML — so the browser can show something immediately.
    • The RSC payload — a compact description of the Server Component result. It says what to show, where each Client Component goes, which props it gets, and which JS file holds its code.
    It doesn't wait for everything. The layout shell and the loading.tsx fallback go first; each <Suspense> section is sent later, when its data is ready.
  6. The browser shows the HTML. The user can already read the page. But clicking a button does nothing yet — no JavaScript has run.
  7. JavaScript loads — React, the Next.js runtime (its browser-side code, including the router), and only the code for your Client Components. Server Components add zero JavaScript.
  8. Hydration. React walks the page and attaches event handlers and state to each Client Component, matching the HTML that's already there instead of rebuilding it. Server Component HTML is never hydrated — there's nothing to attach. With Suspense, sections hydrate independently (selective hydration), and a section the user clicks on gets hydrated first.
  9. Client-side navigation with <Link>. When a link scrolls into view, Next.js prefetches the next route in the background (production builds only). On click there is no full page reload: the browser asks the server only for the RSC payload of the parts that changed. Shared layouts are not re-rendered and keep their state (open menus, scroll position, typed text). Visited and prefetched payloads are kept in the browser's Router Cache, so Back/Forward is instant.
  10. Server Action round trip. Submitting a <form action={createPost}> sends a POST to the current URL with a hidden action id and the form data. The server runs the function (check the user, validate, write to the database), then revalidatePath/revalidateTag clears stale cache. In the same response it sends back the action's return value and the freshly rendered RSC payload, so the page updates without a reload and without a second request.

Summary table

#StepRuns whereWhat happensFiles / APIs involved
1RequestBrowser → serverUser opens a URL—
2MiddlewareServer (before routing)Redirect, rewrite, set cookies/headersmiddleware.ts, matcher
3Route matchingServerURL → folder; gather layouts and special filesapp/, page, layout, loading, error, [slug]
4Render Server ComponentsServerAwait data; reuse cache where allowedasync components, fetch options, revalidate
5Stream responseServer → browserHTML + RSC payload, slow parts later<Suspense>, loading.tsx
6Show HTMLBrowserPage visible, not yet interactive—
7Load JSBrowserDownload React + Client Component code"use client" files
8HydrationBrowserAttach handlers/state to Client ComponentsClient Components only
9Client navigationBrowser ⇄ serverFetch only the RSC payload; keep layouts<Link>, router.push, prefetch, Router Cache
10Server ActionBrowser ⇄ serverRun server code, revalidate, return result + fresh UI"use server", <form action>, revalidatePath

The files, labelled with their step

// middleware.ts                   ② runs before anything else
// app/layout.tsx                  ③④ root layout — Server Component
// app/shop/layout.tsx             ③④ shop layout — kept on client navigation ⑨
// app/shop/loading.tsx            ⑤ sent instantly as the fallback
// app/shop/[slug]/page.tsx        ③④ the page itself

import { Suspense } from "react";
import AddToCart from "./AddToCart";         // "use client" → JS sent ⑦, hydrated ⑧
import { addToCart } from "./actions";       // "use server" → Server Action ⑩

export default async function Page({ params }) {
  const { slug } = await params;
  const product = await getProduct(slug);    // ④ runs on the server, may be cached

  console.log("rendering", slug);            // prints in the SERVER terminal, not the browser

  return (
    <>
      <h1>{product.name}</h1>                 {/* plain HTML — never hydrated */}
      <AddToCart id={product.id} action={addToCart} />
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Reviews slug={slug} />              {/* ⑤ streams in when its data is ready */}
      </Suspense>
    </>
  );
}

A quick way to feel the lifecycle: put a console.log in a Server Component and one in a Client Component. The server one only ever prints in your terminal. The client one prints in the terminal (during the first server render) and in the browser console (during hydration and every re-render after).

Common interview traps: (1) "Server Components are hydrated" — no, only Client Components are; Server Components send no JavaScript at all. (2) "Clicking a Link reloads the page" — no, it fetches only the RSC payload for the changed segment, and layouts keep their state. (3) A hydration mismatch error means the HTML from step 5 didn't match what React produced in step 8 — usually from Date.now(), Math.random(), or reading window during render. (4) Middleware is not just for the first page load — it also runs for Link navigations and Server Actions that match.

28. Data Fetching & Caching

In plain words

In Next.js you fetch data inside server components with plain await. Next.js can cache the result (keep a saved copy) so later visitors get it instantly.

Revalidating means throwing away a saved copy so the next request fetches fresh data.

A cache is a printed timetable on the wall. Fast to read, but someone has to reprint it when the times change — that reprint is revalidation.

Fetching in a server component

export default async function Page() {
  const res = await fetch("https://api.site.com/posts");
  const posts = await res.json();
  return <List posts={posts} />;
}

The component is an async function and simply awaits (pauses until the data arrives). No loading state needed here — Next.js shows your loading.tsx automatically while this runs.

A tag (below) is a label you attach to cached data, so you can later say "clear everything labelled posts" after someone writes a new post.

Controlling the cache

fetch(url)                                  // may be cached — the default varies by version
fetch(url, { cache: "no-store" })            // never cache; fresh on every request
fetch(url, { next: { revalidate: 60 } })     // cache, refresh at most once a minute
fetch(url, { next: { tags: ["posts"] } })    // label it, so you can clear it on demand
// Clear a tagged cache after changing data
import { revalidateTag, revalidatePath } from "next/cache";
revalidateTag("posts");
revalidatePath("/blog");
Caching defaults changed between Next 13, 14 and 15 and caused a lot of confusion. Don't rely on memory — be explicit about cache or revalidate on every fetch that matters, and check the docs for your version.

Fetch several things at once

If one request doesn't need the other's result, start both together with Promise.all. Waiting for each in turn is called a waterfall — each request waits for the one above it to finish. (The timings in the comments are just example numbers.)

// ✗ waits for each in turn — 3 seconds
const user  = await getUser();
const posts = await getPosts();

// ✓ all at once — 1 second
const [user, posts] = await Promise.all([getUser(), getPosts()]);

Streaming — show the fast parts first

Streaming means sending the page in pieces as each piece becomes ready, instead of waiting to send it all at once. <Suspense> marks a piece that may be slow; its fallback (here a skeleton — grey placeholder shapes) shows until the real content arrives.

import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <Header />                        {/* instant */}
      <Suspense fallback={<Skeleton />}>
        <SlowComments />                {/* streams in when ready */}
      </Suspense>
    </>
  );
}

Without this, one slow query holds up the entire page. With it, Next.js sends the finished parts immediately and streams the rest in as it completes — the user reads the article while comments are still loading.

29. Server Actions & Forms

In plain words

A Server Action is a function you write next to your components, marked "use server". You call it from the browser like a normal function, but it actually runs on the server.

Next.js secretly turns the call into a network request for you.

Like a hotel room phone with a "room service" button. You press one button; behind the scenes it dials the kitchen, places the order and brings the answer back. You never deal with the phone line yourself.

A server action is a function that runs on the server but is called directly from your component — no API route, no fetch call.

// app/actions.ts
"use server";                       // marks everything here as server-only

export async function createPost(formData) {
  const title = formData.get("title");

  if (!title) return { error: "Title required" };     // always validate on the server

  await db.post.create({ data: { title } });
  revalidatePath("/posts");         // refresh the cached list
}
// the form — note: no onSubmit, no fetch, no useState
import { createPost } from "./actions";

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button>Create</button>
    </form>
  );
}

Next.js creates the endpoint, serialises the call (turns the arguments into text that can travel over the network) and handles the response for you. A useful side effect: because this is a real <form> with an action, it still works if JavaScript hasn't loaded yet. This is called progressive enhancement — the basic version works for everyone, and JavaScript makes it nicer.

formData is the browser's standard object holding every field of the submitted form; formData.get("title") reads the input named title.

Showing progress

useFormStatus tells a component inside a form whether that form is currently being submitted (pending). It must be used in a client component placed inside the <form>.

"use client";
import { useFormStatus } from "react-dom";

function Submit() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}
Treat a server action like a public API endpoint, because that's what it becomes. Anyone can call it with any data. Always check authentication and validate input inside the action — never assume it was only called from your form.

30. Route Handlers & Middleware

In plain words

A Route Handler is a file (route.ts) that answers HTTP requests with data instead of a page — your own small API.

Middleware is code that runs before a request reaches any page, and can let it through, redirect it, or change it.

Middleware is the security guard at the building's front door: checks your badge before you reach any office. A route handler is a service counter inside that hands out information rather than showing you a room.

API endpoints

// app/api/users/route.ts
import { NextResponse } from "next/server";

export async function GET(request) {
  const users = await db.user.findMany();
  return NextResponse.json(users);
}

export async function POST(request) {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

Export one function per HTTP method (the type of request: GET reads data, POST creates it, and so on). Use these when something other than your own pages needs the data — a mobile app, a webhook from Stripe (a message another service sends to your URL when something happens, like "payment succeeded"), a public API. For your own pages, server components and server actions are usually simpler.

Middleware — runs before every matching request

// middleware.ts, at the project root
import { NextResponse } from "next/server";

export function middleware(request) {
  const token = request.cookies.get("session");

  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };

The matcher lists which URLs the middleware runs for. NextResponse.next() means "let the request carry on"; redirect sends the user somewhere else.

Good for redirects, auth checks (authentication — "is this user logged in?"), locale detection (which language/country to show), and A/B splits (showing two versions of a page to different users to see which works better). It runs on every matching request before the page does, so keep it fast — no database queries, no heavy work.

31. Images, Fonts & SEO

In plain words

Next.js ships ready-made components for images and fonts that do the fiddly speed work for you, plus a simple way to set each page's title and description for Google and link previews (metadata).

Like a camera's auto mode: it picks sensible settings so the photo comes out well, without you tuning each dial.

next/image

import Image from "next/image";

<Image src="/hero.jpg" alt="Our team" width={800} height={450} priority />

What it does for you:

  • Converts images to modern, smaller formats (like WebP or AVIF).
  • Makes several sizes and serves the right one for each screen.
  • Lazy-loads anything below the fold — images you'd have to scroll down to see are only downloaded when you get near them.
  • Because you gave width and height, it reserves the space, so the page doesn't jump as images load. (That jumping is called layout shift.)

Add priority to the one big image at the top; that one shouldn't be lazy-loaded.

next/font

import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });

<body className={inter.className}>

Downloads the font at build time (when you run npm run build) and serves it from your own domain. No request to Google at runtime (while users are visiting), and no flash of the wrong font while it loads.

Metadata

export const metadata = {
  title: "Products — My Shop",
  description: "Shown in Google results and link previews",
  openGraph: { title: "Products", images: ["/og.png"] },
};

// or generate it from the data
export async function generateMetadata({ params }) {
  const post = await getPost((await params).slug);
  return { title: post.title, description: post.excerpt };
}

Because this runs on the server, the tags are in the HTML itself — which is exactly what search engines and social previews need. (openGraph is the standard set of tags that WhatsApp, Slack, LinkedIn and others read to build the preview card when someone shares your link.)

32. Deploying

In plain words

Deploying means putting your app on a server on the internet so real users can reach it. Common homes for Next.js apps: Vercel, Netlify, or your own server running Node.

Environment variables are settings (like a database address or a secret key) kept outside your code, so they can differ between your laptop and the live site.

Deploying is moving from rehearsal to opening night. Environment variables are the keys to the venue — different keys for the rehearsal room and the real theatre, and never printed on the posters.

npm run build       # check it builds before you push — most errors surface here
npm run start       # run the production build locally

Environment variables

DATABASE_URL=postgres://…          # server only — safe
NEXT_PUBLIC_API_URL=https://…      # NEXT_PUBLIC_ = sent to the browser
Anything prefixed NEXT_PUBLIC_ is baked into the JavaScript bundle and readable by anyone. Never use that prefix for a database URL, an API secret or a private key. The prefix exists precisely so this choice is visible in the variable's name.

What to check before going live

  • Every page has a title and description.
  • loading.tsx and error.tsx exist for slow or fragile routes.
  • Images use next/image with width and height.
  • Caching is explicit (written out, not left to defaults) where freshness matters.
  • Server actions validate input and check the user is allowed.
  • Run Lighthouse (a speed and quality checker built into Chrome DevTools); check the bundle for anything unexpectedly large.
Revision

Interview Q&A — quick answers

React basics

  • What is React? A library for building UIs where you describe what the screen should look like for the current data, and it works out the changes.
  • Props vs state? Props come from the parent and are read-only; state is owned by the component and changing it re-renders.
  • What is JSX? A shorthand that compiles to React.createElement calls, which produce plain objects describing the UI.
  • Why keys? So React can tell which item is which when a list changes. Index keys break on reorder or deletion and can attach state to the wrong row.
  • Why can't I change state directly? React compares by reference. The same object means "nothing changed", so no re-render.
  • Controlled vs uncontrolled input? Value held in React state vs held by the DOM and read with a ref.

How it works

  • What is the virtual DOM? A plain-object description of the UI. React compares the old and new versions and applies only the real differences.
  • What is reconciliation? The matching process: a different element type throws the subtree away; the same type is kept and updated; children are matched by key.
  • When does a component re-render? Its state changed, its parent re-rendered, or a context it reads changed.
  • Why is state not updated immediately? Updates are batched; the current render holds a frozen snapshot. Use setX(x => …) when the new value depends on the old.
  • What is batching? Several state updates in one event are collected into a single re-render.

Hooks

  • Rules of hooks, and why? Top level only, components or custom hooks only — because React matches hooks by call order, so the order must never change.
  • useEffect dependency array? Omitted → every render; [] → once; [a] → when a changes. Return a cleanup for anything you started.
  • Why does my effect run twice? StrictMode in development, on purpose, to reveal missing cleanup.
  • useMemo vs useCallback? Cache a value vs cache a function's identity. useCallback(fn, d) is useMemo(() => fn, d).
  • useState vs useRef? State re-renders when it changes; a ref doesn't. Refs are for timers, DOM nodes and values the user never sees.
  • When useReducer? When several pieces of state change together, or the transitions are complex enough to be worth writing in one place.
  • Context vs Redux? Context avoids prop drilling but re-renders every consumer; a state library adds selectors so components only re-render for the slice they use.
  • What is a custom hook? A use… function that calls other hooks. Shares logic, not state.

Lifecycle

  • What are the lifecycle stages of a React component? Mount (first appears), update (re-renders because its state, its parent, or a context changed), unmount (removed; state discarded). Each render is a render phase (pure calculation) followed by a commit phase (DOM changes, then effects).
  • How do you write componentDidMount / componentDidUpdate / componentWillUnmount with hooks? useEffect(fn, []) for mount; useEffect(fn, [deps]) for "after update when these change" (it also runs on mount); the function returned from an effect is the unmount/cleanup. shouldComponentUpdate → React.memo. Error boundaries and getSnapshotBeforeUpdate still have no hook.
  • Render phase vs commit phase? Render: React calls components and diffs the trees — must be pure, may run more than once or be thrown away. Commit: React applies changes to the DOM in one go, sets refs, runs useLayoutEffect, the browser paints, then useEffect runs.
  • useEffect vs useLayoutEffect? Both run after the DOM update. useLayoutEffect runs before paint (blocks it — use for measuring/positioning); useEffect runs after paint (the default). Cleanup runs before the next setup and on unmount, for both.
  • Why does my component mount, unmount and mount again in development? StrictMode simulates an unmount/remount (and double-calls render) in development only, to expose missing cleanups and impure renders. Production runs once.
  • Walk me through a Next.js App Router request. Middleware → route matching (folders, layouts) → Server Components render on the server with data and caching → HTML + RSC payload stream back (Suspense/loading.js first) → browser shows HTML → JS loads → Client Components hydrate. After that, <Link> fetches only the (often prefetched) RSC payload and keeps layouts; a Server Action is one POST that returns the result plus refreshed UI.
  • Are Server Components hydrated? No. They send HTML and RSC payload but no JavaScript. Only Client Components are hydrated.

Next.js

  • Why Next.js over plain React? Server rendering for speed and SEO, file-based routing, a backend in the same project, and built-in optimisation.
  • SSG vs ISR vs SSR vs CSR? Built at deploy / rebuilt periodically / built per request / built in the browser.
  • What is hydration? React attaching event handlers to server-rendered HTML to make it interactive. A "hydration mismatch" means server and client produced different markup — usually from Date.now(), Math.random() or reading window during render.
  • Server vs client components? Server is the default and ships no JavaScript but can't be interactive; "use client" adds interactivity at the cost of bundle size.
  • Why push "use client" down the tree? Everything a client component imports also becomes client. Keeping it at the leaves keeps the bundle small.
  • What is a server action? A server function callable straight from a component or form, with no API route. Must still validate and check auth.
  • Route handler vs server action? Handlers are for external consumers (mobile apps, webhooks); actions are for your own UI.
  • What does middleware do? Runs before matching requests — auth redirects, locale, A/B tests. Must be fast.
  • What is streaming with Suspense? Sending the finished parts of a page immediately and streaming slow parts in as they're ready.
  • NEXT_PUBLIC_? Marks a variable as safe to send to the browser. Never use it for secrets.

Learning Path

  1. Solid JavaScript first — array methods, destructuring, spread, promises, async/await. React is JavaScript; gaps here feel like React confusion.
  2. Components and props — build a static page out of small components with no state at all.
  3. State and events — a counter, a toggle, a form. Get comfortable with the snapshot idea from section 4.
  4. Lists and conditionals — a to-do app: add, delete, toggle, filter. This drills keys and immutable updates.
  5. Effects and fetching — load from a public API with real loading, error and empty states. Add cleanup.
  6. Project 1 — a multi-page app with routing, forms with validation, and proper error handling.
  7. The hooks that need practice — useRef, useMemo, useCallback, useReducer, useContext. Write two custom hooks of your own.
  8. Data properly — swap hand-written fetching for TanStack Query and notice what it removes.
  9. Next.js basics — App Router, layouts, server components, a page with real data.
  10. Next.js properly — rendering strategies, caching, server actions, streaming with Suspense.
  11. Project 2 — a full-stack Next.js app: database, auth, forms via server actions, deployed publicly.
  12. Then — TypeScript everywhere, testing with React Testing Library, and an accessibility and performance pass.
The habit that matters most: for every component, ask "what does this look like while loading, when the request fails, when the list is empty, when the text is three times longer, and with only a keyboard?" Answering those five turns tutorial code into real engineering.