React NotesRohit’s interview study guide
Chapter 03

State & Events

State vs props, useState, batching, functional updates and stale closures, immutability and Immer, events and the synthetic event system, lifting state up, controlled vs uncontrolled inputs, and deciding where state should live.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#State vs props

Props are what a component is given; state is what a component remembers. Props come from the parent and the component can't change them. State belongs to the component instance, survives between renders, and changes when the component calls its setter — and every change makes React render that component again.

From my notes: props are inputs passed from a parent, immutable, and make a component dynamic; state is local to a component, can change over time, and controls its behaviour. One correction: my notes call state mutable. It changes over time, but you never mutate it — you replace it by calling the setter with a new value, and React keeps the old value untouched until the next render. Treat both props and state as read-only snapshots; the difference is who's allowed to ask for a new one.

A mental model: props are the settings someone else chose for you (like the temperature a thermostat is told to hold); state is your own memory (like how long the heater has been on). A parent can change your settings; only you can change your memory.

StepCounter.jsx
import { useState } from "react";

export function StepCounter({ step, label }) {
  const [count, setCount] = useState(0); // owned here, changes over time

  return (
    <button onClick={() => setCount(count + step)}>
      {label}: {count}
    </button>
  );
}

export function Settings() {
  const [step, setStep] = useState(1);
  return (
    <>
      <StepCounter step={step} label="Score" />
      <button onClick={() => setStep(10)}>Use step 10</button>
    </>
  );
}

// click Score twice     → "Score: 2"
// click "Use step 10"   → still "Score: 2" (the prop changed, the state didn't)
// click Score once more → "Score: 12"
What’s happening
  1. Settings owns step (state, starts at 1) and passes it to StepCounter as a prop. StepCounter owns count (its own state, starts at 0). label is a prop too, a fixed string.
  2. Two clicks on Score: each runs setCount(count + step) with step = 1, so count goes 0 → 1 → 2 and the button reads Score: 2. Only StepCounter re-renders; Settings is untouched.
  3. "Use step 10" changes Settings's state. Settings re-renders and passes step={10} down. StepCounter re-renders with the new prop — but its count stays 2, because state belongs to the instance and isn't reset by new props.
  4. The next Score click uses the new prop: 2 + 10 = 12. Props and state combine freely during render; what differs is ownership.
  5. StepCounter can't change step itself. If it needed to, Settings would pass down a callback like onStepChange (see One-way data flow).
PropsState
Where it comes fromThe parent, as JSX attributesThe component itself (useState, useReducer)
Who can change itOnly the parent, by re-rendering with new valuesOnly the component, by calling its setter
Changes trigger a re-render?Yes, when the parent re-rendersYes, when the setter gets a different value
Survives re-rendersRe-supplied by the parent each timeKept by React for that instance
Reset whenParent passes something elseComponent unmounts, or its key changes
Mutate it directly?No — frozen in developmentNo — replace it via the setter
Should it be state?

For a product page, which of these should be state in the component? (a) the product id from the URL, (b) whether the "details" panel is expanded, (c) the price including tax, (d) the text in a review textarea.

Show answer

Only (b) and (d).

What’s happening
  1. (a) comes from outside (the router or the parent), so it's a prop or comes from a hook like useParams — not something this component decides.
  2. (b) changes over time because of the user, and nothing else can compute it, so it's state.
  3. (c) can be computed during render from the price and tax rate. Storing it in state means keeping two copies in sync, which is how stale-total bugs appear (see Where state lives: a decision guide).
  4. (d) changes as the user types and must survive re-renders, so it's state (for a controlled textarea), or it can live in the DOM (uncontrolled). Either way, not a prop.

#useState

useState is the hook that gives a function component a piece of state. You call it with an initial value and get back a pair: the current value for this render, and a setter function to request a new one. const [count, setCount] = useState(0); uses array destructuring, so you name the pair whatever you like.

Each render is a snapshot. Within one render, count is a constant — calling setCount doesn't change the variable you already have; it tells React "next time you render this component, use this new value". The mental model: a component's render is a photo, not a live video. The setter asks React to take a new photo; the one you're holding doesn't change.

React
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(`after setCount: ${count}`);
  }

  console.log(`render: ${count}`);
  return <button onClick={handleClick}>{count}</button>;
}

// one click logs, in order:
// render: 0
// after setCount: 0     ← still 0: this render's snapshot
// render: 1
What’s happening
  1. First render: useState(0) sees no stored state for this component yet, so it stores 0 and returns [0, setCount]. render: 0 is logged and the button shows 0.
  2. A click runs handleClick, which belongs to the first render, so its count is 0. setCount(0 + 1) queues an update to 1. It does not reassign the count constant — it can't; it's a const local to that render.
  3. The next line logs after setCount: 0. That's the snapshot: everything in this handler sees the values from the render that created it.
  4. After the handler returns, React processes the queue and calls Counter again. This time useState(0) ignores its argument (the initial value is only used on the first render) and returns the stored 1. render: 1 is logged.
  5. "Why does my state not update right after I set it?" is one of the most common React questions. The answer is this snapshot behaviour: use the value you're about to set (const next = count + 1; setCount(next); use(next);), or read it in the next render.

Things to know about useState:

  • The initial value is used once. On later renders the argument is ignored. That's also why copying a prop into state (useState(props.colour)) doesn't follow later prop changes (see Where state lives: a decision guide).
  • Lazy initialiser: pass a function to compute an expensive initial value only once: useState(() => readDraftFromStorage()). With useState(readDraftFromStorage()) (calling it), the function runs on every render and the result is thrown away. In the test, typing three characters made the eager version call it 4 times (1 mount + 3 re-renders) and the lazy version once.
  • Setting an object replaces it; it doesn't merge. setForm({ name: "Asha" }) on { name, email } leaves you with { name: "Asha" } — the email is gone (the test logs exactly that). Spread to keep the rest: setForm({ ...form, name: "Asha" }). (Class components' this.setState did merge, which is where the confusion comes from — see this.state and setState.)
  • Setting the same value skips the re-render. React compares old and new with Object.is. A toggle that calls setOn(false) while already false rendered once in total across two clicks.
  • The setter is stable. It's the same function on every render (the test collects it into a Set across three renders and gets size 1), so it's safe to pass down and leave out of dependency arrays.
  • State is per instance and per position. Two <Counter />s have separate state, and state stays attached to a component as long as it renders at the same place in the tree with the same type and key (see The Virtual DOM and reconciliation).
AdvancedHow React knows which state is which

useState has no name or id argument, so how does const [a] = useState(1); const [b] = useState(2); get the right values back? Call order. React keeps a list of hook slots for each component instance and hands them out in the order the hooks are called: the first useState call gets slot 0, the second slot 1, every render. That's the reason for the rule "only call hooks at the top level, never inside conditions or loops" — a conditional hook shifts every slot after it (see Rules of hooks and How hooks work under the hood).

#State updates are batched

When an event handler calls several setters, React doesn't re-render after each one. It queues the updates and renders once, after the handler finishes, with all of them applied. This is batching. It avoids wasted renders and, more importantly, stops the user ever seeing a half-updated screen where the name has changed but the age hasn't.

From my notes: "state updates are asynchronous and React batches them for performance." Batching, yes. "Asynchronous" is a common way to say it but it's imprecise: the setter doesn't return a promise and you can't await it. What happens is that the setter schedules a render instead of running one immediately, and the current render's variables never change (the snapshot from useState). A mental model: a waiter writing down an order. You can say "a coffee… and a croissant… and make it a large", and the kitchen gets one ticket when you're done talking, not three.

Batching.jsx
import { useState } from "react";
import { flushSync } from "react-dom";

export const renders = [];

export function Profile() {
  const [name, setName] = useState("Rohit");
  const [age, setAge] = useState(30);
  renders.push(`${name}, ${age}`);

  function inHandler() {
    setName("Asha");
    setAge(31);
  }

  function inTimeout() {
    setTimeout(() => {
      setName("Ravi");
      setAge(40);
    }, 100);
  }

  async function afterAwait() {
    await Promise.resolve();
    setName("Meera");
    setAge(25);
  }

  function withFlushSync() {
    flushSync(() => setName("Dev"));
    // the DOM already shows "Dev, 25" here
    flushSync(() => setAge(50));
  }

  // …a <p>{name}, {age}</p> and one button per handler
}

// renders recorded per click (React 19.3, createRoot):
// handler   → ["Asha, 31"]
// timeout   → ["Ravi, 40"]
// await     → ["Meera, 25"]
// flushSync → ["Dev, 25", "Dev, 50"]
What’s happening
  1. Inside a React event handler (inHandler): two setters, one render, and that render already has both new values: Asha, 31. There's never a render with Asha, 30.
  2. Inside a setTimeout (inTimeout): also one render, Ravi, 40. This is React 18's automatic batching: since React 18, with createRoot, updates are batched wherever they come from — timeouts, promises, native event listeners.
  3. After an await (afterAwait): the setters run in a later microtask, long after the click handler returned, and are still batched into one render: Meera, 25.
  4. flushSync opts out: React renders and updates the DOM before flushSync returns. Two flushSync calls give two renders: Dev, 25 (only the name applied yet) and then Dev, 50. In a separate test, reading the DOM straight after flushSync(() => setN(1)) gave 1; without flushSync, the same read inside a handler gave the old 0.
  5. Use flushSync rarely — when you must measure or scroll to new DOM immediately after an update (for example, scrolling a freshly added list item into view). It forces synchronous work and defeats batching.

#Functional updates and stale closures

A setter accepts either a value (setCount(5)) or an updater function (setCount((c) => c + 1)). With a value, you say "set it to this". With an updater, you say "whatever it is by then, compute the next one like this". React queues updater functions and runs them in order, each receiving the result of the previous one.

This matters because of closures. Every function created during a render — event handlers, timeout callbacks, effect callbacks — closes over that render's state (see Closures on the JavaScript site). If the function runs later, after the state has moved on, it still sees the old value: a stale closure. From my notes: "Closures can cause unexpected state updates, especially if you reference state variables directly in an asynchronous callback."

Updaters.jsx
export function TripleCounter() {
  const [count, setCount] = useState(0);

  function addThreeWrong() {
    setCount(count + 1); // count is 0 in this render…
    setCount(count + 1); // …still 0
    setCount(count + 1); // …still 0  → queues "set to 1" three times
  }

  function addThreeRight() {
    setCount((c) => c + 1); // 0 → 1
    setCount((c) => c + 1); // 1 → 2
    setCount((c) => c + 1); // 2 → 3
  }

  // …
}

// click "+3 (value)"   → count: 1
// click "+3 (updater)" → count: 4
What’s happening
  1. addThreeWrong belongs to a render where count is 0. All three calls evaluate 0 + 1 before React does anything, so the queue is [set 1, set 1, set 1]. The next render processes it and ends on 1.
  2. addThreeRight queues three functions. When React re-renders, it runs them in order against the latest value: the first gets 1 (the current state after the first click), returns 2; the second gets 2, returns 3; the third returns 4.
  3. The updater's argument is the pending state — the result of everything queued before it — not the snapshot the handler closed over. That's why updaters can't go stale.
  4. Rule of thumb: when the next state is computed from the previous state, use the updater form. When you're setting an unrelated value (setName(e.target.value)), a plain value is fine.

The example from my notes — a delayed update inside setTimeout:

Updaters.jsx
export function DelayedLike({ functional }) {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      if (functional) setCount((prevCount) => prevCount + 1); // correctly uses the latest state
      else setCount(count + 1); // stale closure: `count` is from the render that created this handler
    }, 1000);
  };

  return <button onClick={handleClick}>Likes: {count}</button>;
}

// click three times quickly, wait 1 s:
// setCount(count + 1)               → "Likes: 1"
// setCount((prevCount) => prevCount + 1) → "Likes: 3"
What’s happening
  1. All three clicks happen within the same second. No state has changed yet, so no re-render has happened, and all three clicks run the same handleClick from the first render, where count is 0.
  2. Each click schedules a timer. Each timer's callback closes over that render's count, which is 0.
  3. One second later, the three callbacks run. With setCount(count + 1), each one computes 0 + 1 — three "set to 1" updates — and the button shows Likes: 1 even though the user clicked three times.
  4. With setCount((prevCount) => prevCount + 1), the callbacks don't read count at all. React feeds each updater the latest value: 0 → 1 → 2 → 3. The button shows Likes: 3.
  5. The stale value isn't a React bug — it's ordinary JavaScript closure behaviour. React just makes it easy to hit, because every render creates new functions with new snapshots.
Predict the count
React
function handleClick() {
  setCount(count + 5);
  setCount((c) => c * 2);
  setCount(count + 1);
}

count is 1 when the button is clicked. What does the next render show?

Show answer

2.

What’s happening
  1. All three lines run with this render's snapshot, count = 1. The queue becomes: set to 6 (1 + 5), double it, set to 2 (1 + 1).
  2. React processes them in order: start 1, replace with 6, double → 12, replace with 2.
  3. The last plain value wins because a value replaces whatever came before it, while an updater builds on it. Mixing the two in one handler is legal but confusing; use one style per piece of state.

#Immutability: why mutation breaks re-renders

React decides whether state changed by comparing references: it checks the new value against the old one with Object.is. For numbers and strings that compares the values. For objects and arrays it compares identity — is this the same object in memory? So if you mutate an array and pass the same array back to the setter, React sees the same reference and assumes nothing changed.

From my notes: React compares previous and current state by reference; if you mutate the state directly without creating a new reference, React won't detect the change and won't re-render. My notes also say React does a "shallow comparison" of state — that's a small mix-up worth fixing for interviews. For useState, React does not look inside the object at all; it's a single Object.is(old, new) check on the value itself. Shallow comparison (checking each top-level property) is what React.memo does with props (see React.memo).

A mental model: state is a photograph pinned to a board. React looks at whether you pinned a new photo. Drawing on the existing photo with a pen (mutation) doesn't make React look again — and worse, it changes the "old" photo React was keeping for comparison.

Items.jsx
export function Items({ mutate }) {
  const [items, setItems] = useState([1, 2, 3]);
  const [, setTick] = useState(0);

  const addItem = (newItem) => {
    if (mutate) {
      items.push(newItem); // ❌ direct mutation
      setItems(items);     // same reference → React skips the re-render
    } else {
      setItems([...items, newItem]); // ✅ new array
    }
  };

  return (
    <>
      <p>{items.join(", ")}</p>
      <button onClick={() => addItem(items.length + 1)}>Add</button>
      <button onClick={() => setTick((t) => t + 1)}>Unrelated update</button>
    </>
  );
}

// mutate:  Add → "1, 2, 3"   Add → "1, 2, 3"   Unrelated update → "1, 2, 3, 4, 5"
// spread:  Add → "1, 2, 3, 4"
What’s happening
  1. Mutating version, first Add: items.push(4) changes the array in place to [1, 2, 3, 4]. setItems(items) passes the same array object. Object.is(old, new) is true, so React bails out and doesn't re-render. The screen still says 1, 2, 3.
  2. Second Add: items.length is now 4 (the hidden mutation), so it pushes 5. Still the same array, still no render, still 1, 2, 3 on screen while the data is [1, 2, 3, 4, 5].
  3. Unrelated update: a different piece of state changes, so the component re-renders for its own reasons. It reads items — the mutated array — and suddenly shows 1, 2, 3, 4, 5. The UI "catches up" at a random later moment. This is why mutation bugs feel haunted: they show up when something else happens.
  4. Spread version: [...items, newItem] creates a new array. Object.is is false, React re-renders, and the screen shows 1, 2, 3, 4 immediately.
  5. Immutability isn't a style preference in React: the "did it change?" check is a reference check, so a new reference is how you say "it changed". It also keeps the previous state intact, which features like useMemo dependencies, React.memo, concurrent rendering and undo all rely on.

The immutable versions of everyday operations:

Instead of (mutates)Write (returns new)
arr.push(x)[...arr, x]
arr.unshift(x)[x, ...arr]
arr.splice(i, 1)arr.filter((_, j) => j !== i) or arr.toSpliced(i, 1) (ES2023)
arr[i] = xarr.map((v, j) => (j === i ? x : v)) or arr.with(i, x) (ES2023)
arr.sort() / arr.reverse()arr.toSorted() / arr.toReversed() (ES2023), or [...arr].sort()
obj.key = v{ ...obj, key: v }
delete obj.keyconst { key, ...rest } = obj; then use rest

Nested objects need a copy at every level you change:

Nested.jsx
const [user, setUser] = useState({
  name: "John",
  age: 30,
  address: { street: "123 Main St", city: "Somewhere" },
});

const moveToAnywhere = () =>
  setUser({
    ...user,                                     // copy the top level
    address: { ...user.address, city: "Anywhere" }, // copy the level you change
  });
What’s happening
  1. { ...user } makes a new top-level object with the same name, age and — importantly — the same address object reference.
  2. address: { ...user.address, city: "Anywhere" } replaces that reference with a new address object that copies street and overrides city.
  3. Result: a new user, a new address, and everything else shared with the old state. React sees a new reference and re-renders; the test checks the heading now says Anywhere.
  4. If you only spread the top level and then did next.address.city = "Anywhere", you'd be mutating the address object that the old state still points to. Three levels deep, this gets verbose quickly — which is exactly what Immer fixes.
Why doesn't this re-render?
React
const [user, setUser] = useState({ name: "Rohit" });

function rename() {
  user.name = "Asha";
  setUser(user);
}
Show answer

user is the same object before and after, so Object.is(prev, next) is true and React skips the render.

What’s happening
  1. user.name = "Asha" mutates the object React is holding as the current state.
  2. setUser(user) passes that same object back. React compares references, finds them identical, and bails out.
  3. The data has changed but the screen hasn't. The next unrelated re-render will suddenly show Asha.
  4. Fix: setUser({ ...user, name: "Asha" }), or with Immer setUser(produce((d) => { d.name = "Asha"; })).

#Immer for nested updates

Immer is a small library that lets you write updates as if you were mutating, while it produces a correct new immutable object for you. You get a draft — a proxy of your state — and change it with normal assignments, push, delete and so on. Immer records what you changed and builds a new object that copies only the changed paths, sharing everything else with the original.

From my notes: for complex applications you can use libraries like Immutable.js or Immer; Immer lets you write code that looks like mutation but returns a new immutable object, so React re-renders correctly. (Immutable.js, with its own Map/List types, is rarely chosen today — Immer works on plain objects, which is why it won. It's also what Redux Toolkit uses inside createSlice reducers, see Redux Toolkit: createSlice and configureStore.)

The mental model: Immer gives you a tracing-paper copy to draw on. You scribble freely; when you're done, Immer produces a clean new drawing that includes your edits, and the original underneath is never touched.

The "beautiful code using Immer" from my notes, with one fix — import produce from "immer" no longer works:

ImmerProfile.jsx
import { useState } from "react";
import { produce } from "immer"; // named import — Immer 10+ has no default export

export function UserProfile() {
  const [user, setUser] = useState({
    name: "John",
    age: 30,
    address: { street: "123 Main St", city: "Somewhere" },
  });

  const updateName = () => {
    setUser(
      produce(user, (draft) => {
        draft.name = "Jane"; // looks like mutation, but Immer returns a new object
      })
    );
  };

  const updateAddress = () => {
    setUser(
      produce((draft) => {
        draft.address.city = "Anywhere"; // deep update, no spreads
      })
    );
  };

  return (
    <div>
      <h1>{user.name}</h1>
      <h2>{user.address.city}</h2>
      <button onClick={updateName}>Update Name</button>
      <button onClick={updateAddress}>Update Address</button>
    </div>
  );
}

// click both buttons → <h1>Jane</h1> <h2>Anywhere</h2>
What’s happening
  1. The import. Immer 10 (2023) removed the default export. My notes' import produce from 'immer' now fails: in Node with SyntaxError: The requested module 'immer' does not provide an export named 'default', and in a Vite 8 build with [MISSING_EXPORT] "default" is not exported by …/immer/dist/immer.mjs. Use import { produce } from "immer" (the sandbox has Immer 11.1).
  2. produce(user, recipe) — the form from my notes. Immer creates a draft of user, runs the recipe (draft.name = "Jane"), and returns a new object { name: "Jane", age: 30, address: <same address object> }. setUser gets a new reference, so React re-renders.
  3. produce(recipe) with no base state returns a curried producer: a function that takes a state and returns the next one. Passing it to setUser makes it an updater function (see Functional updates and stale closures), so it works on the latest state instead of the user captured by this render. Prefer this form.
  4. draft.address.city = "Anywhere" is a deep update in one line. Immer copies user and address (the path that changed) and nothing else — the same result as the nested spread in the previous topic, without the spreads.
  5. The original state is never modified. The test clicks both buttons and sees Jane and Anywhere.
AdvancedWhat Immer guarantees: structural sharing and freezing
JavaScript
const prev = { name: "John", address: { city: "Somewhere" }, tags: ["a"] };
const next = produce(prev, (d) => { d.name = "Jane"; });
const same = produce(prev, (d) => { d.name = "John"; });

next === prev;                // false — something changed, new root
next.address === prev.address; // true  — untouched branches are shared
next.tags === prev.tags;      // true
same === prev;                // true  — a no-op recipe returns the original
Object.isFrozen(next);        // true  — and so is next.address
next.name = "X";              // TypeError: Cannot assign to read only property 'name' of object '#<Object>'
What’s happening
  1. Structural sharing: only the objects on the path to a change are copied. address and tags weren't touched, so next reuses them. That makes Immer cheap even for big state, and it keeps React.memo and useMemo working, because unchanged branches keep their identity.
  2. No-op detection: assigning a value that's already there ("John") isn't a change, so produce returns the original object. Passing it to a setter makes React bail out — no wasted render.
  3. Auto-freeze: Immer deep-freezes what it returns (in development and production by default), so accidental mutation outside a producer throws instead of silently corrupting state. The test's next.name = "X" throws the TypeError above and prev.name stays "John".
  4. Recipes can either mutate the draft or return a brand-new value, not both. And you can push on a draft array (draft.items.push(x)), which is the main ergonomic win over spreads.

The use-immer package wraps this pattern in a hook, const [user, updateUser] = useImmer(initial), so you write updateUser((draft) => { draft.name = "Jane"; }). It isn't installed in the sandbox, so I haven't run it; it's a thin wrapper over useState + curried produce.

When to reach for Immer: state that's nested two or more levels, or updates that touch arrays of objects (todos[i].done = true). For flat state, spreads are fine and one dependency fewer.

#Handling events

React handles DOM events through props on JSX elements, named in camelCase: onClick, onChange, onSubmit, onKeyDown, onMouseEnter. You pass a function — React calls it when the event happens, with an event object as the argument. Handlers are usually defined inside the component, so they can read props and state and call setters.

The mental model: an event handler prop is leaving your phone number, not making the call. onClick={handleClick} says "call this when clicked". onClick={handleClick()} makes the call right now, during render, and leaves its return value as the number.

Events.jsx
export function NewsletterForm({ onSubscribe }) {
  const [email, setEmail] = useState("");
  const [log, setLog] = useState([]);

  function handleSubmit(event) {
    event.preventDefault(); // stop the browser's full-page form submission
    onSubscribe(email);
  }

  function handleTopic(topic) {
    setLog([...log, topic]);
  }

  return (
    <form onSubmit={handleSubmit} onClick={() => setLog((l) => [...l, "form clicked"])}>
      <input aria-label="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      <button type="button" onClick={() => handleTopic("react")}>React</button>
      <button
        type="button"
        onClick={(e) => {
          e.stopPropagation(); // the form's onClick won't hear this one
          handleTopic("kotlin");
        }}
      >
        Kotlin
      </button>
      <button type="submit">Subscribe</button>
      <output>{log.join(", ")}</output>
    </form>
  );
}

// click React, then Kotlin → log: "react, form clicked, kotlin"
// type "r@x.io", click Subscribe → onSubscribe("r@x.io"), no page reload
What’s happening
  1. Passing arguments: onClick={() => handleTopic("react")} wraps the call in an arrow function, so handleTopic runs on click, not during render. That's the standard way to pass extra arguments.
  2. Bubbling: clicking React runs the button's handler ("react"), then the event bubbles to the form, whose onClick adds "form clicked". React events bubble through the component tree just like DOM events.
  3. stopPropagation: the Kotlin button calls e.stopPropagation(), so the event stops at the button and the form's onClick never runs. The log after both clicks is exactly react, form clicked, kotlin.
  4. preventDefault: a form submit normally makes the browser navigate (a full page reload). event.preventDefault() cancels that so React can handle it; onSubscribe is called with "r@x.io".
  5. The input uses onChange to copy every keystroke into state — a controlled input (see Controlled vs uncontrolled components).

Other things that differ from plain DOM code:

  • return false doesn't prevent anything. In inline HTML handlers it did; in React you must call e.preventDefault(). A test with onSubmit={() => false} confirmed defaultPrevented stayed false.
  • Capture phase: add Capture to the name — onClickCapture runs on the way down, before the target's onClick. For a click on a button inside a div, the order is div capture → button capture → button bubble → div bubble.
  • Name handler props after what happened: a component exposes onSelect, onClose, onSubscribe; inside it, the functions are conventionally handleX.
  • Defining handlers inline is fine. A new arrow function each render costs almost nothing; it only matters when passed to a memoized child, which is what useCallback is for (see useCallback).

#Synthetic events and event pooling

The event your handler receives is not the browser's native event. It's a SyntheticEvent: a React wrapper with the same interface as the native one (type, target, currentTarget, preventDefault(), stopPropagation()…), plus nativeEvent if you need the original. React creates it so events behave the same way across browsers and so React can run its own propagation through the component tree.

From my notes: a synthetic event is React's wrapper around the browser's native event; React normalises the event system so events behave the same regardless of browser (older Internet Explorer behaved differently), and properties like event.target, event.type, preventDefault() and stopPropagation() are normalised everywhere. All still true. Two parts of my notes are outdated, though: React no longer uses an event pool, and its delegation no longer happens at the document.

React
function handleClick(event) {
  console.log(event.type); // "click"
  setTimeout(() => {
    console.log(event.type); // React 17+: "click"   (React 16 and earlier: null — the event was pooled)
  }, 1000);
}

function handleClickWithPersist(event) {
  event.persist(); // React 16: prevents pooling. React 17+: does nothing (kept so old code doesn't break)
  setTimeout(() => {
    console.log(event.type); // "click"
  }, 1000);
}

// logs in React 19.3:
// "click", "persist is function, returns undefined", "later: click", "later: click"
What’s happening
  1. React 16 and earlier (my notes' version): to save memory, React kept a pool of event objects and reused them. As soon as your handler returned, React wiped every property (event.type became null) and put the object back in the pool. Reading the event in a setTimeout or after an await gave null, unless you called event.persist() to take that object out of the pool.
  2. React 17 removed pooling. Modern browsers make short-lived objects cheap, and the pool caused exactly the confusion above. Every event is now a fresh object you can keep as long as you like.
  3. In React 19.3 the test reads event.type inside a 1-second timeout and gets "click" both with and without persist().
  4. event.persist still exists, so old code doesn't crash, but it's a no-op that returns undefined. If you see it in a codebase, it's a sign the code predates React 17, and it can be deleted.

What a synthetic event looks like in React 19.3 — the test logged these for a click on a <span> inside a <div onClick>:

PropertyValue
event.constructor.name"SyntheticBaseEvent"
event.type"click"
event.target.tagName"SPAN" — the element actually clicked
event.currentTarget.tagName"DIV" — the element whose handler is running
event.nativeEvent.constructor.name"PointerEvent" — the browser's own event
methodspreventDefault, stopPropagation, isDefaultPrevented, isPropagationStopped, persist, isPersistent
AdvancedEvent delegation: from document to the root (React 17)

React doesn't attach a listener to every button. It attaches one listener per event type at the top and, when an event arrives, walks the component tree from the target upwards, calling the matching onX props. This is event delegation.

  • React 16 and earlier: the listeners were on document.
  • React 17+: they're on the root container you passed to createRoot (#root).

The change was made for gradual upgrades and embedding: with listeners on document, two React apps (or two React versions) on one page interfered — e.stopPropagation() in one app couldn't stop the other's handlers, because both were already at document. With listeners on each root, every app is self-contained. That matters for micro-frontends, where several separately-built React apps share a page (see Micro-frontends).

Two consequences you can observe in React 19.3:

  • A native document.addEventListener("click", …) doesn't fire when a React handler calls e.stopPropagation(), because React's root listener runs first and the native event really is stopped before reaching document. In React 16 the document listener still ran. (The test confirms docHeard stays empty.)
  • A native listener added directly on the element runs before the React handler, because the native event reaches the element before bubbling up to the root where React listens. The test logs ["native on button", "React onClick"].

React 17 also stopped emulating bubbling for onScroll, switched onFocus/onBlur to the native focusin/focusout events, and made onXCapture use the real browser capture phase.

#Lifting state up

When two components need the same piece of state — one changes it, another displays it, or both must stay in sync — you move the state up to their closest common parent and pass it down as props, along with a callback to change it. That's lifting state up. Siblings can't share state directly; the parent becomes the single owner and the siblings become views of it.

From my notes: lifting state up means moving state from a child to a common parent when multiple components need to share or sync it. Why:

  1. Shared state — when several children need to read or update the same state, the closest common ancestor can give it to all of them.
  2. Single source of truth — the state is managed in one place, which reduces complexity and makes debugging easier.
  3. Simplified data flow — React's one-way data flow (parent to child) is preserved, so the flow of data is easy to follow.

The mental model: two people editing copies of the same spreadsheet drift apart. Put one copy on a shared drive (the parent), let both read from it, and route every edit through it.

Lifting.jsx
import { useState } from "react";

const BOOKS = ["Clean Code", "Refactoring", "React in Depth", "Kotlin in Action"];

export function BookSearch() {
  const [query, setQuery] = useState(""); // lifted: both children need it
  const matches = BOOKS.filter((b) => b.toLowerCase().includes(query.toLowerCase()));

  return (
    <>
      <SearchBox query={query} onQueryChange={setQuery} />
      <ResultCount count={matches.length} />
      <ResultList books={matches} />
    </>
  );
}

function SearchBox({ query, onQueryChange }) {
  return <input aria-label="search" value={query} onChange={(e) => onQueryChange(e.target.value)} />;
}

function ResultCount({ count }) {
  return <p>{count} found</p>;
}

function ResultList({ books }) {
  return <ul>{books.map((b) => <li key={b}>{b}</li>)}</ul>;
}

// type "in" → "3 found", ["Refactoring", "React in Depth", "Kotlin in Action"]
What’s happening
  1. Before lifting, the natural first version keeps query in SearchBox's own state. It works for the input — but ResultList and ResultCount are siblings and can't see it.
  2. After lifting, query lives in BookSearch, the closest component above all three. SearchBox becomes controlled by its parent: it displays query and reports changes through onQueryChange.
  3. Typing i then n calls onQueryChange("i"), then onQueryChange("in"). Each call updates BookSearch's state; BookSearch re-renders, filters, and passes the results down.
  4. matches is computed from query during render — not stored as more state. ResultCount gets 3 and ResultList gets the three matching titles. They can't disagree, because both come from the same query.
  5. The children are now simple and reusable: ResultList renders any list of books, and could be tested without a search box at all.

How far to lift: only to the closest common parent that needs it, not "to the top just in case". Lifting too high makes every keystroke re-render a large part of the tree and couples unrelated components. When the common parent is many levels up and the props pass through components that don't use them, that's the point to consider composition or context (see The Context API).

#Controlled vs uncontrolled components

Form inputs keep their own value in the DOM — type into an <input> and the browser remembers the text. React gives you two ways to work with that:

  • Controlled: React state is the source of truth. You pass value={state} and update the state in onChange. The input shows exactly what the state says, on every keystroke.
  • Uncontrolled: the DOM is the source of truth. You give an optional starting value with defaultValue and read the current value when you need it — through a ref, or from the form with FormData on submit.

A mental model: a controlled input is a puppet — it moves only when React pulls the strings. An uncontrolled input is a pet — it does its own thing and you check on it when you need to.

The uncontrolled example from my notes, made runnable:

Inputs.jsx
import { useRef, useState } from "react";

export function UncontrolledInput({ onSubmit }) {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    onSubmit(inputRef.current.value); // read the DOM when you need the value
  };

  return (
    <>
      <input type="text" ref={inputRef} defaultValue="Rohit" aria-label="uncontrolled" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}

// type " Varma", click Submit → onSubmit("Rohit Varma")
What’s happening
  1. defaultValue="Rohit" sets the starting text once, on mount. After that, React doesn't touch the input's value — typing changes only the DOM.
  2. ref={inputRef} gives the component a handle to the real <input> element; inputRef.current is that element after mount (see Refs to DOM elements).
  3. Typing Varma causes no re-renders at all — there's no state involved.
  4. On click, inputRef.current.value reads "Rohit Varma" straight from the DOM and passes it on. The component only learns the value at the moment it asks.

The same field, controlled:

Inputs.jsx
export function ControlledInput({ onSubmit }) {
  const [name, setName] = useState("Rohit");

  return (
    <>
      <input
        aria-label="controlled"
        value={name}
        onChange={(e) => setName(e.target.value.toUpperCase())}
      />
      <p>{name.length}/20</p>
      <button disabled={name.trim() === "" || name.length > 20} onClick={() => onSubmit(name)}>
        Submit
      </button>
    </>
  );
}

// type " varma" → the input shows "ROHIT VARMA", the counter "11/20"; clear it → Submit disabled
What’s happening
  1. value={name} means the input shows whatever name is, every render. React owns the value now.
  2. Each keystroke fires onChange. The handler calls setName(e.target.value.toUpperCase()), React re-renders, and the input shows the new name. Because the handler transforms the value, typing varma displays ROHIT VARMA — you can filter, format or reject input as it's typed.
  3. Because the value is in state, the rest of the UI can react to it live: the 11/20 counter and the disabled state of the button are computed from name on every render. Clearing the field disables Submit immediately.
  4. The cost is a re-render per keystroke. That's normally invisible; for very large forms it's one reason libraries like React Hook Form use uncontrolled inputs under the hood (see React Hook Form).

From my notes: controlled components are better for complex forms where you need validation or synchronisation with component state; uncontrolled components are useful for simpler scenarios with fewer state updates. I'd add: uncontrolled is also the natural fit for React 19 form actions, which read values from FormData (see Forms with React 19 actions), and <input type="file"> is always uncontrolled, because its value can't be set from code.

ControlledUncontrolled
Source of truthReact stateThe DOM
Propsvalue + onChangedefaultValue (and ref or name)
Read the valueIt's already in stateref.current.value or FormData on submit
Re-renders while typingEvery keystrokeNone
Good forLive validation, formatting, dependent fields, disabling buttonsSimple forms, file inputs, form actions, integrating non-React widgets

#Where state lives: a decision guide

Added

Most state bugs aren't about how to update state but about where it lives and whether it should exist at all. Two copies of the same information drift apart; state placed too high re-renders half the app; server data copied into component state goes stale. The single best habit is to keep the minimum state and compute everything else.

The mental model: state is the facts you can't work out from other facts. A person's birth date is a fact; their age is computed. Store the birth date; calculate the age every time you need it.

Derived.jsx
// ❌ fullName duplicates information that's already in state
export function NameFormDuplicated() {
  const [first, setFirst] = useState("Rohit");
  const [last, setLast] = useState("Varma");
  const [fullName, setFullName] = useState("Rohit Varma");

  return (
    <>
      <input aria-label="first" value={first} onChange={(e) => setFirst(e.target.value)} />
      <input
        aria-label="last"
        value={last}
        onChange={(e) => {
          setLast(e.target.value);
          setFullName(`${first} ${e.target.value}`); // remembered here…
        }}
      />
      <p>{fullName}</p>
    </>
  );
}

// ✅ derive it during render
export function NameForm() {
  const [first, setFirst] = useState("Rohit");
  const [last, setLast] = useState("Varma");
  const fullName = `${first} ${last}`;
  // …same inputs, <p>{fullName}</p>
}

// change "first" to "Asha":
// NameFormDuplicated → <p>Rohit Varma</p>   ❌ stale
// NameForm           → <p>Asha Varma</p>
What’s happening
  1. In the duplicated version, fullName is a third piece of state that's supposed to equal first + " " + last. Keeping it true is now the developer's job, in every handler.
  2. The last-name handler remembers to update it; the first-name handler forgets. Changing the first name to Asha updates first but not fullName, so the paragraph still says Rohit Varma — the test shows exactly that.
  3. In the derived version, fullName is a plain const computed from first and last on every render. There's nothing to forget; it can't be stale. The test shows Asha Varma.
  4. "But computing it every render is wasteful" — string concatenation, filtering a few hundred items, summing a cart: all trivially cheap. Only reach for useMemo when a calculation is measurably slow (see useMemo).

The same bug in another shape — copying a prop into state:

React
function ColourBadge({ colour }) {
  const [current] = useState(colour); // ❌ copies the prop once
  return <span>{current}</span>;
}
// <ColourBadge colour="red" /> then <ColourBadge colour="blue" /> → still shows "red"

useState(colour) uses its argument only on the first render, so later prop changes are ignored. Use the prop directly. If the component really needs editable local state that starts from a prop (a form initialised from a saved record), name it initialColour so the intent is clear, and give the component a key to reset it when the record changes (see Rendering lists and keys).

The decision guide, top to bottom — stop at the first "yes":

QuestionThen the state lives…
Can it be computed from props or other state?Nowhere — compute it during render (useMemo only if slow)
Does only this component use it?In this component: useState, or useReducer for complex transitions (see useReducer)
Do a few nearby components share it?In their closest common parent, passed down as props (Lifting state up)
Do many components at different depths need it (theme, user, locale)?In context near the top (The Context API)
Is it data from the server (lists, records, search results)?In a server-state cache like TanStack Query — not copied into useState (Server state vs client state)
Should it survive a refresh or be shareable as a link (filters, tabs, page number)?In the URL: route params or search params (Route params and useLocation)
Is it complex client state shared app-wide, with many writers?A store: Zustand or Redux Toolkit (Choosing a state management approach)
Is it the values of a big form?A form library (React Hook Form) or the DOM itself (uncontrolled + FormData)

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.