Solving React useSyncExternalStore: Fix Concurrent Rendering Tearing
React useSyncExternalStore prevents UI tearing during concurrent rendering. Learn how to synchronize external state sources effectively in your React apps.
During a recent refactor of a high-frequency dashboard, I noticed the UI jittering whenever we performed heavy background calculations. It turns out our custom subscription model was failing because of React concurrent rendering, leading to a nasty case of "tearing" where the UI displayed inconsistent data states.
If you’ve ever built a system that pulls data from an external source—like a WebSocket, a browser storage API, or a custom event emitter—you’ve likely encountered this same pain. The fix isn't just about calling useState; it's about using React useSyncExternalStore to ensure your component tree stays in lockstep with the source of truth.
The Problem: Why Concurrent React Tearing Happens
In older versions of React, rendering was synchronous. If your data changed, React would re-render immediately. With concurrent rendering, React can pause, resume, or even discard work in progress.
If your external store updates while React is in the middle of a render pass, different parts of your component tree might "see" different versions of the state. This is called tearing. It’s the visual equivalent of a screen flicker, and it’s notoriously hard to debug.
We initially tried to patch this by wrapping our subscribers in useLayoutEffect, but that just pushed the problem into the next commit cycle, often causing the UI to snap from the old value to the new one, which felt just as broken to the user.
Implementing useSyncExternalStore
The useSyncExternalStore hook is specifically designed to subscribe to external stores in a way that is compatible with React’s concurrent features. It forces the component to re-read the store whenever the state changes, ensuring consistency.
Here is the basic signature:
JAVASCRIPTconst state = useSyncExternalStore(subscribe, getSnapshot);
subscribe: A function that registers a callback to be called whenever the store changes.getSnapshot: A function that returns the current value of the store.
A Concrete Example
Let's say you're building a custom store for window dimensions or a theme preference that lives outside of React's state tree.
JAVASCRIPTimport { useSyncExternalStore } from CE9178">'react'; // Your external store const store = { data: CE9178">'light', listeners: new Set(), subscribe(callback) { this.listeners.add(callback); return () => this.listeners.delete(callback); }, getSnapshot() { return this.data; }, update(newData) { this.data = newData; this.listeners.forEach(l => l()); } }; function ThemeComponent() { const theme = useSyncExternalStore( store.subscribe, store.getSnapshot ); return <div>Current theme: {theme}</div>; }
Why This Works Better Than Context
When you manage global state with Zustand or Redux, you’re already using libraries that have internalised these patterns. However, if you're building a bespoke integration for a legacy system or a browser API, you need this hook.
Unlike useState or useContext, useSyncExternalStore doesn't just store a value—it guarantees that the value returned by getSnapshot is consistent across the entire render pass. Even if your optimistic UI patterns are updating frequently, the hook ensures the component remains "in sync" with the store at the moment the render starts.
Comparison: Old vs. New Patterns
| Feature | Old Pattern (useEffect) | useSyncExternalStore |
|---|---|---|
| Consistency | Risk of tearing | Guaranteed atomic |
| Performance | Causes extra renders | Optimized for concurrent |
| API | Manual subscription | Native React hook |
| Complexity | High (needs cleanup) | Low (declarative) |
Common Pitfalls
- Non-stable
getSnapshot: If yourgetSnapshotfunction returns a new object reference every time (e.g.,() => ({ value: store.data })), React will think the state has changed every single render, leading to an infinite loop. Always return a primitive or a memoized object reference. - Missing
getServerSnapshot: If you are using Server-Side Rendering (SSR), you must provide a third argument to the hook:getServerSnapshot. If you don't, React will throw an error during hydration because the server-rendered HTML won't match the client's initial state.
If you are dealing with complex UI hierarchies, you might also be interested in how React compound components can help clean up the API surface of these synchronized stores.
Final Thoughts
We used to spend about two days a month fixing race conditions in our state management logic. Moving to useSyncExternalStore effectively eliminated that class of bug. While it feels a bit boilerplate-heavy at first, the predictability it brings to concurrent React state is worth the effort.
I'm still experimenting with how to best handle large, complex state objects with this hook—specifically, whether to use a selector pattern inside getSnapshot or just keep the store granular. For now, keeping the snapshots small and immutable has kept our apps snappy and tear-free.
FAQ
Does useSyncExternalStore replace useState?
No. Use useState for state that belongs inside your React component tree. Use useSyncExternalStore only for data that lives outside of React.
Why does my UI tear without this hook? Concurrent rendering allows React to interrupt work. If an external store changes during this pause, the UI might render half-old, half-new data. This hook prevents that.
Can I use this for API data? Technically yes, but it's usually better to use a library like TanStack Query for advanced server-client state synchronization rather than building your own sync layer from scratch.