React NotesRohit’s interview study guide
Chapter 04

Class Components & Lifecycle

The "old" React: classes, this.state, setState, the lifecycle methods and how each one maps to hooks — still asked in interviews, still in older codebases, and still the only way to write an error boundary.

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

#Class components

Added

Before hooks arrived in React 16.8 (February 2019), a component that needed state or needed to do something after it appeared on screen had to be a class. A class component extends React.Component, receives its props on this.props, and has one required method, render(), that returns JSX. React creates one instance of the class per place it appears in the tree and keeps that instance alive for as long as the component is mounted — the instance is where state, timers and subscriptions live.

The mental model: a function component is a function React calls on every render; a class component is an object React creates once and then calls render() on each time it needs fresh JSX. Everything that has to survive between renders hangs off this. That one difference explains most of what follows — including the most common class-component bug, losing this.

Class components are not deprecated. React 19.3 still fully supports them; the React team just recommends function components for new code. You'll meet them in older codebases, in interview questions ("explain the lifecycle"), and in every error boundary, because there is still no hook for catching render errors.

Greeting.jsx
import { Component } from "react";

// Class component (the pre-2019 way)
class Greeting extends Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

// Function component (the modern way) — same output
function GreetingFn({ name }) {
  return <h1>Hello, {name}</h1>;
}

<Greeting name="Rohit" />;   // <h1>Hello, Rohit</h1>
<GreetingFn name="Rohit" />; // <h1>Hello, Rohit</h1>
What’s happening
  1. class Greeting extends Component makes Greeting a subclass of React's base class. The base class is what gives the instance this.props, this.setState and this.forceUpdate, and it's how React recognises a class component (it checks Component.prototype.isReactComponent).
  2. When <Greeting name="Rohit" /> mounts, React does roughly new Greeting(props), stores the instance, sets instance.props = { name: "Rohit" }, and calls instance.render().
  3. render() reads this.props.name and returns <h1>Hello, Rohit</h1>. It must be pure — same props and state, same JSX, no side effects — exactly like a function component's body.
  4. On a later re-render React doesn't create a new instance: it updates this.props on the same object and calls render() again. A function component, by contrast, is simply called again with new arguments.
  5. Both versions produce identical DOM (the test renders both and finds two <h1>Hello, Rohit</h1>). The difference is where the state and side effects would live: on this for the class, in hooks for the function.

Handlers and this

The classic class bug: you pass a method as an event handler, React later calls it as a plain function, and this is undefined.

Counter.jsx
class Broken extends Component {
  state = { count: 0 };

  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    // ❌ passes the bare function; React calls it without a receiver
    return <button onClick={this.handleClick}>Clicked {this.state.count}</button>;
  }
}
// Click → TypeError: Cannot read properties of undefined (reading 'setState')

// Fix 1 (the old idiom): bind once in the constructor
class BoundInConstructor extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }
  render() {
    return <button onClick={this.handleClick}>Clicked {this.state.count}</button>;
  }
}

// Fix 2 (what most class code used later): an arrow function in a class field
class ClassField extends Component {
  state = { count: 0 };
  handleClick = () => {
    this.setState({ count: this.state.count + 1 });
  };
  render() {
    return <button onClick={this.handleClick}>Clicked {this.state.count}</button>;
  }
}
What’s happening
  1. onClick={this.handleClick} reads the method off the instance and hands React the function alone. The link to the instance is lost at that moment — this is plain JavaScript method extraction, nothing React-specific.
  2. When you click, React calls the handler like handler(event). Class bodies are always in strict mode, so a function called without a receiver gets this === undefined.
  3. this.setState then throws. The test caught the real message: TypeError: Cannot read properties of undefined (reading 'setState'), and the button stayed at Clicked 0.
  4. Fix 1: this.handleClick.bind(this) in the constructor creates a new function with this permanently set to the instance, and stores it as an own property that shadows the prototype method. super(props) must come first — you can't touch this in a subclass constructor before calling super.
  5. Fix 2: handleClick = () => { … } is a class field, so it's created per instance in the constructor, and an arrow function takes this from where it was defined — the instance. Both fixed buttons went to Clicked 1 after one click.
  6. Function components never have this problem because there is no this: a handler is a closure over the render's variables.
AdvancedWhat React 19.3 does with the removed class APIs

A quick check of what actually happens in 19.3 (tested in the sandbox):

Removed.jsx
class StringRef extends Component {
  render() {
    return <input ref="name" />; // string ref — removed in React 19
  }
}
// render(<StringRef />) throws:
// "Expected ref to be a function, an object returned by React.createRef(), or undefined/null."

function FnDefaults({ size }) {
  return <p>size={String(size)}</p>;
}
FnDefaults.defaultProps = { size: "md" }; // ignored for function components in React 19

class ClassDefaults extends Component {
  static defaultProps = { size: "md" }; // still works for classes
  render() {
    return <p>size={this.props.size}</p>;
  }
}
// <FnDefaults />    → size=undefined
// <ClassDefaults /> → size=md
What’s happening
  1. String refs were a React 16-and-earlier way to name a DOM node (this.refs.name). React 19 doesn't recognise a string as a ref any more, so rendering throws immediately with the message quoted above. The fix is createRef() in a class or useRef() in a function (Chapter 6).
  2. defaultProps on a function component is silently ignored in React 19 — no warning, the prop is just undefined. Use a default parameter ({ size = "md" }) instead.
  3. static defaultProps on a class still works, because classes have no destructuring-default equivalent. That's why you'll still see it in class code.

#this.state and setState

Added

A class keeps all its state in one object, this.state, and changes it only through this.setState(...). Two things surprise people coming from hooks: setState merges the object you pass into the existing state (shallowly), and it doesn't change this.state straight away — it queues a request, React batches the requests, and the new state appears on the next render.

Think of setState as filling in a change request form: you hand it in, React collects all the forms from the same event, and processes them together in one re-render. Reading this.state immediately after handing in the form shows you the old value, because nothing has been processed yet. That's the same model as the useState setter — the main differences are the object merge and the optional callback.

Counter.jsx
class Counter extends Component {
  state = { count: 0, label: "Clicks" };

  addThreeWrong = () => {
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    console.log(`right after: ${this.state.count}`);
  };

  addThreeRight = () => {
    this.setState((prev) => ({ count: prev.count + 1 }));
    this.setState((prev) => ({ count: prev.count + 1 }));
    this.setState(
      (prev) => ({ count: prev.count + 1 }),
      () => console.log(`callback: ${this.state.count}`) // runs after the update is committed
    );
  };

  render() {
    console.log(`render: ${this.state.label} ${this.state.count}`);
    return (
      <>
        <p>{this.state.label}: {this.state.count}</p>
        <button onClick={this.addThreeWrong}>+3 (wrong)</button>
        <button onClick={this.addThreeRight}>+3 (right)</button>
      </>
    );
  }
}
// render: Clicks 0
// click "+3 (wrong)" → right after: 0, render: Clicks 1
// click "+3 (right)" → render: Clicks 4, callback: 4
What’s happening
  1. The class field state = { count: 0, label: "Clicks" } is the same as setting this.state in the constructor. First render logs render: Clicks 0.
  2. "+3 (wrong)": each this.setState({ count: this.state.count + 1 }) reads this.state.count now, and it's still 0 all three times, because none of the requests has been processed. All three queue { count: 1 }.
  3. right after: 0 proves this.state hasn't changed yet. React then processes the batch — { count: 1 } three times is still 1 — and renders once: render: Clicks 1.
  4. label is still "Clicks" even though no call mentioned it: setState shallow-merges { count: 1 } into the state object. (The useState setter replaces the value instead — with an object in useState you must spread the old one yourself.)
  5. "+3 (right)": the updater form (prev) => … receives the state as it will be after the previous queued updates, so the chain goes 1 → 2 → 3 → 4, again in one render: render: Clicks 4.
  6. The second argument to setState is a callback React runs after the update is committed, so it sees this.state.count === 4. In function components the equivalent is an effect that depends on that state.
What does this log?
React
class Toggle extends Component {
  state = { on: false, clicks: 0 };
  handleClick = () => {
    this.setState({ on: !this.state.on });
    this.setState({ clicks: this.state.clicks + 1 });
    console.log(this.state.on, this.state.clicks);
  };
  render() {
    return <button onClick={this.handleClick}>{String(this.state.on)} {this.state.clicks}</button>;
  }
}
// The button is clicked once.
Show answer

It logs false 0, then the button shows true 1.

What’s happening
  1. The two setState calls are queued, not applied, so console.log still sees the state from this render: on is false, clicks is 0.
  2. After the handler returns, React processes both requests in one batch. Each is merged into the state: { on: true } then { clicks: 1 } gives { on: true, clicks: 1 }.
  3. One re-render shows true 1. Because the two updates touch different keys, the object form is fine here; it only goes wrong when several updates read the same key (use the updater form then).

#Lifecycle methods

Added

A class component goes through three phases: mounting (created and inserted into the DOM), updating (re-rendered because props or state changed), and unmounting (removed). React calls specific methods on the instance at each point, and you override the ones you need. There's a fourth, special group for errors thrown by children.

The mental model is a timeline with hooks you can hang code on: "right after you appear" (componentDidMount), "right after you change" (componentDidUpdate), "right before you go" (componentWillUnmount). React splits each phase into a render phase (pure, may be paused or repeated: constructor, getDerivedStateFromProps, shouldComponentUpdate, render) and a commit phase (DOM is updated, side effects allowed: getSnapshotBeforeUpdate, componentDidMount, componentDidUpdate, componentWillUnmount).

PhaseMethods, in orderSide effects allowed?
Mountingconstructor → static getDerivedStateFromProps → render → componentDidMountOnly in componentDidMount
Updatingstatic getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → componentDidUpdateOnly in componentDidUpdate (getSnapshotBeforeUpdate may read the DOM)
UnmountingcomponentWillUnmountYes — clean up
Errors (in children)static getDerivedStateFromError → componentDidCatchOnly in componentDidCatch

Here is a component that logs every one of them, run through a mount, a prop change and an unmount:

Lifecycle.jsx
class Lifecycle extends Component {
  constructor(props) {
    super(props);
    this.state = { doubled: 0 };
    log("constructor");
  }
  static getDerivedStateFromProps(props, state) {
    log("getDerivedStateFromProps");
    return { doubled: props.value * 2 };
  }
  componentDidMount() {
    log("componentDidMount");
  }
  shouldComponentUpdate(nextProps, nextState) {
    log("shouldComponentUpdate");
    return true;
  }
  getSnapshotBeforeUpdate(prevProps, prevState) {
    log("getSnapshotBeforeUpdate");
    return null;
  }
  componentDidUpdate(prevProps, prevState, snapshot) {
    log(`componentDidUpdate (value ${prevProps.value} → ${this.props.value})`);
  }
  componentWillUnmount() {
    log("componentWillUnmount");
  }
  render() {
    log("render");
    return <p>{this.state.doubled}</p>;
  }
}

// render(<Lifecycle value={1} />)
//   constructor, getDerivedStateFromProps, render, componentDidMount
// rerender(<Lifecycle value={2} />)        → shows 4
//   getDerivedStateFromProps, shouldComponentUpdate, render,
//   getSnapshotBeforeUpdate, componentDidUpdate (value 1 → 2)
// unmount()
//   componentWillUnmount
What’s happening
  1. Mount. constructor runs once per instance: call super(props), set the initial this.state, bind handlers. No side effects here — no fetches, no subscriptions.
  2. getDerivedStateFromProps runs next. It's static (no this), receives props and state, and returns an object to merge into state (here { doubled: 2 }) or null. Then render produces JSX, React updates the DOM, and componentDidMount runs — the DOM node exists now, so this is where you fetch, subscribe, start timers or measure.
  3. Update. When the parent re-renders with value={2}, there's no constructor (same instance). getDerivedStateFromProps runs again (making doubled 4), then shouldComponentUpdate gets the chance to say "skip this render" by returning false.
  4. render runs, then getSnapshotBeforeUpdate runs after render but before the DOM changes are applied — the last moment you can read the old DOM (scroll positions). Whatever it returns is passed as the third argument to componentDidUpdate.
  5. componentDidUpdate(prevProps, prevState, snapshot) runs after the DOM is updated. Comparing prevProps.value (1) to this.props.value (2) is how you react to specific changes — e.g. refetch when an id changes.
  6. Unmount. componentWillUnmount runs just before the component is removed: clear timers, cancel requests, unsubscribe. After this the instance is thrown away.

The error methods only fire when a child throws during rendering. A class that implements them is an error boundary (covered fully in Chapter 19, Error Handling):

ErrorBoundary.jsx
class ErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true }; // render phase: switch to the fallback
  }

  componentDidCatch(error, info) {
    logToService(error, info.componentStack); // commit phase: side effects OK
  }

  render() {
    if (this.state.hasError) return <p role="alert">Something went wrong.</p>;
    return this.props.children;
  }
}

function Buggy() {
  throw new Error("Boom");
}

<ErrorBoundary><Buggy /></ErrorBoundary>; // renders "Something went wrong."
What’s happening
  1. Buggy throws while rendering. React walks up the tree to the nearest component with getDerivedStateFromError — the boundary.
  2. getDerivedStateFromError(error) is static and pure: it returns a state update ({ hasError: true }) so the next render shows the fallback. In the test React called it twice — React retries a failed render once before committing the fallback, so this method must not have side effects.
  3. componentDidCatch(error, info) runs once, in the commit phase, with info.componentStack (the test's first line was at Buggy (…)). This is where you report to Sentry or similar.
  4. React 19.3 also logs the error once with console.error: "The above error occurred in the <Buggy> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
  5. There is no hook equivalent for either method, which is the main reason class components still matter. The react-error-boundary package wraps a class like this one so you never write it yourself.
AdvancedStrict Mode and class lifecycles

In development, <StrictMode> double-invokes the render phase of classes (constructor, getDerivedStateFromProps, render) and simulates an unmount and remount after the first mount. The same Lifecycle component inside <StrictMode> logs:

JavaScript
// render(<StrictMode><Lifecycle value={1} /></StrictMode>)
// constructor
// constructor
// getDerivedStateFromProps
// getDerivedStateFromProps
// render
// render
// componentDidMount
// componentWillUnmount
// componentDidMount
What’s happening
  1. Each render-phase method runs twice so that an impure one (a constructor that subscribes to something, a render that mutates a variable) shows up as a visible bug in development.
  2. After mounting, React calls componentWillUnmount and then componentDidMount again on the same instance. If your unmount doesn't undo exactly what your mount did (a listener you forget to remove), you'll now have two.
  3. This is the class-component version of "Strict Mode runs effects twice" (Chapter 5), and it only happens in development builds.
AdvancedThe UNSAFE_ lifecycles

React 16.3 renamed three lifecycles because they run in the render phase, which async rendering can repeat or abandon: componentWillMount, componentWillReceiveProps and componentWillUpdate. The old names still run in React 19.3 but warn:

JavaScript
// class Legacy extends Component { componentWillMount() {} … }
// console.warn:
// componentWillMount has been renamed, and is not recommended for use.
// See https://react.dev/link/unsafe-component-lifecycles for details.
// * Move code with side effects to componentDidMount, and set initial state in the constructor.
// * Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in
//   non-strict mode. In React 18.x, only the UNSAFE_ name will work. …
What’s happening
  1. The warning text still says "In React 18.x, only the UNSAFE_ name will work" — that never happened; in 19.3 componentWillMount still runs (the sandbox test confirmed it). Don't rely on it anyway.
  2. Renaming to UNSAFE_componentWillMount silences the warning outside Strict Mode. Inside <StrictMode> you get a console.error instead: "Using UNSAFE_componentWillMount in strict mode is not recommended and may indicate bugs in your code."
  3. Replacements: componentWillMount → constructor (state) or componentDidMount (effects); componentWillReceiveProps → getDerivedStateFromProps or, better, no derived state at all; componentWillUpdate → getSnapshotBeforeUpdate + componentDidUpdate. The codemod is npx react-codemod rename-unsafe-lifecycles.

#Mapping lifecycle methods to hooks

Added

The tempting mental model is "useEffect with [] is componentDidMount". It's close enough for a one-liner in an interview, but the better model is different: lifecycles ask "when?"; effects ask "what am I keeping in sync with what?". A class spreads one concern across three methods (start it in mount, restart it in update, stop it in unmount). An effect describes the concern once — "connect to roomId, and here's how to disconnect" — and React works out when to run the setup and the cleanup.

Class componentFunction component
constructor (initial state)useState(initial) or useState(() => expensiveInit())
constructor (instance fields, bound handlers)useRef for fields; plain functions for handlers (no this)
render()The function body
componentDidMountuseEffect(() => { … }, [])
componentDidUpdateuseEffect(() => { … }, [deps]) — runs after every render where deps changed
componentWillUnmountThe cleanup function returned from useEffect
componentDidMount + measure before paintuseLayoutEffect
shouldComponentUpdate / PureComponentReact.memo (comparator returns true for "equal", the opposite of shouldComponentUpdate)
static getDerivedStateFromPropsCompute during render, a key to reset, or set state during render
getSnapshotBeforeUpdateNo hook equivalent
getDerivedStateFromError / componentDidCatchNo hook equivalent — keep a class or use react-error-boundary
this.setState(update, callback)useState setter; run "after" logic in an effect or the event handler
this.forceUpdate()const [, forceUpdate] = useReducer((x) => x + 1, 0) (rarely a good idea)
this.context / static contextTypeuseContext(MyContext)

Here's the same chat-room connection both ways, plus the bug classes made easy:

ChatRoom.jsx
// Class: one concern, three methods
class ChatRoomClass extends Component {
  componentDidMount() {
    this.connection = createConnection(this.props.roomId);
    this.connection.connect();
  }
  componentDidUpdate(prevProps) {
    if (prevProps.roomId !== this.props.roomId) {
      this.connection.disconnect();
      this.connection = createConnection(this.props.roomId);
      this.connection.connect();
    }
  }
  componentWillUnmount() {
    this.connection.disconnect();
  }
  render() {
    return <h1>Welcome to {this.props.roomId}</h1>;
  }
}

// Hooks: one concern, one effect
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
  return <h1>Welcome to {roomId}</h1>;
}

// Both, for roomId "general" → "travel" → "travel" → unmount:
// connect general, disconnect general, connect travel, disconnect travel
What’s happening
  1. Mount with roomId="general": the class's componentDidMount connects and stores the connection on this so the other methods can find it. The effect does the same, but keeps connection in a closure — no instance field needed.
  2. roomId changes to "travel": the class's componentDidUpdate must compare prevProps.roomId to this.props.roomId itself, then disconnect, reconnect and replace this.connection. The effect version gets this for free: roomId is in the dependency array, so React runs the previous cleanup (disconnect general) and then the setup again (connect travel).
  3. A re-render with the same "travel": the class's if is false; React skips the effect because Object.is("travel", "travel"). Neither reconnects.
  4. Unmount: componentWillUnmount / the cleanup disconnects travel. Both logs are identical: connect general, disconnect general, connect travel, disconnect travel.
  5. The effect is shorter, and it's harder to get wrong: the setup and the matching cleanup sit next to each other, and the linter checks that roomId is in the dependency array.

The bug the class shape invites — forgetting componentDidUpdate:

ChatRoomForgetful.jsx
class ChatRoomForgetful extends Component {
  componentDidMount() {
    this.connection = createConnection(this.props.roomId);
    this.connection.connect();
  }
  componentWillUnmount() {
    this.connection.disconnect();
  }
  render() {
    return <h1>Welcome to {this.props.roomId}</h1>;
  }
}
// "general" → "travel" → unmount:
// connect general, disconnect general
// …while the heading says "Welcome to travel"
What’s happening
  1. Mount connects to general, exactly as before.
  2. When roomId becomes "travel", nothing listens for the change: there is no componentDidUpdate. render happily shows "Welcome to travel" — the test confirmed the heading — while the connection is still to general.
  3. On unmount it disconnects general. The log never contains connect travel. The UI and the side effect have drifted apart.
  4. The hooks version can't drift like this: an effect re-runs whenever its dependencies change, unless you deliberately leave one out (and the react-hooks/exhaustive-deps lint rule complains if you do). That's the practical meaning of "effects synchronise, lifecycles schedule".
Translate this class
React
class Title extends Component {
  componentDidMount() {
    document.title = `${this.props.count} new messages`;
  }
  componentDidUpdate(prevProps) {
    if (prevProps.count !== this.props.count) {
      document.title = `${this.props.count} new messages`;
    }
  }
  render() {
    return null;
  }
}
Show answer
React
function Title({ count }) {
  useEffect(() => {
    document.title = `${count} new messages`;
  }, [count]);
  return null;
}
What’s happening
  1. The mount and update branches do the same thing, so they collapse into one effect body.
  2. The prevProps.count !== this.props.count check becomes the dependency array [count]: React compares the new count to the previous one with Object.is and skips the effect when they match.
  3. There's nothing to clean up (setting a title doesn't start anything), so the effect returns nothing — the class had no componentWillUnmount either.

#PureComponent and shouldComponentUpdate

Added

By default, when a parent re-renders, every child re-renders too, whether or not its props changed. Usually that's fine. When it isn't, class components have two opt-outs: implement shouldComponentUpdate(nextProps, nextState) and return false to skip a render, or extend PureComponent, which implements shouldComponentUpdate for you with a shallow comparison of every prop and every state key.

A shallow comparison is a bouncer checking ID cards, not searching bags: for each prop it asks "is this the same value (for primitives) or the same object reference (for objects)?" with Object.is. It never looks inside objects. That makes it fast — and makes it blind to mutation. The hooks-era equivalent is React.memo, which does the same shallow comparison on props.

RenderCounts.jsx
class Plain extends Component {
  render() { log("Plain"); return <li>{this.props.label}</li>; }
}
class Custom extends Component {
  shouldComponentUpdate(nextProps) {
    return nextProps.label !== this.props.label; // false → skip render
  }
  render() { log("Custom"); return <li>{this.props.label}</li>; }
}
class Pure extends PureComponent {
  render() { log("Pure"); return <li>{this.props.label}</li>; }
}
const Memo = memo(function Memo({ label }) {
  log("Memo");
  return <li>{label}</li>;
});

class Parent extends Component {
  state = { ticks: 0 };
  render() {
    return (
      <>
        <button onClick={() => this.setState({ ticks: this.state.ticks + 1 })}>
          Tick {this.state.ticks}
        </button>
        <ul>
          <Plain label="a" />
          <Custom label="a" />
          <Pure label="a" />
          <Memo label="a" />
        </ul>
      </>
    );
  }
}
// mount:      Plain, Custom, Pure, Memo
// click Tick: Plain
What’s happening
  1. On mount, all four children render once — none of the opt-outs applies to the first render.
  2. Clicking Tick changes the parent's state, so Parent.render() runs and creates fresh elements for all four children with label="a".
  3. Plain has no opt-out, so it re-renders even though nothing it shows changed. That's the default behaviour.
  4. Custom.shouldComponentUpdate sees "a" !== "a" is false and returns false, so React skips its render. Pure does the same comparison automatically for every prop and state key. Memo is the function-component version and also skips.
  5. Only Plain appears in the second log line. Note what shouldComponentUpdate returns: true means "do render". memo's optional comparator is the opposite: it returns true when props are equal (meaning "skip").

The price of shallow comparison is that mutating an object or array is invisible:

TodoApp.jsx
class TodoList extends PureComponent {
  render() {
    return <p>{this.props.items.length} items</p>;
  }
}

class TodoApp extends Component {
  state = { items: ["milk"] };

  addMutating = () => {
    this.state.items.push("eggs"); // ❌ same array, changed in place
    this.setState({ items: this.state.items });
  };

  addImmutable = () => {
    this.setState((s) => ({ items: [...s.items, "eggs"] })); // ✅ new array
  };

  render() {
    return (
      <>
        <TodoList items={this.state.items} />
        <button onClick={this.addMutating}>Add (mutate)</button>
        <button onClick={this.addImmutable}>Add (copy)</button>
      </>
    );
  }
}
// click "Add (mutate)" → still shows "1 items"
// click "Add (copy)"   → shows "3 items"
What’s happening
  1. addMutating pushes into the existing array, so the array now holds ["milk", "eggs"] — but it's the same array object.
  2. setState re-renders TodoApp (a plain Component always re-renders on setState), which passes items to TodoList again.
  3. TodoList is a PureComponent: Object.is(prevProps.items, nextProps.items) is true (same reference), so it skips rendering and keeps showing 1 items. The data changed; the screen didn't.
  4. addImmutable creates a new array from the current one: ["milk", "eggs", "eggs"]. The reference differs, so TodoList renders and shows 3 items — three, because the mutated "eggs" was really in there all along.
  5. This is the same rule as useState and React.memo: React decides "changed?" by reference, so update with copies (..., map, filter, or Immer).
AdvancedA custom comparator for memo

memo(Component, arePropsEqual) lets you replace the shallow comparison — the function-component counterpart of a hand-written shouldComponentUpdate:

Row.jsx
const Row = memo(
  function Row({ user }) {
    return <p>{user.name}</p>;
  },
  (prev, next) => prev.user.id === next.user.id // true = "equal, skip the render"
);

// render(<Row user={{ id: 1, name: "Ann" }} />)   → renders, shows "Ann"
// rerender(<Row user={{ id: 1, name: "Annie" }} />) → skipped, still shows "Ann"
What’s happening
  1. The second user is a new object, so the default shallow comparison would re-render.
  2. The custom comparator says "same id means equal" and returns true, so React skips the render.
  3. The screen still says Ann although the name changed to Annie — the test logged exactly one render. A comparator that ignores a prop the component displays creates stale UI; compare everything you render, or don't write a comparator.

#getDerivedStateFromProps and getSnapshotBeforeUpdate

Added

These two lifecycles were added in React 16.3 to replace the unsafe "will" methods, and they're the two that interviewers use to check whether you know the lifecycle beyond the basics.

static getDerivedStateFromProps(props, state) runs before every render — mount, prop changes, and (since 16.4) your own setState calls too. It returns an object to merge into state, or null. It's for the rare case where state must follow a prop — classically, "reset this draft when the user id changes". It's static on purpose: no this, so no side effects and no access to the previous props unless you copy them into state yourself.

getSnapshotBeforeUpdate(prevProps, prevState) runs after render but before React writes the changes to the DOM. Whatever it returns is passed as the third argument to componentDidUpdate. It's a photo of the DOM taken just before it changes — used to keep a chat window's scroll position steady when older messages are inserted above.

EmailInput.jsx
class EmailInput extends Component {
  state = { email: this.props.defaultEmail, prevUserId: this.props.userId };

  static getDerivedStateFromProps(props, state) {
    // Different user? Throw away the draft and start from their saved email.
    if (props.userId !== state.prevUserId) {
      return { email: props.defaultEmail, prevUserId: props.userId };
    }
    return null; // no change to state
  }

  render() {
    return (
      <input
        aria-label="email"
        value={this.state.email}
        onChange={(e) => this.setState({ email: e.target.value })}
      />
    );
  }
}
// userId 1, type "draft@x.com"        → input shows draft@x.com
// re-render with the same userId 1    → still draft@x.com
// re-render with userId 2 (bob@x.com) → bob@x.com
What’s happening
  1. The initial state copies the prop (email: "ann@x.com") and also remembers prevUserId: 1. Storing the previous prop in state is the standard trick, because a static method can't see this.props.
  2. The user types; each keystroke calls setState, and getDerivedStateFromProps runs before each of those renders too. props.userId (1) equals state.prevUserId (1), so it returns null and the draft survives.
  3. The parent re-renders with the same userId — still null, still draft@x.com. Comparing only the id (not the email) is what keeps an unrelated parent re-render from wiping the user's typing.
  4. The parent switches to userId={2}: the ids differ, so the method returns { email: "bob@x.com", prevUserId: 2 }, which is merged into state before render. The input shows bob@x.com.
  5. If it compared props.defaultEmail !== state.email instead, every keystroke would be reverted — the classic derived-state bug, and why the React docs call this method a last resort.

In function components there's no equivalent method — and usually you don't need one. The cleanest fix is a key: when the key changes, React throws away the old component and mounts a fresh one with fresh state.

EmailInputFn.jsx
function EmailInput({ defaultEmail }) {
  const [email, setEmail] = useState(defaultEmail);
  return <input aria-label="email" value={email} onChange={(e) => setEmail(e.target.value)} />;
}

// The parent resets it by changing the key:
<EmailInput key={user.id} defaultEmail={user.email} />;
// key 1, type "!!"  → ann@x.com!!
// key 2 (bob@x.com) → bob@x.com
What’s happening
  1. useState(defaultEmail) uses the prop only as the initial value — later changes to the prop are ignored, which is what you want while the user is typing.
  2. The parent passes key={user.id}. React uses keys to decide "same component or a different one?", for single children as well as lists.
  3. Typing gives ann@x.com!!. When the key changes from 1 to 2, React unmounts the old EmailInput (its state is discarded) and mounts a new one, whose useState starts from bob@x.com.
  4. No comparison code, no "previous id" bookkeeping. The same key trick works for class components too, and it's what the React docs recommend over getDerivedStateFromProps.
AdvancedKeeping a scroll position with getSnapshotBeforeUpdate
MessageList.jsx
class MessageList extends Component {
  listRef = createRef();

  getSnapshotBeforeUpdate(prevProps) {
    // The DOM still shows the OLD messages here.
    if (prevProps.messages.length < this.props.messages.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop; // distance from the bottom
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // The DOM now shows the NEW messages.
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return (
      <ul ref={this.listRef}>
        {this.props.messages.map((m) => <li key={m}>{m}</li>)}
      </ul>
    );
  }
}
// 2 messages (20px each), scrollTop 10 → prepend 2 older messages
// snapshot: DOM has 2 messages → returns 40 - 10 = 30
// didUpdate: DOM has 4 messages, snapshot = 30 → scrollTop = 80 - 30 = 50
What’s happening
  1. The list starts with 2 messages. jsdom has no layout, so the test gave each <li> a fake height of 20px: scrollHeight is 40. The user has scrolled to scrollTop = 10.
  2. Two older messages are prepended. render returns the new list, but before React touches the DOM it calls getSnapshotBeforeUpdate. The test logged "DOM has 2 messages" at this point — it really sees the old DOM. It returns the distance from the bottom: 40 - 10 = 30.
  3. React applies the DOM changes; the list now has 4 items and scrollHeight 80. Without any correction, scrollTop would still be 10, and the content the user was reading would jump down by 40px.
  4. componentDidUpdate receives snapshot = 30 and sets scrollTop = 80 - 30 = 50, so the same messages stay in view. The test confirmed scrollTop: 50.
  5. There is no hook equivalent: useLayoutEffect runs after the DOM has changed, so it's too late to read the old scrollHeight. You'd have to record measurements before every update yourself (for example in a scroll handler). The React docs say plainly that for this case you still write a class.
AdvancedThe hooks version of getDerivedStateFromProps

When a key isn't an option (you want to keep some state), store the previous prop in state and adjust during render:

EmailInputPrev.jsx
function EmailInput({ userId, defaultEmail }) {
  const [email, setEmail] = useState(defaultEmail);
  const [prevUserId, setPrevUserId] = useState(userId);

  if (userId !== prevUserId) {
    setPrevUserId(userId);
    setEmail(defaultEmail);
  }

  return <input aria-label="email" value={email} onChange={(e) => setEmail(e.target.value)} />;
}
// same userId → ann@x.com!! (draft kept); userId 2 → bob@x.com
What’s happening
  1. This is a line-for-line translation of the class: prevUserId lives in state, and the comparison runs before the JSX is produced.
  2. Calling a setter during render is allowed only like this — for the component's own state, guarded by a condition that becomes false. React throws away the JSX from this pass and immediately re-renders with the new state, before touching the DOM or rendering children.
  3. Without the guard it would loop forever (React stops it with "Too many re-renders"). With it, the second pass sees userId === prevUserId and continues normally.
  4. It's better than doing the same reset in an effect, which would first paint the stale email and then render again. But a key is still simpler when you can use one.

#Why the industry moved to hooks

Added

Hooks weren't added because classes were broken; they were added because three problems kept recurring in large class codebases, and classes had no good answer to them.

1. Related logic was split up, and unrelated logic was mixed together. A subscription was started in componentDidMount, restarted in componentDidUpdate and stopped in componentWillUnmount — one concern in three places. Meanwhile each of those methods also held bits of every other concern. 2. Reusing stateful logic needed wrappers. The only tools were higher-order components and render props, which nest ("wrapper hell" in DevTools) and collide on prop names. 3. this and classes themselves — binding bugs, this.props changing under an async callback, and classes being harder for tools to minify and optimise.

Hooks fix all three with one idea: stateful logic is just a function you call (a custom hook), so it can be grouped by concern, reused by calling it in another component, and has no this. A good mental model: classes organise code by time (what happens at mount, at update, at unmount); hooks organise code by purpose (the title logic, the window-size logic).

Profile.jsx
// Class: two concerns, interleaved across three lifecycle methods
class ProfileClass extends Component {
  state = { width: window.innerWidth };
  handleResize = () => this.setState({ width: window.innerWidth });

  componentDidMount() {
    document.title = `${this.props.name}'s profile`;      // concern A
    window.addEventListener("resize", this.handleResize); // concern B
  }
  componentDidUpdate(prevProps) {
    if (prevProps.name !== this.props.name) {
      document.title = `${this.props.name}'s profile`;    // concern A again
    }
  }
  componentWillUnmount() {
    window.removeEventListener("resize", this.handleResize); // concern B again
  }
  render() {
    return <p>{this.props.name} at {this.state.width}px</p>;
  }
}

// Hooks: each concern in one place, and reusable
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width;
}

function Profile({ name }) {
  useDocumentTitle(`${name}'s profile`);
  const width = useWindowWidth();
  return <p>{name} at {width}px</p>;
}
// Both: title "Ann's profile"; resize to 500 → "Ann at 500px"; name Bob → "Bob's profile", "Bob at 500px"
What’s happening
  1. In the class, the title logic (A) lives in componentDidMount and componentDidUpdate, and the resize logic (B) lives in componentDidMount and componentWillUnmount. To understand either concern you read three methods; to delete one you edit three methods.
  2. In the hooks version, useDocumentTitle holds all of A and useWindowWidth holds all of B, including its cleanup. Each is a plain function whose name starts with use.
  3. Profile just calls them. Another component that needs the window width calls useWindowWidth() too — and gets its own state and listener, because hooks state belongs to the component that calls them.
  4. The test ran both versions through the same script — mount as Ann, fire a resize at 500px, re-render as Bob — and both ended with the title Bob's profile and the text Bob at 500px. Same behaviour, different organisation.
  5. Sharing B between classes would have needed a wrapper, which is the next example.
AdvancedThe pre-hooks way to share logic: a higher-order component
withWindowWidth.jsx
function withWindowWidth(Wrapped) {
  return class WithWindowWidth extends Component {
    state = { width: window.innerWidth };
    onResize = () => this.setState({ width: window.innerWidth });
    componentDidMount() { window.addEventListener("resize", this.onResize); }
    componentWillUnmount() { window.removeEventListener("resize", this.onResize); }
    render() {
      return <Wrapped {...this.props} width={this.state.width} />;
    }
  };
}

const Banner = withWindowWidth(function Banner({ width }) {
  return <p>{width < 600 ? "mobile" : "desktop"}</p>;
});
// <Banner /> → "desktop"; resize to 400 → "mobile"
What’s happening
  1. withWindowWidth takes a component and returns a new class that owns the state and the listener, then renders the original with an extra width prop.
  2. It works (the test switched from desktop to mobile), but every enhancement adds a layer: withRouter(withTheme(withWindowWidth(Banner))) shows up as four nested components in DevTools.
  3. If two HOCs both inject a prop called width, the inner one silently wins. With hooks you name the variables yourself: const width = useWindowWidth().
  4. HOCs and render props still exist (Chapter 18 covers them), but for sharing stateful logic, custom hooks replaced them almost completely.

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.