Deep Dives - Concurrent Rendering and the Tearing Problem
Contents
References
- 01. reactwg/react-18#69
- 02. reduxjs/react-redux#1351
- 03. reduxjs/react-redux#1740
- 04. reactwg/react-18#70
- 05. reactwg/react-18#86
- 06. blog.axlight.com/why-use-sync-external-store-is…
- 07. dai-shi/will-this-react-global-state-work-in-concurrent-rendering
- 08. blog.isquaredsoftware.com/blogged-answers-a-mostly-compl…
This article is the first of many in exploring and understanding the inner workings of the technology that I use everyday. Some of these articles will present my own personal deep dive into topics I’m interested in, sometimes they’re already well discussed, other times maybe not. Then other articles might be more code exploratory including samples and a post mortem of my findings to follow.
First up, tearing in React!
What tearing is
In short: two parts of your UI rendering against inconsistent versions of the same state, at the same time.
Before React 18, this wasn’t something you had to think about. React rendered your entire component tree in one go: every component ran, everything committed, and the whole tree was working from the same snapshot of state. React 18 changed that by introducing concurrent rendering, where React can now pause a render mid-tree, hand control back to the browser to handle higher priority work, and then resume. This is genuinely great for perceived performance and UI responsiveness, but it creates a gap that didn’t exist before. If something outside of React mutates state while React is paused, components that rendered before the pause are working with one version of the world and components that render after are working with a different one.
The incident that forced the ecosystem to respond
The most concrete version of this problem played out publicly in the react-redux ecosystem when React 18 shipped, and it’s worth understanding because it shaped the API React provides today.
React-redux v7 was built around a pattern using useReducer and useEffect. The idea was that each connected component would keep its own local slice of state, with an effect-based subscription keeping it in sync with the Redux store. In synchronous React that worked fine, because effects always ran after the whole tree committed, so by the time any subscription update fired the render was already done. The pattern held up in production for years.
What React 18’s concurrent mode did was invalidate that assumption. When startTransition or Suspense-driven renders came into play, React could now pause mid-tree, and the Redux store could update during that pause. The react-redux team had actually been tracking concurrent mode compatibility as a concern since React first started previewing concurrent features, but the full scope of the problem only became clear once React 18 shipped. A simplified version of what v7 was doing internally looked something like this:
function useSelector(selector) { const store = useContext(StoreContext) const [selected, setSelected] = useState(() => selector(store.getState()))
useEffect(() => { return store.subscribe(() => { setSelected(selector(store.getState())) }) }, [store, selector])
return selected}Each component instance calls useState when React renders it, capturing whatever the store’s state is at that moment, and that’s where the vulnerability enters. If the store updates between rendering component A and rendering component B during a concurrent pass, those two components initialize from different snapshots. The useEffect subscription doesn’t wire up until after commit, which is past the point where it could help. Both components commit to the DOM with inconsistent values, and the same store is showing two different things on screen simultaneously.
This wasn’t theoretical or limited to contrived examples. Mark Erikson, who maintains react-redux, and members of the React core team including Dan Abramov worked through the problem and its constraints together in public threads across the React 18 working group on GitHub. It affected every application using react-redux with concurrent features enabled, which is why the response had to happen at the infrastructure level rather than as advice to individual application developers.
The React team shipped useSyncExternalStore as a first-class API specifically to give library authors a way out of this problem, and react-redux v8 adopted it without any visible change for users. Internally, useSelector became something much closer to this:
function useSelector(selector) { const store = useContext(StoreContext) return useSyncExternalStore( store.subscribe, () => selector(store.getState()) )}The two implementations look almost identical, but what React does with them is completely different. React calls getSnapshot once to establish a baseline at the start of the render pass, then checks it again at the end. If the store mutated in between, the snapshot is stale, React discards the in-progress tree, and re-renders everything synchronously so that every component reads from the same consistent value. Andrew Clark from the React core team documented the reasoning behind that tradeoff in the discussion that also tracked the API’s evolution from useMutableSource, with the short version being that brief tearing is almost always a worse experience than the overhead of a synchronous re-render. You provide useSyncExternalStore a subscribe function, a getSnapshot function to read the current value, and an optional server snapshot for SSR, and React handles the rest.
Where the ecosystem still disagrees
Daishi Kato, who built Zustand and Jotai, has written about a genuine design tension here. Jotai deliberately does not use useSyncExternalStore internally, and the reasoning is worth engaging with rather than dismissing. His position is that the synchronous constraint useSyncExternalStore imposes is partially incompatible with time slicing, which is one of concurrent rendering’s most valuable properties. Forcing a synchronous re-render when a store updates mid-pass means you’re partially giving back the scheduling flexibility that concurrent mode was meant to provide, and for some use cases he argues that brief inconsistency is an acceptable tradeoff for better cooperation with the scheduler. He’s documented that tradeoff here and it gets into some genuinely nuanced design territory if you want to go deeper. He also put together a test harness that checks popular state libraries for tearing and branching behavior under concurrent rendering, which is probably the most cited empirical reference on the topic in the community.
Where you’re most likely to run into this
If you’re using Redux Toolkit, Zustand, or a similarly well-maintained library, they’ve already handled this at the library level. Where you need to be more careful is when you’re rolling your own store with module-level state and pub/sub, integrating a third-party state system that hasn’t updated its React adapter, or reading from browser APIs like navigator.onLine or localStorage directly during a render, since those are mutable external values React has no visibility into.
Mark Erikson’s guide to React rendering behavior is probably the most thorough community resource on how all of this fits together, updated for React 18 with specific coverage of how external store updates interact with the concurrent rendering pipeline.
None of this applies unless you’re actually using concurrent features. If you’re not using startTransition, useDeferredValue, or Suspense with streaming, you’re still in synchronous rendering territory and tearing can’t happen. As concurrent features become more common in library code and in React’s own ecosystem though, this is worth having on your radar rather than discovering it the same way we did. The react-redux story shows the mainstream answer, and useSyncExternalStore is what most of the ecosystem has converged on, but the tradeoffs are real and different libraries are still making different calls based on their priorities.
Thank you for reading ← You can click me!