In this article, we'll go over the concept of tearing, how it's relevant to global state libraries used with React concurrent mode, and how the suggested solutions present new tradeoffs.
Readers familiar with these topics should feel free to skip the below content and go right to the demo.
In the most generic sense, tearing describes the presence of unwanted artifacts that result from inconsistent representations of some underlying state. In video, "screen tearing" occurs when a screen mistakenly displays information from multiple frames in a single draw likeso:
Image source: wikipedia
In this article, I want to discuss a different type of tearing called "state tearing". State tearing occurs when two or more UI components render inconsistent views of the same data source.
While state tearing itself is a solved problem in React 18, the solutions devised by the core React team to prevent it each come with their own tradeoffs.
Libraries using an external store give users fine grained reactivity to prevent unnecessary rerenders, but must occasionally opt-out of concurrent rendering to avoid state tearing. Libraries using an internal global store, like a propagated context value, ensure that all renders are non-tearing in concurrent mode, but don't allow fine grained reactivity for users out of the box - users must rely on memoization to avoid unintuitive rerenders. And lastly, some libraries, like Jotai, allow both fully concurrent renders and fine grained reactivity, but suffer from brief moments of state tearing while they reconcile inconsistencies. Personally, I'd go with Jotai, since I think a bit of temporary tearing isn't a big deal. Nonetheless, until React releases a public stable version of React Forget, the compiler the core team is developing, any global state library author must choose the least of three evils: temporary inconsistency, occasional de-optimizations, or lack of fine-grained reactivity.
And this brings us to the twitter post by Tanner Linsley, the creator TanStack, that inspired this article:
"I'm convinced that React's concurrent rendering model is fundamentally at odds with fine-grained reactivity/updates/rerendering. I have users who are clawing at me for fine-grained updates and rendering subscriptions in TanStack Router, but you simply can't have both fine-grained updates AND things like transition/suspense-aware state. Let me explain... [read the rest on twitter]" - @tannerlinsley
Tanner's post is referring to the last two of the three evils I mentioned above: occasional de-optimizations and lack of fine-grained reactivity.
I spent a couple days mulling over what he wrote, and eventually found a github discussion, https://github.com/reactwg/react-18/discussions/69, where @rickhanlonii explains how state tearing can occur when a React app uses external state in concurrent mode. I won't summarize everything he writes, since I can't imagine that I'd do a better job, but I recommend reading it before continuing to the next section of the article.
For readers not familiar with the inner workings of React state management libraries, I think it makes sense to provide more context around why external state was such a common implementation choice.
In the context of React, external state is usually found living in a store object defined above of any React-specific closure. Let's look at a simple example of some React code that uses such a store called externalState
.
import _ from "lodash"; import { lastPostedDate } from "./dataFromLastBuild" const externalState = { lastPostedDate, blog: "https://interbolt.org/blog/" name: "Colin", hobbies: ["running", "programming", "concerts"], pets: { dogs: 1, cats: 2, guineaPig: 1, }, }; const AboutMe = () => { const { name, pets, hobbies, blog, lastPostedDate } = externalState; const daysSinceLastPost = Math.round(Math.abs((new Date() - lastPostedDate) / 24 * 60 * 60 * 1000)) return ( <> <h2>About {name}</h2> <section> <h3>My Pets</h3> <p> Collectively, my partner and I have{" "} {_.sum(Object.values(pets))} </p> </section> <section> <h3>Hobbies</h3> <p> I enjoy {hobbies.join(", ").slice(0, -1)}, and {hobbies.at(-1)} </p> </section> <section> <h3>Blog</h3> <a href={blog}>{blog}</a> <p>Last posted {daysSinceLastPost} day(s) ago</p> </section> </> ); };
If we know externalState
will never change throughout the lifecycle of our application, this is a perfectly fine approach, but the name externalState
implies that we will use it to manage state, and in React, state is usually dynamic. But changes to variables that are defined outside of React's render lifecycle don't trigger rerenders in components that access them. Therefore, any update to externalState
won't change the UI.
import getLastPostedDate from "./getLastPostedDate" import { lastPostedDate } from "./dataFromLastBuild" const externalState = { lastPostedDate, // .. // ... }; // š this will update externalState.lastPostedDate after a new post // is published on my blog. const checkForNewPostsInterval = setInterval(async () => { try { externalState.lastPostedDate = await getLastPostedDate() } catch (err) { // error handling } }, 60 * 1000) const AboutMe = () => {...}
The purpose of the code above is silly, I know. But bear with me as I use it to demonstrate the problem.
In the above code, once per minute, checkForNewPostsInterval
calls an async function named getLastPostedDate
that fetches the publish date of my most recent blog post. Due to externalState.lastPostedDate
living outside of the render lifecycle, if I publish a new post while a user is viewing the <AboutMe />
component, externalState.lastPostedDate
will update but <AboutMe />
will never rerender. So unless the user refreshes their page, the UI will display a lie.
What's the point of using an external store if changes to its value never trigger rerenders in the components that read from it? Interestingly, when writing global state libraries, that kinda is the point.
externalState
:const externalState = {...}; // š each of these components may or may not use // some slice of `externalState`. const Header = () => {...} const AboutMe = () => {...} const BlogPreviews = () => {...} const Chat = () => {...} const Footer = () => {...} const App = () => { return ( <PageContainer > <Header /> <AboutMe /> <BlogPreviews /> <Chat /> <PetPhotos /> <Footer /> </PageContainer> ); };
It's much easier for library authors to build subscription APIs to enable fine grained access to slice changes than it is for them to coach their users on when to memoize. Therefore, any store where users must explicity opt-in to reactivity will result in more performant applications than those that rely on a store that forces users to opt-out of reactivity, since forgetting to opt-out can result in potentially expensive rerenders. Another excerpt from Tanner's post to help drive this home:
"To get fine-grained subscriptions in react, you have to use an external store (literally anything that doesn't trigger rerenders out of the box) and wire up the subscriptions to where they are used in react, hopefully with something like selectors, proxies, signals or atoms. [read the rest on twitter]" - @tannerlinsley
In practice, React global state libraries usually offer some kind of hook-based API for explicitly opting-in to reactivity likeso:
const BlogPreviews = () => { const [blog, lastPostedDate] = useExternalStateSelector(externalState => [ externalState.blog, externalState.lastPostedDate ]) return ( // implementation irrelevant ); }
In the above code, useExternalStoreSelector
does the dirty work of ensuring rerenders only occur when either externalState.blog
or externalState.lastPostedDate
changes. And in fact, this is how Redux does it:
import { useSelector } from 'react-redux' const BlogPreviews = () => { // `useExternalStoreSelector` was redux's `useSelector` all along š® const [blog, lastPosted] = useSelector(reduxStore => [ reduxStore.blog, reduxStore.lastPosted ]) return ( // implementation irrelevant ); }
Then came concurrency.
One of the first concepts JavaScript developers learn is that the language is single-threaded, meaning "synchronous" code will block the JS thread when it runs.
For most of its life, React guaranteed that renders would execute synchronously, which in turn guaranteed that non-render code had to wait for any ongoing renders to complete before running. And since changes to external state should (ideally) not happen within the render execution itself, the resulting render tree should never contain any two components that don't agree on the value of the external state (aka state tearing).
const externalState = {}; const RandomComponent = () => { // ā A library shouldn't allow its users to do this! externalState.someProp = 1; return ( ... ); };
And everyone lived happily ever after...?
Not so fast. Fast forward to today and, in concurrent mode, a React render can now yield to more important work, like user interactions. While a render is yielding, naively implemented external state is susceptible to changes that result in state tearing. A helpful visual from the following github comment show's a sequence that results in a "torn" render.
Image source: github
startTransition
One way that a render might yield to more important work is via React's startTransition
method.
startTransition
?startTransition
takes a synchronous function as its only parameter and ensures that any state changes resulting from its execution trigger a deprioritized render. An example:
const App = () => { const [mapSearch, setMapSearch] = useState("Charlotte, NC"); const [inputText, setInputText] = useState("Charlotte, NC"); const handleInputTextChange = (text) => { startTransition(() => { setMapSearch(text); }); setInputText(text); }; return ( <> <UserTextInput text={inputText} /> <ExpensiveMapToRender search={mapSearch} /> </> ); };
Prior to concurrent mode, calling setMapSearch
would kick off a rerender within <ExpensiveMapToRender />
, which, by its name, we assume would block the JS thread for a perceivable amount of time and cause the user's input to feel "laggy".
startTransition
solves this problem by ensuring that any expensive renders caused by the change to mapSearch
yield to renders caused by changes to inputText
, since the call to setInputText
does not occur within a startTransition
function. The end result is that an expensive map render, which might lock the UI, will only occur if the user takes a break from typing.
What made this click for me was to think about startTransition
a bit like a render debounce, where the timeout of the debounce equals the amount of time it takes for more important renders to occur. From the react.dev docs, here's some extra context around state updates defined within startTransition
:
A state update marked as a transition will be interrupted by other state updates. For example, if you update a chart component inside a transition, but then start typing into an input while the chart is in the middle of a re-render, React will restart the rendering work on the chart component after handling the input update.
startTransition
Let's look at the previous code example again, but this time I'm going to use a (fake) external store library to manage the input text.
const { updateFakeStore } = setupFakeExternalStore(); const App = () => { const handleInputTextChange = (text) => { startTransition(() => { updateFakeStore((store) => { store.mapSearch = text; return store; }); }); updateFakeStore((store) => { store.inputText = text; return store; }); }; return ( <> <UserTextInput onChange={handleInputTextChange} /> <ExpensiveMapToRender /> </> ); };
The code above assumes that <UserTextInput />
and <ExpensiveMapToRender />
are able to access the store, probably redux subscription-style, within their own render functions. Despite using external state, the code above will run correctly in both concurrent and sync render modes. But let's consider a tiny addition that causes concurrency to potentially break.
const { updateFakeStore } = setupFakeExternalStore(); const App = () => { const handleInputTextChange = async (text) => { startTransition(() => { // Rerender time: 50ms for first four children, 200ms for last six children. updateFakeStore((store) => { store.mapSearch = text; return store; }); }); updateFakeStore((store) => { store.inputText = text; return store; }); // New stuff below š // Resolves in 100ms const isMapInactive = await getIsMapInactive(); // THIS WILL CAUSE TEARING if (isMapInactive) { updateFakeStore((store) => { store.mapSearch = ""; store.inputText = ""; return store; }); } }; return ( <> <UserTextInput onChange={handleInputTextChange} /> <ExpensiveMapToRender /> </> ); };
In the code comments we see that the map takes 50ms to render its first four child components and then 200ms to render its final six, and that await getIsMapInactive()
resolves in 100ms. With that knowledge, let's walkthrough the scenario where await getIsMapInactive()
resolves to true:
handleInputTextChange
to run with the text "montreal".updateFakeStore
within the startTransition
function will trigger an expensive rerender of ExpensiveMapToRender
.updateFakeStore
interrupts and updates the input text. After the input text updates and the execution hits await getIsMapInactive()
, the map resumes its render.await getIsMapInactive()
resolves in 100ms, which means the expensive map has time to render its first four children before it's interrupted again. These first four children are rendered with a version of the store where store.mapSearch
and store.inputText
both equal "montreal".await getIsMapInactive()
resolves and isMapInactive=true
, the map render is once again interrupted so that updateFakeStore
can update store.inputText
and store.mapSearch
to empty strings.And that is state tearing.
useSyncExternalStore
to the rescueThe good news is that the React core developers considered all of this. In React 18, there's a new hook called useSyncExternalStore
that allows developers to safely access external state within the render lifecycle when using concurrent mode.
Without going too in-depth, useSyncExternalStore
is guaranteed to not tear because when in doubt, it falls back to synchronous rendering. From the react.dev
docs:
"If the [external] store is mutated during a non-blocking transition update, React will fall back to performing that update as blocking. Specifically, for every transition update, React will call getSnapshot a second time just before applying changes to the DOM. If it returns a different value than when it was called originally, React will restart the update from scratch, this time applying it as a blocking update, to ensure that every component on screen is reflecting the same version of the store."
The obvious downside of this approach is that we risk losing out on the benefits of concurrent mode if we write state tearing code while a render is yielding. For example, in the code example above with the expensive map, React would have caught the difference between the final six child components and the first four. Then, it would have grabbed a new snapshot of the store, which would include either the empty strings or the "montreal" strings (idk know for sure which one useSyncExternalStore
chooses here), and rerendered everything synchronously. The result is that all ten children of the map component would use a consistent version of the store.
So while useSyncExternalStore
is an OK option (and the one that Redux uses by the way), it doesn't take full advantage of concurrent rendering and prioritizes fine grained reactivity instead.
While the context API works flawlessly with concurrent mode and will never result in state tearing, it ends up putting pressure on application developers to memoize lots of their components, which could lead to annoying bugs. It's safe to say that one or both of the following features need to exist before library developers start to use the context API in places where they would typically reach for an external store:
React.useContextSelector
implementation, similar to Daishi Kato's userspace solution here: https://github.com/dai-shi/use-context-selector. This still presents issues when handlers defined and passed to a context provider aren't memoized aggressively.I might change my opinion on this as I learn more, but I have a hunch that if these things come to pass the core development team might change their tone on more aggressive uses of the context API for global state management.
Anyways, with no further ado, let's leave this blog and check out a demo I built to better visualize the tradeoffs made by various global state libraries: https://tearabledots.com.
The demo includes three different global state management strategies: external store tracked via useState
and useEffect
(this will tear), external store tracked via useSyncExternalStore
hook, and a propagated React context value. Within reason, I wrote as much as I could in the demo, so I think we're ready to wrap this post up.
That's all folks. I do React/NextJS consulting/contracting work and, as of December 2023, am available for hire. Feel free to email me a [email protected] with any questions or inquiries.