React's reconciliation runs in two conceptual phases: the render phase (building a new Virtual DOM tree by calling render(), pure and interruptible) and the commit phase (applying the computed diff to the real DOM, synchronous and side-effect-safe). Lifecycle methods are pinned to one of these phases, and calling this.setState synchronously inside a render-phase method that isn't designed for it is what causes infinite render loops — React re-renders, hits the same method, updates state again, forever.

PhaseMethodssetState safe?DOM available?
Render phaseconstructor, getDerivedStateFromProps, shouldComponentUpdate, render, getSnapshotBeforeUpdateOnly getDerivedStateFromProps returns state directly — never call setState imperatively hereNo
Commit phasecomponentDidMount, componentDidUpdate, componentWillUnmountYes — safe to call setState (React batches and re-renders after commit)Yes

Mounting runs exactly once per component instance, in this order: constructor → static getDerivedStateFromProps → render → (React commits to the DOM) → componentDidMount.

constructor(props)

Purpose: initialize this.state and bind event handler methods. Trigger: exactly once, before the component is ever rendered. super(props) is non-negotiable — omit it and this is not initialized at all, so referencing this.state anywhere in the constructor throws a ReferenceError before super() runs, per the ES2015 class specification (a derived class's this doesn't exist until super() is called).

src/components/UserProfileCard.jsxjavascript
import { Component } from 'react'

class UserProfileCard extends Component {
  constructor(props) {
    super(props) // must run first — initializes `this` via React.Component

    this.state = {
      isEditing: false,
      draftName: props.initialName ?? ''
    }

    this.toggleEdit = this.toggleEdit.bind(this)
    this.handleNameChange = this.handleNameChange.bind(this)
  }

  toggleEdit() {
    this.setState((prev) => ({ isEditing: !prev.isEditing }))
  }

  handleNameChange(event) {
    this.setState({ draftName: event.target.value })
  }

  render() {
    const { isEditing, draftName } = this.state
    return (
      <div className="profile-card">
        {isEditing ? (
          <input value={draftName} onChange={this.handleNameChange} />
        ) : (
          <span>{draftName}</span>
        )}
        <button type="button" onClick={this.toggleEdit}>
          {isEditing ? 'Save' : 'Edit'}
        </button>
      </div>
    )
  }
}

export default UserProfileCard

static getDerivedStateFromProps(props, state)

Purpose: derive state entirely from incoming props, before every render — both on mount and on every subsequent update. It is static, so it has no access to this and cannot cause side effects like fetching data or reading refs. Return an object to update state, or null to indicate no change is needed.

src/components/RecordEditor.jsxjavascript
import { Component } from 'react'

class RecordEditor extends Component {
  state = {
    draftValue: this.props.record.value,
    lastRecordId: this.props.record.id
  }

  // Runs before every render. Compares the incoming record's id against the
  // last id we derived state from — if the parent swapped in a different
  // record (same component instance, different data), reset the draft.
  // If the id is unchanged, return null so in-progress edits are preserved.
  static getDerivedStateFromProps(props, state) {
    if (props.record.id !== state.lastRecordId) {
      return {
        draftValue: props.record.value,
        lastRecordId: props.record.id
      }
    }
    return null
  }

  handleChange = (event) => {
    this.setState({ draftValue: event.target.value })
  }

  render() {
    return (
      <input value={this.state.draftValue} onChange={this.handleChange} />
    )
  }
}

export default RecordEditor

render()

Purpose: return the JSX describing what should appear on screen. React compiles JSX to React.createElement(type, props, ...children) calls, which produce plain JS objects — Virtual DOM nodes — not real DOM. render() must be pure: given the same this.props and this.state, it must return the same tree, with no mutation of instance state, no DOM reads/writes, and no network calls. React's reconciler diffs the newly returned tree against the previous one (element-type and key comparisons at each level) to compute the minimal set of real DOM mutations, which are applied later in the commit phase — never inside render() itself.

componentDidMount()

Purpose: the one safe place to run side effects that need a real, mounted DOM node. Trigger: immediately after the component's output has been committed to the DOM — this.myRef.current is guaranteed to be a real node here. This is where you fetch initial data, instantiate DOM-dependent third-party libraries (chart libraries, map widgets), and attach global listeners.

src/components/LiveChart.jsxjavascript
import { Component, createRef } from 'react'
import Chart from 'chart.js/auto'

class LiveChart extends Component {
  chartCanvasRef = createRef()
  chartInstance = null // plain instance property — not state, doesn't trigger re-renders

  state = {
    dataPoints: [],
    isLoading: true,
    error: null
  }

  async componentDidMount() {
    // 1. DOM-dependent library init — the <canvas> element is guaranteed to
    // exist in the real DOM at this point, which is NOT true in render().
    this.chartInstance = new Chart(this.chartCanvasRef.current, {
      type: 'line',
      data: { labels: [], datasets: [{ label: 'Requests/sec', data: [] }] }
    })

    // 2. Global event listener — paired with removal in componentWillUnmount
    // (Module 3) to avoid leaking a listener per mount/unmount cycle.
    window.addEventListener('resize', this.handleResize)

    // 3. Async data fetching.
    try {
      const response = await fetch(`/api/metrics/${this.props.metricId}`)
      if (!response.ok) throw new Error(`Request failed: ${response.status}`)
      const dataPoints = await response.json()

      // Guard: the component may have unmounted while this fetch was in
      // flight (fast navigation away). Calling setState after unmount logs a
      // React warning and is a memory-leak smell — this.isUnmounted is set
      // in componentWillUnmount (Module 3 covers the full teardown).
      if (!this.isUnmounted) {
        this.setState({ dataPoints, isLoading: false })
      }
    } catch (error) {
      if (!this.isUnmounted) {
        this.setState({ error: error.message, isLoading: false })
      }
    }
  }

  handleResize = () => {
    this.chartInstance?.resize()
  }

  render() {
    const { isLoading, error } = this.state
    return (
      <div className="live-chart">
        {isLoading && <p>Loading metrics…</p>}
        {error && <p role="alert">Failed to load: {error}</p>}
        <canvas ref={this.chartCanvasRef} />
      </div>
    )
  }
}

export default LiveChart

Triggered by a parent re-rendering and passing new props, by a local this.setState call, or by this.forceUpdate(). Order: static getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → (React commits DOM mutations) → componentDidUpdate.

shouldComponentUpdate(nextProps, nextState)

Purpose: the ultimate performance guardrail. Return false to skip render() and the entire rest of the update for this subtree, avoiding wasted Virtual DOM diffing and layout recalculation. Return true (the default if you don't implement it) to proceed normally.

src/components/ExpensiveRow.jsxjavascript
import { Component } from 'react'

class ExpensiveRow extends Component {
  // Manual shallow comparison — equivalent to what PureComponent does
  // automatically, shown explicitly so the guardrail logic is visible.
  shouldComponentUpdate(nextProps, nextState) {
    const propKeys = Object.keys(nextProps)
    if (propKeys.length !== Object.keys(this.props).length) return true

    for (const key of propKeys) {
      if (nextProps[key] !== this.props[key]) return true
    }

    return nextState.isHighlighted !== this.state.isHighlighted
  }

  state = { isHighlighted: false }

  render() {
    const { rowData } = this.props
    // Simulate an expensive layout — e.g. a large data grid row with many
    // computed cells. shouldComponentUpdate prevents this from re-running
    // on every parent re-render when rowData and isHighlighted are unchanged.
    return (
      <tr className={this.state.isHighlighted ? 'row--highlighted' : ''}>
        {rowData.map((cell) => (
          <td key={cell.id}>{cell.value}</td>
        ))}
      </tr>
    )
  }
}

export default ExpensiveRow

getSnapshotBeforeUpdate(prevProps, prevState)

Purpose: read a DOM property (scroll position, element dimensions) immediately before React applies the pending DOM mutations, so you can compare it against the same property after the update in componentDidUpdate. Trigger: right after render(), but before the commit — the DOM at this point still reflects the previous state. Whatever you return here is passed as the third argument (snapshot) to componentDidUpdate.

src/components/ChatMessageList.jsxjavascript
import { Component, createRef } from 'react'

class ChatMessageList extends Component {
  listRef = createRef()

  // Capture whether the user is scrolled to (near) the bottom BEFORE new
  // messages are inserted into the DOM. If we waited until
  // componentDidUpdate, the DOM would already reflect the new, taller list
  // and scrollHeight would include the incoming messages, making the
  // "was near bottom" check meaningless.
  getSnapshotBeforeUpdate(prevProps) {
    if (prevProps.messages.length === this.props.messages.length) {
      return null // no new messages, nothing to pin
    }

    const list = this.listRef.current
    const distanceFromBottom =
      list.scrollHeight - list.scrollTop - list.clientHeight

    // Treat "within 60px of the bottom" as "was at the bottom" — accounts
    // for sub-pixel rendering differences across browsers.
    return distanceFromBottom < 60 ? 'pin-to-bottom' : null
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    if (snapshot === 'pin-to-bottom') {
      const list = this.listRef.current
      list.scrollTop = list.scrollHeight
    }
  }

  render() {
    return (
      <ul className="chat-message-list" ref={this.listRef}>
        {this.props.messages.map((message) => (
          <li key={message.id}>{message.text}</li>
        ))}
      </ul>
    )
  }
}

export default ChatMessageList

componentDidUpdate(prevProps, prevState, snapshot)

Purpose: react to a completed update — sync with external systems, or fire a conditional network request based on which prop actually changed. Trigger: immediately after the DOM has been updated to match the latest render.

src/components/UserDetailPanel.jsxjavascript
import { Component } from 'react'

class UserDetailPanel extends Component {
  state = { user: null, isLoading: true }

  componentDidMount() {
    this.loadUser(this.props.userId)
  }

  componentDidUpdate(prevProps) {
    // Guard is mandatory: componentDidUpdate fires after EVERY update, not
    // just ones caused by userId changing (e.g. a parent re-render passing
    // a new but equal userId, or local setState from an unrelated field).
    // Omitting this check causes an infinite fetch loop: fetch -> setState
    // -> componentDidUpdate -> fetch -> ...
    if (prevProps.userId !== this.props.userId) {
      this.loadUser(this.props.userId)
    }
  }

  async loadUser(userId) {
    this.setState({ isLoading: true })
    const response = await fetch(`/api/users/${userId}`)
    const user = await response.json()
    this.setState({ user, isLoading: false })
  }

  render() {
    const { user, isLoading } = this.state
    if (isLoading) return <p>Loading user…</p>
    return <div className="user-detail">{user.name}</div>
  }
}

export default UserDetailPanel

Module 2 Takeaways

  • Mounting order: constructor → getDerivedStateFromProps → render → (commit) → componentDidMount.
  • Updating order: getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → (commit) → componentDidUpdate.
  • render() must stay pure — no setState, no DOM access, no network calls; it only produces Virtual DOM nodes for the reconciler to diff.
  • shouldComponentUpdate returning false skips render() and the commit entirely for that subtree — the core performance lever for class components.
  • getSnapshotBeforeUpdate is the only hook that can read the DOM after render() but before the commit applies new mutations.
  • componentDidUpdate needs an explicit prop/state comparison guard before firing conditional side effects, or it risks an infinite update loop.