[compiler] Only inline SSR state whose setter is no longer read #37865

Open
mattcarrollcode opened 10:17pm on October 15, 2026 wants to merge 2 commits into facebook/react main from
compiler-ssr-keep-referenced-setters

Pull Request Overview

  • Opened on October 16, 2026
  • Status Open
  • Commit count 2 with first commit October 16, 2026

Total Delta

0 Total Diff Delta

Open Days

Open 21 weekdays

Test Delta

0 Diff Delta in Test Files
Breakdown by Phase

How long has this pull request spent in each phase of its lifecycle?

Data pending calculation for pull request

Author avatar

[compiler] Only inline SSR state whose setter is no longer read

The bug: React Compiler's SSR mode removes useState setter without checking to see if the setter is used in a SSR pass

With outputMode: 'ssr', the React Compiler turns const [count, setCount] = useState(0) into const count = 0 inlining the state. That deletes setCount, but the pass never checked whether anything else used it. So this:

const [open, setOpen] = useState(false);

return <Dialog open={open} onOpenChange={setOpen} />;

became const open = false; return <Dialog open={open} onOpenChange={setOpen} />;, which crashes with setOpen is not defined. The same happened when the setter went into a context value, was returned from a custom hook, or was called during render.

This issue was reproduced in both the Rust and TypeScript implementations, though the TypeScript implementation escaped some cases but not all.

The fix: only remove useState setters that aren't used later in SSR pass

The compiler now deletes a setter only if nothing left in the server copy uses it.

The compiler already removes code that never runs on the server: event handlers, effects, and the on… and ref props on HTML tags like <button>. It now makes those removals first, then works out what the compiler's existing cleanup step (dead code elimination) would delete, without deleting anything yet, and uses that to check each piece of state it wants to inline. Now there are additional checks to determine when/if to inline state:



  • If the setter was removed, inline state (see exception in the next section): if nothing left uses a useState's setter the state is inlined as before . For example, a setter used only by a button's onClick handler goes away with the handler, so const [count, setCount] = useState(0) still becomes const count = 0.


  • If the setter is still there, don't inline state: code that runs on the server reads it, so the useState call stays. The Dialog example above now renders instead of crashing.

Multiple renders in SSR and updated values passed to useState

If values passed to useState could change we also should not inline state. If a setter or a custom hook might change some state during render, React renders the component again and state passed to useState could change.

useState(start) saves a value: React stores start on the first render, and later renders of the component get the saved value back, ignoring start. Inlined state saves nothing, so start is recomputed on every render. The two match as long as the component renders only once.

On the server a component usually renders once. But when it calls one of its own setters while rendering, React throws that render away and renders the component again with the new state, on the server too. Inlined state can then come out different from the saved value:

function Pager({requestedPage, pageCount}) {

const [page, setPage] = useState(requestedPage);
const [wasOutOfRange] = useState(page > pageCount);
if (page > pageCount) {
setPage(pageCount); // forces a second render
}
return (
<p>
Page {page} of {pageCount}
{wasOutOfRange && ' (page not found, showing the last page)'}
</p>
);
}

wasOutOfRange has no setter, so checking setters alone would inline it as const wasOutOfRange = page > pageCount. If requestedPage is 7 and pageCount is 5, the second render recomputes it as 5 > 5, so the server renders Page 5 of 5 without the (page not found, showing the last page) notice. The browser renders the notice, which leads to a hydration mismatch.

The compiler can't tell whether a setter will actually run during render. So if a setter is still used, or the component calls a custom hook (which can call its own setters during render, or return a setter the component calls), it keeps every useState whose value is used, unless it starts at a literal like 0 or 'ready'. A useReducer with an init function keeps its hook even if its value is never used, because React calls init only on the first render. Other state whose value is never used is still inlined.

No comments have been left on this PR.