Skip to content

Why did this render again?

What a state update does to a React component tree, and where to put state so it does less.

This started as slides for a talk about React. Slides can only show a picture of a render, so here the pictures are real: every box in a diagram below is drawn by an actual component on this page.

A box lights up when React calls that component, and the number above it counts how often that has happened. Underlined boxes can be clicked.

01model

React is not a template engine.

A template is filled with data once. Anything that happens after that is separate JavaScript, which finds elements and changes them.

<h1>{{ account.name }}</h1>
<button data-modal-button>Edit profile</button>
document.querySelector('[data-modal-button]')
  .addEventListener('click', openModal)

React asks for a different way of thinking. A component is a function. It is handed the current state and returns what the UI should look like for that state.

function AccountHeader({ account }) {
  const statusLabel = account.isActive ? 'Active' : 'Inactive'

  return (
    <header>
      <h1>{account.name}</h1>
      <p>{statusLabel}</p>
      <button>Edit profile</button>
    </header>
  )
}

You never write the code that changes the page. You describe the UI for one state, and when the state changes, React calls the function again and works out the difference: UI = fn(state).

The rest of this post is about that one step. Which functions does React call again, and why those?

02the rule

State is data that React keeps for you between renders. You get at it through useState, useReducer, useSyncExternalStore or useContext. Because React hands it to you, React also knows when you change it, and a change schedules a render.

What happens next is the whole reactivity model:

  1. state changes
  2. the component that owns it renders again
  3. its children render along
AppHeaderLogoMenuMainListDetail
Every box is a component with its own state. Click one to update it and watch where the render goes.

Renders go down the tree. They never go up and they never go sideways. Update App and all seven components render. Update Logo and one does.

React does not check whether a child got different props. The parent rendered, so the child renders. Nothing about the two updates differs except where the state lives.

03commit

"Re-render" sounds expensive, because it sounds like the browser is doing work. Mostly it is not. A render is React calling your function. After that it compares the result with the previous one, and only the differences are written to the DOM. That second step is the commit.

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

  return (
    <button onClick={() => setCount(count + 1)}>
      <Value count={count} />
      <Label />
    </button>
  )
}

function Value({ count }) {
  return <b>{count}</b>
}

function Label() {
  return <span>Clicks</span>
}
CountercountValuediff<b>0</b>in the DOMLabeldiff<span>Clicks</span>in the DOM
Click Counter. Both children render every time. The bottom row is the real DOM, watched by a MutationObserver: only one of the two is ever written.

Label renders on every click and its DOM node is never touched. A render that changes nothing costs a function call and a comparison, which is why most re-renders are fine.

It becomes a problem when the function itself is slow, or when there are a great many of them. That is worth knowing before reaching for a fix.

04state

Which component really needs this state?

If renders flow down from wherever the state lives, then the place you put state decides how much renders. Take an account screen with a button that opens a modal. The obvious home for isOpen is the screen.

function AccountScreen() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <main>
      <Button onClick={() => setIsOpen(!isOpen)}>
        Edit profile
      </Button>
      {isOpen && <EditModal />}
      <Overview />
      <Feed />
    </main>
  )
}
AccountScreenisOpenButtonclickEditModalOverviewFeed
Click Button to open and close the modal. Then move the state and click again.

With the state in AccountScreen, opening the modal also renders Overview and Feed, which have nothing to do with it. Move the state into a component that holds only the button and the modal, and the same click renders just those.

So state goes as low as it can. Buttons, dropdowns, modals and inputs can nearly always own theirs. Lift it only when several components really share it, and then only as far as their lowest shared parent.

Nothing was optimised here. The state moved, and the render boundary moved with it.

05memo

memo gives a component a way out. When its parent renders, React first compares the new props with the previous ones. If each of them is the same, the component is skipped, along with everything below it.

The comparison is by reference. An object or a function created while the parent renders is a new one every time, so memo seldom comes alone: the object needs useMemo and the callback needs useCallback.

function Overview(props) {
  // ...
}

function AccountScreen({ account }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <Button onClick={() => setIsOpen(!isOpen)}>
        Edit profile
      </Button>
      {isOpen && <EditModal />}
      <Overview
        account={account}
        filters={{ status: 'active' }}
        onSelect={(id) => openAccount(id)}
      />
      <Feed />
    </>
  )
}
AccountScreenisOpenButtonclickEditModalOverviewStatsChartFeed
AccountScreen passes
account
same every render
filters
new every render
onSelect
new every render
Overview renders along with its parent.
Click Button. A grey packet that fades out is React stopping at a component it can skip.

Switch on memo by itself and nothing changes, because filters and onSelect are new on every render. It only starts skipping once both are stable. Take either one away again and it stops working, without an error or a warning.

That is what makes memoization fragile. And there are renders it never stops: the component's own state, a context it reads, and props that really did change.

The hooks have a price as well. They keep a value around and compare dependencies on every render, which for this costs more than it saves:

const fullName = useMemo(() => {
  return firstName + ' ' + lastName
}, [firstName, lastName])

useCallback on its own prevents nothing. It only pays off when something compares the reference: a memo component, the dependencies of a useEffect, or a library that expects stable references.

So before memoizing anything, I ask why the component renders at all, and then work in this order:

  1. find where the render starts
  2. move the state as low as it will go
  3. use composition
  4. memoize what is still expensive

The React Compiler writes the useMemo and useCallback for you. It does not decide where your state lives, so the first three steps stay yours.

06context

Context is a subscription.

Context exists to end prop drilling. A provider makes a value available, and any component below it can read that value without the components in between passing it along.

For rendering, it helps to see useContext as subscribing to the provider's value. When the value changes, every component that reads the context renders. The rest is left alone.

const SidebarContext = createContext(null)

function SidebarProvider({ children }) {
  const [isOpen, setIsOpen] = useState(false)

  const value = {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
  }

  return (
    <SidebarContext.Provider value={value}>
      {children}
    </SidebarContext.Provider>
  )
}
SidebarProviderisOpenunrelated setStateSidebarTogglereads isOpenContentno contextOpenButtonneeds open()SidebarisOpen: false
Click SidebarToggle or OpenButton to change the sidebar. Click the provider to update some other state it holds.

Start with one context and toggle the sidebar. All three consumers render, including OpenButton, which only wants a function. A context works on the whole value: you subscribe to the object, not to a field in it.

Content does not render. It reads no context, and it was passed to the provider as children, so the provider rendering again does not create it again. That is the composition from the list above.

Now click the provider. Nothing about the sidebar changes, but the value object is built again, so every consumer renders for nothing. useMemo fixes that case and only that case: toggle the sidebar and it is as before.

Splitting does the rest. The data goes in one context and the functions in another. The functions never change, so a component that only opens the sidebar never renders again.

function OpenButton() {
  const { open } = useContext(SidebarApiContext)

  return <Button onClick={open}>Open</Button>
}

Context is not free global state. One large context ties every consumer to every update, and each consumer gets to choose only which context it follows.

07refs

A ref also keeps a value between renders. It differs from state in one way: changing it tells React nothing, so no render follows.

function Counter() {
  const [count, setCount] = useState(0)
  const clicks = useRef(0)

  return (
    <>
      <StateButton
        onClick={() => setCount(count + 1)}
      />
      <RefButton
        onClick={() => { clicks.current += 1 }}
      />
      <Screen count={count} clicks={clicks.current} />
    </>
  )
}
Countercount = 0clicks.current = 0StateButtonsetCount(count + 1)RefButtonclicks.current += 1Screencount 0 · clicks 0
Click RefButton a few times, then StateButton. The ref changes right away. Screen only finds out at the next render.

The filled square is state, the hollow one is the ref. Click RefButton and the value goes up, but no box lights up and Screen keeps showing the old number. The next state update renders, and only then does the screen catch up.

That makes refs both handy and dangerous. The question to ask is whether the value has to be visible in the UI. If it does, it is state. If not, a ref is right: a timer id, a previous value, a flag in an event handler, a render count. The counts in these diagrams are kept outside of state too, because counting a render must not cause one.

The other use is the DOM node itself, for things like focus:

function NameField() {
  const inputRef = useRef(null)

  return (
    <>
      <input ref={inputRef} />
      <Button onClick={() => inputRef.current?.focus()}>
        Focus
      </Button>
    </>
  )
}

State is for the UI. Refs are the escape hatch.

08recap

  • UI = fn(state)
  • A state update runs component functions again.
  • Whoever owns the state decides where the rendering starts.
  • Move state down before you memoize.
  • Context is a subscription, not free global state.
  • Refs keep a value without triggering a render.
NORMALdylanelens.com/blog/react-rerendersenTop