With React 18 the long awaited concurrent features are now available to the public. While they technically do not introduce new restrictions about how we build our components, there are many patterns that previously worked but might now introduce subtle bugs in your apps. Let's re-learn the rules of React so that we can stay safe in this new concurrent world.
Staying Safe in a Concurrent World
AI Generated Video Summary
This talk explores the implications of the new concurrent features in React 18 and how they impact developers. It discusses the core premise of React and the importance of pure function components. The talk also addresses misconceptions about React's rendering process and the prevention of tearing in applications. Additionally, it highlights the reconciliation and commit phases in React and the challenges of dependency management in state management libraries.
1. Introduction to React 18 Concurrent Features
This talk explores the implications of the new concurrent features in React 18 and how they impact developers. The speaker, Andreas, shares his experience as a development lead in a software agency and highlights the importance of staying safe in a concurrent world.
I hope you're having an awesome conference so far. Maybe you even listened to a couple of talks mentioning the new concurrent features that were released in React 18 a couple of months ago. In this talk, we are not going into details about how these features work and what they are doing, but we want to take a look at the implications and ramifications that those features have on us as developers so that we can stay safe in a concurrent world of React.
Before we are going to dive into that, let me first tell you a bit about myself. My name is Andreas and I'm from Dresden in Germany, where I'm a development lead in a small software agency. Our job is to go into other software development companies and help the teams accelerate their software projects. We do that by using technologies like TypeScript React. So, this is exactly the stuff that I'm doing every day.
In this work, what we realized over the last couple of months is that there's lots of fear, uncertainty and doubt because of the new React agent release, the new rules and what you have to do to be concurrent mode-safe in your applications. And this is why I proposed this talk, so that you can lean back and stay safe in a concurrent world.
2. Introduction to React 18 Concurrent Features
Abramov introduced async rendering in 2018 to make React adapt to the user's device and ensure fast and responsive interactions. Since then, the release date was pushed back, and the name changed to concurrent rendering or concurrent mode. With React 18, concurrent features were introduced, allowing developers to opt-in specific parts of their application. The rules of React have not changed, but we are now starting to utilize them more effectively.
When we jumped back a couple of years ago, to 2018, then Abramov introduced async rendering. So, React should adapt to the user's device, fast interactions should feel instant and slow-end interactions should feel responsive. And the main technique was by splitting up the rendering process, so that we can pause, resume, do different updates, so that our application will stay fast and responsive, no matter the device or the network conditions.
Since then, lots have changed. So, for example, the release date has pushed back a bit, has been pushed back a bit, and the name changed from async rendering to concurrent rendering or concurrent mode. And then with React 18, the team made the awesome decision to not introduce a concurrent mode that switches all of your application in this new concurrent world, but they introduced concurrent features, so that you can opt in in tiny parts of your application into the concurrent features, so that your whole application must not be concurrent mode ready, but only parts of your application.
They even released a blog post back then, where they made certain changes to the React API to prepare this change for the future. So they removed component will mount, component will receive props and component will update, or replaced them with unsafe variants. This is so you as developers know that these methods are not really safe to use with concurrent features, but they can still live in your codebase as long as you don't use the concurrent features. You could say, the rules of React have changed since then. But it is not true. The rules of React have not changed since back then. This is the most important point of my presentation. The rules of React have not changed. We are only now starting to really make use of the same rules that have been present way back then.
3. React's Core Premise and Abstraction Layer
The core premise for React is that user interfaces are simply a production of data into a different form of data. The same input always gives the same output. Render functions and class components have now become function components, but the rule remains the same - they must be pure. They should not read values from the outside world or mutate it. React handles the decision of when rendering happens and which part of the application renders, allowing developers to work above this abstraction layer.
I found this design document stating the concepts and the mental models around React from 7 years ago. And in this it says, the core premise for React is that user interfaces are simply a production of data into a different form of data. The same input always gives the same output. A pure function. So what were the render functions and our class components back then are now function components. But the rule is still the same. These functions, the render function and our component functions must be pure. They should not read values from the outside world. So for example, they should not use math.random and they should not mutate the outside world. So add some event listeners, fetch some data from the server, because React wants to make the decision when the rendering happens and which part of our application renders. We as developers should only live above this abstraction level so that we are not interfering with everything below this abstraction layer.
4. Misconceptions about React's Rendering Process
The reconciliation phase in React's rendering process is not atomic, contrary to a common misconception. In earlier versions of React, the stack reconciler made the rendering process non-interruptible. However, with the introduction of the Fiber architecture in React 16 and the concurrent features in React 18, the rendering process can now be split apart. This allows React to yield to the browser's event loop and handle user inputs during the rendering process.
This leads us to a couple of misconceptions about this rendering process that we just assumed were true but are never really true and are not true today with concurrent features. The first misconception is that the reconciliation phase is atomic. So the rendering process of React as you may know is split into those two parts, the reconciliation phase where React calls all of your functions or your components, generates the JSX, compares the JSX to the previous version to then generate those units of work that will be done later. And this later is the commit phase.
So when everything is reconciled, React knows what has to be done to make the user interface match the current state of the world. So then the commit phase runs and React updates the user interface, no matter if it's the DOM or React Native or the terminal or whatever. So in this first misconception that we have in our mind, we see reconciliation as an atomic process. Whenever rendering starts, it runs to the end. And this was true in earlier versions of React, but never guaranteed. It was never a rule of React, it just happened to be true because of the implementation details.
Back then, React used the so-called stack reconciler. So we have this stack where the React library traverses our application down to lower levels, calls the app function, calls the main function, et cetera. And the problem with that process was that it was just not interruptable. It was just not possible to interrupt this reconciliation phase. This is why they made this major rework with React 16 where they switched to the Fiber architecture. What does this mean? React no longer has the stack of components, but it's a simple list that we can iterate through linearly to do the work that is required for each component.
And the great thing now with the concurrent features is that now the concurrent phase, everything React does can now also be split apart, if we want that as users. That means the event loop, in the meantime, gets a bit of air to handle some inputs from the user. So for example, the user scrolled on the page or the clicked on the button or entered some values inside of a form. The event loop can now process those changes because React can now be interrupted. React checks after every component, do I still have time or is there some work to do from the browser? And then it yields to the browser so the browser can handle this work.
Because this is a bit hard to grasp, I made a small demonstration for that. What we have here is a small application where on the left we have those three components and on the top we have some controls so that we can control what's happening inside of our app. On the right I have the console open, where you can see that all components log to the console when they start rendering and when they are finished with rendering. Let's clear that and let's run a re-render. So the first component starts rendering, then it's done. After that, the second component starts and then directly after that the third component starts. As you can maybe see, each component takes roughly 5 seconds to do its work. This is an artificial delay so that we can take a closer look at what's happening here. But by default, this rendering process is still atomic.
5. React Reconciliation and Preventing Tearing
When re-rendering a React component, the reconciliation process takes a snapshot of the JSX elements and commits it to the DOM after completing the re-conciliation. Low-priority updates allow React to yield to the browser and handle event clicks before continuing rendering. However, this non-deterministic behavior can lead to tearing in the application, where different components display different snapshots of state. To prevent this, it's important to avoid reading values that may change outside of the React world, such as global variables or local storage. Additionally, caution must be exercised when using custom state management solutions like Redux, as accessing state during rendering can cause similar issues.
When I click the re-render button, it changes this global state value. I'm just clicking until everything is done with rendering. You can see that React rendered our component with the initial state and only after this re-reconciliation process it handled my events and incremented the global state, which is just a global variable inside of my application. You can also see that the state in our component, in the user interface, still represents the state from the start of the process.
React during this re-conciliation takes a snapshot inside of those JSX elements and then when it's done, it commits those snapshots to the DOM. So, no matter what the current value is, it commits the snapshot from the time when the re-conciliation was run.
Now let's switch to the low-priority update. So, it's using react-use-start-transition, as you may have already seen in some other talks, to schedule a low-priority update. Now let's click re-render low-priority and click on change global state again. And as you maybe saw, after the first component is done rendering, React yields it to the browser and the browser decides that it now needs to handle those event clicks. So, it calls my function which just logs to the console and changes the value of the variable and then it goes back to the browser to the event loop and the event loop decides that now React turns again and React continues rendering.
This happens between every component during the rendering process, but as you can already see between component 2 and 3 the event loop decides again to give React the choice to do something and not the event handler. So, it's a bit non-deterministic what the browser is doing in the background. Sometimes the console logs would appear between those two as well or only here. But as you can see in our case between component 1 and 2 the change to the variable happened. The first component rendered with state 25, the second rendered with 28, and the third also rendered with 28. So, now you can see that the reconciliation process is no longer strictly atomic in a modern React application because we don't know someone up the tree might be using those concurrent features and introduce us to those reconciliation phases that are split up over time.
And as you can see this leads to problems. Our first component displays the 25 and the second and third component displays the 28. So now we have this tearing in our application. One part of our application shows one snapshot in time and the other parts of the application display another snapshot in time. This of course is not what we want.
So that means what do we need to do so that we don't get this ugly tearing inside of our applications. We need to stop reading values that might change outside of the React world. So in our case this was a simple global variable and we read the value of the variable and change it in some event handler. If that would be a constant that would be totally fine because a constant can never change and then we can use that constant during rendering. But we need to stop reading values that might change outside of React. So for example a local variable or local storage because everyone can write to local storage or the current value of a ref property because those refs or references are explicitly made so that you have objects that you can mutate that you can reset outside of the React world without React knowing that something has changed. So React doesn't know it needs to re-render, doesn't know that it needs to abort this new concurrent rendering process and will display some inconsistent state in your UIs in the worst cases. And this also means that you are writing your own redox and trendy lines for example that you need to be very careful how you connect that to your components because if you just call getState() during the rendering process it's again the same problem.
6. React's Reconciliation and Commit Phases
Reading global variables that can be changed can lead to tearing problems. React's reconciliation and commit phases ensure atomicity, even with concurrent features. However, React strict mode in development detects inconsistencies between reconciliation phases. Concurrent features introduce slices between reconciliation steps, potentially rendering the commit phase obsolete or changing its result. In the demo application, showing and hiding blocks with high and low priority updates demonstrate the rendering and commit phases.
Just the same as reading some global variable that can be changed so that you also might run into those tearing problems. Most of the time you will add another event listener for example for the changes in the store or for changes to some local storage for example. So everything will work out eventually but there might be certain periods of time where your application displaces this teared state.
Before we go into how you can solve these problems, let's first take a look at the second misconception. One render or on reconciliation phase always leads to exactly one commit phase. By default this might be true. We have this reconciliation process that runs through all of our components inside of our application and then when this is done, when we acknowledge what has to be done, it will switch to the commit phase and run the commit and update the DOM for all of our components. Even when we are using concurrent features, we still have the guarantee that the commit phase is atomic. Even if we have those time splits in the reconciliation phase, we have the guarantee that committing is atomic because React team made the decision that they do not want to display inconsistent data. So, they say, whenever we start updating the DOM, we do everything that we currently have to do to update every place that reads the state so that everything is a consistent state.
But now, there are some situations where the one-to-one mapping between reconciliation and commit phase is no longer true. When you are using React strict mode in development, for example, you already have this change that the reconciliation phase will be run twice and the commit phase will only run once. This already warns you in development when those two reconciliation phases yield different results. Because, as we said earlier, when the input is the same, the output should always also be the same. So the strict mode helps you detect when you are using external values that might change outside React. For example, when you are using math.random, React will detect that different reconciliation phases yield different results and warn you. But now, with those concurrent features, we have those slices in between the reconciliation steps. Between each of those steps, something might happen that might make the commit phase obsolete or change the result of the commit phase because some state changed again.
We can take a look at that in our demo application again. Let's first hide our blocks and tick this Show Commits box, and clear the console. Now, let's show our blocks with a high priority update and then directly after that, we hide them again. After that I click on Hide Blocks. We have to wait for all our components rendering, one, two, three, five seconds each. Then we can see that after that process when the rendering is done, we get those commits for every component, and directly after that we get those unmounts. By default you have this guarantee that you are rendering, and when you are done with reconciliation, then your effects will be called in the commit phase, and the unmounts will be called because our elements are directly hidden again. Now let us try the same thing with a low priority update. We show the blocks with a low priority, and directly after that we hide the blocks. What we can see is that React rendered our first block, the first component. After that it yielded to the browser. The browser handled our event.
7. React Rendering and Pure Rendering
In concurrent rendering, if a low priority update is aborted, no effects or layout effects are called. This can lead to problems, so developers should avoid subscribing or mutating the outside world inside components. Rendering should be pure to ensure no cleanup is needed.
The event set the show variable, the show toggle back to false, and React knew that okay, this is the old state that I still have available here in my concurrent rendering build. So we can just abort this new low priority update and keep everything as it is. So now we have this state where one component was rendered, was called, our function was called, but there didn't happen any commit. So no effects were called, no layout effects were called. Only the rendering phase. And this of course can again lead to problems. And it means that we as developers, we need to stop subscribing or mutating the outside world inside of our components. Because whenever we mutate something from the outer world or add an event listener for example in a subscription, then we depend on the unsubscribed from the user fact to clean up after us. But in those cases where you only have this reconciliation phase that is completely aborted, you will never have a clean-up phase. Because a pure function doesn't need a clean-up. You just don't call it again. So we have to make sure that our rendering itself is pure again.
8. Dependency Issues in State Management Libraries
In state management libraries, automatic dependency addition to components during the reconciliation phase can lead to issues. This is more common in libraries rather than application code. It involves adding variables as dependencies to a global state management container, which can cause problems when there is no guarantee of a commit phase. React applications typically do not exhibit such patterns.
The good news is that this very rarely happens in the application code that we have seen out there. But this quite often happens in state management libraries. Some libraries try to automatically figure out which variables you were using during the reconciliation phase and add those variables as dependencies to this component in some global state management container. The component renders and recognizes that you are reading some values and registers that as a dependency. This maybe looks something like this. We have this reference for this listener and whenever the component is run we check if we have already instantiated that listener and if no, then we just create a new function and put that inside of the event listener array on the window object. The window object is of course a global object and we add a function so we mutate the list of event listeners and add a function there in this global object. We could do that when we are absolutely sure that the useEffectCleanup() will clean the listener function up again. This will remove the same event listener that we added previously. But since you just saw, we no longer have this guarantee or we never had this guarantee that after each rendering process there will definitely be a commit phase, which means we must not do this in our applications. Again this is more towards the library maintainers and the library authors as in normal React applications you very very rarely see patterns like this.