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
Transcription
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 development lead in a small software agency. And our job is to go into other software development companies and help the teams accelerate their software projects. We're doing 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 18 release, the new rules, and what you have to do to be concurrent mode and 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. Let me jump back a couple of years ago to 2018, and Abramov introduced async rendering. So React should adapt to the user's device. Fast interactions would feel instant, and slow interactions would feel responsive. And the main technique was by splitting up the rendering process so that we can pause, resume, and do different updates so that our applications stay fast and responsive, no matter the device or the network conditions. Since then, lots have changed. So for example, the release date 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 code base as long as you don't use the concurrent features. So you could say the rules of React have changed since then. But this 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. I found this design document stating the concepts and the mental models around React from seven years ago. And in this, it says, the core premise for React is that user interfaces are simply a projection 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 in 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. This leads us to a couple of misconceptions about this rendering process that we just assumed were true, but were never really true and are not true today with concurrent features. The first misconception is 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 the 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 interruptible. 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. This meant React no longer has the stack of components, but it's a simple list that React 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 clicked on a 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. So let's clear that and let's run a rerender. So the first component starts rendering, then it's done, and directly after that the second component starts, and then directly after that the third component starts. And as you can maybe see, each component takes roughly five seconds to do its work. So 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. So when I click the Rerender button and change this global state value here, 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 reconciliation process, it handled my event clicks, 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 represent the state from the start of the process. Because React, during this reconciliation, 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 reconciliation was run. Now let's switch to the low priority update. So it's using React start transition, as you may have already seen in some other talks, to schedule a low priority update. So 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 yielded to the browser, and the browser decided 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 two and three, the event loop decided 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. So sometimes the console logs would appear between those two as well, or only here. But as you can see in our case, in between component one and two, the change to the variable happened. So 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 somewhere someone up the tree might be using those new React concurrent features and introduce us to those reconciliation phases that are split up over time. And as you can see, this leads to problems. So our first component displays the 25, and the second and the third component displays the 28. So now we have this tearing in our application. So one part of the application shows one snapshot in time, and the other parts of the application display another snapshot in time. And 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 variable, a 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. So, 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, it doesn't know that it needs to abort this new concurrent rendering process, and will display some inconsistent states in your UIs in the worst cases. And this also means that you are writing your own Redux and 20 lines, for example, that you need to be very careful how you connect that to your components. Because if you just call getStage during the rendering process, it's again the same problem, it's just as reading, 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 forChanges in the store, 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 displays this teared state. Before we go into how you can solve these problems, let's first take a look at the second misconception. And that is that one render or one reconciliation phase always leads to exactly one commit phase. By default, this might be true. So we have this reconciliation process that runs through all of our components inside of our application. And then when this is done, when React knows what has to be done, it will switch to the commit phase and run the commit, update the DOM for all of our components. And even when we are using concurrent features, we still have the guarantee that the commit phase is atomic. So even if we have those splits, those time splits in the reconciliation phase, we have the guarantee that committing is atomic because React team made the decision that they don't want to display inconsistent data. So they say, okay, 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're 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 this strict mode helps you detect when you're using external values that might change outside of the React world. 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. And 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. I show them and directly after that, I click on hide blocks. Now again, we have to wait through all of our components rendering, one, two, three, five seconds each. And 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. So by default, you kind of have this guarantee that you're rendering and then you're done with rendering or reconciliation. Then your effects will be called in the commit phase and the unmounts will be called because our elements are directly hidden again. But now let's try the same thing with a low priority update. So now we show the blocks with a low priority and directly after that, we hide the blocks. And 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. 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 world. So we can adjust a board, this new low priority update and keep everything as it is. So now we have this state where one component rendered and was called, our function was called, but there didn't happen any commit. So no effects were called, no layout effects were called, nothing happened, 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 unsubscribe from the use effect 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 cleanup phase because a pure function doesn't need a cleanup, you just don't call it again. So we have to make sure that our rendering itself is pure again. The good news is that this very rarely happens in application code that we have seen out there. But this quite often happens in state management libraries because 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. So the component renders, it recognized that you are reading some values and registered that as a dependency. This maybe looks something like this. So you have this reference for this listener and whenever the component is run, we check if we already have 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. So the window object, of course, is 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 user feels that the event listener is running on the same event listener and we add a function there. But this is not possible. So we have to do that in the library. And then we have to do that in the library. So we have to do that in the library and we have to do that in the library. And then we have to do that in the library. So we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library. And then we have to do that in the library.