Performance Optimization with React 18 Concurrent Rendering

Performance Optimization with React 18 Concurrent Rendering

Concurrent rendering is one of the most exciting new features of React 18, and as such, it’s often discussed in the React community.

Table of contents

    Still, while it’s most frequently described as a great tool for performance optimization, we should implement it with some caution. That’s because it not only gives us greater control over the rendering process but also changes the internal workings of React. Thus, it's not always easy to decide when to use it and when to stick to the more traditional way of writing React's code. So, let’s examine the example of the new concurrent features by using the useTransition hook and see how it affects the performance of our code!

    By the way, examples below can also be found at our GitHub repository, so you can experiment with them for a better practical understanding of the topic.

    What is concurrent rendering in React 18?

    React 18 introduced many interesting new features that aim to help with performance optimization (read in the official docs here): automatic batching, streaming server rendering, and most important to us, concurrent features such as transitions. Concurrency mainly works here under the hood, optimizing the user experience and performance of the user interfaces. It's no longer a completely experimental feature, as the React 17 concurrent mode has been replaced with concurrent features that can be used out of the box.

    The changes implemented in the new version of React introduce a whole new rendering model. Still, you can adapt to it at your own pace because you only opt into this concurrent renderer in parts of your app in which you use concurrent features. So, what happens if you decide to do it? When you’re not using it, you can’t control the order of your state updates. In previous versions of React, or if you’re not opted-in to concurrent features, the updates are synchronously rendered. So, when the component tree started rendering, it couldn't be interrupted. If there were some slow operations or any large rendering task, it could make the UI laggy or even stop working and not respond immediately to user interactions.

    React's concurrent rendering is the solution to this problem. The newly introduced hooks, useTransition and useDeferredValue, help distinguish updates as having higher and lower priority in their execution.

    Concurrent rendering React hooks: useTransition

    The useTransition hook lets you do exactly that. By using the startTransition function, you can mark some updates as non-urgent (non-blocking the main thread). These changes will be calculated in the “background”, but interrupted when the higher-priority urgent updates (all not marked as transitions) are called. As a result, they will be executed after all other updates in the queue have finished. Just let’s look at the code snippet:

    const ComponentWithTransition = () => {
     const [isPending, startTransition] = useTransition();
     const [state, setState] = useState();
    
     const handleClick = () => {
       // Higher-priority update
       setState("new state");
    
       // Lower-priority update
       startTransition(() => executeSlowerOperation("new state"));
     };
    }

    As you can see, apart from the marking function startTransition, the hook also provides you with access to the isPending flag. You can use it to render the loader or other elements that should be displayed while loading state and waiting for the transition to end:

     return isPending ? <p>Loading...</p> : <ItemsList searchQuery={state} />;

    Concurrent rendering React hooks: useDeferredValue

    The useDeferredValue hook is a variation of useTransition. While useTransition lets you wrap the state-updating function to mark it as a non-urgent one, the useDeferredValue does the same but with the value. The subsequent behavior is the same. So, what's the point of useDeferredValue when we already have useTransition? The most appropriate use cases here are when you don’t have access to state-changing code, e.g., when you use some external API or pass as a prop the value that is the result of the state change. Let’s rewrite the above code snippet with the usage of the deferred value:

    const DeferredStateComponent = () => {
      const [state, setState] = useState();
      const deferredState = useDeferredValue(state);
    
      const handleClick = (state) => {
        // Higher-priority update
        setState(state);
      };
    
      // Lower-priority update
      useEffect(() => {
        executeSlowerOperation("new state")
      }, [deferredState]);
    }

    Concurrent rendering in React: use-cases

    Of course, the above code is only a simple example of how to use hooks. But what are the real-world use cases for concurrent features? One of them is when you switch between showing many components (for example, by choosing the different tabs), and some of them are slower to render. You can check it out in the official React documentation here.

    The other popular use case is when you utilize user input-based state changes, for example, in the search or filter-based components. Typing (setting the input value for the search query) would be more important in terms of speed because you don’t want the user to have the feeling of laggy user input. On the other hand, the results don’t have to appear instantly. It’s even better that they change after the user stops typing. Depending on the number of results, rendering them on every keypress would be very inefficient, especially if based on data fetching. Before React 18, it was common practice to utilize the debouncing functions in such cases, either custom (with setTimout) or imported from libraries such as lodash. The useTransition lets us simplify that; we can reduce the amount of code and get rid of third-party imports. What’s more, you can also use the isPending flag, which lets you render the loader while waiting for the transition to execute.

    So, what can go wrong? Let’s examine that by writing the simple filter component with and without useTransition and comparing the results in terms of performance.

    The real-world example of the React concurrent features: the user input-based filter component

    Let’s start with the component that will render the ItemsList. To simulate the slow component, I added the setTimeout which will delay the filtering by 2 seconds:

    import { useEffect, useState } from "react";
    
    const initialItems = Array.from(
      { length: 1000 },
      (_, index) => `Item ${index}`
    );
    
    const ItemsList = ({ searchQuery }) => {
      const [filteredItems, setItems] = useState([]);
      const [isLoading, setIsLoading] = useState(false);
    
      useEffect(() => {
        setIsLoading(true);
        setTimeout(() => {
          const filtered = initialItems.filter((item) =>
            item.toLowerCase().includes(searchQuery.toLowerCase())
          );
          setItems(filtered);
          setIsLoading(false);
        }, 2000); // Simulating a 2-second delay
      }, [searchQuery]);
    
      return isLoading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {filteredItems.map((item) => (
            <li key={item}>
              {item}
            </li>
          ))}
        </ul>
      );
    };
    
    export default ItemsList;

    Now, let’s add the parent component in which the search query will be typed and updated. For now, we’ll keep it unoptimized:

    import { useState } from "react";
    import ItemsList from "./ItemsList";
    
    const SlowUpdateFilterComponent = () => {
      const [searchQuery, setSearchQuery] = useState("");
    
      const handleSearch = (query) => {
        setSearchQuery(query);
      };
    
      return (
        <div>
          <h1>Slow Update Filter Component</h1>
          <input
            type="text"
            placeholder="Search..."
            value={searchQuery}
            onChange={(e) => handleSearch(e.target.value)}
          />
          // Our slow component
          <ItemsList searchQuery={searchQuery} />
        </div>
      );
    };
    
    export default SlowUpdateFilterComponent;

    Now, let’s try to measure the performance of the components by using Chrome DevTools React Profiler. The action I took was to type the “Input 0” search query and then erase it.

    As we can see in the picture, there were 23 re-renders in total:

    Performance Optimization with React

    Ok, but how many re-renders were very slow? We can check it by hiding the re-renders below some chosen threshold. Let’s say ours is 8 ms:

    Performance Optimization with React - 2

    Performance Optimization with React - 3

    Seven re-renders were slower than 8 ms, so it’s the one third of all re-renders. Not perfect in terms of performance!

    How much could we improve it with the help of concurrent rendering? Let’s see. I rewrote SlowUpdateFilterComponent as follows:

    import { useState, useTransition } from "react";
    import ItemsList from "./ItemsList";
    
    const OptimizedUpdateFilterComponent = () => {
      const [text, setText] = useState("");
      const [searchQuery, setSearchQuery] = useState("");
      const [isPending, startTransition] = useTransition();
    
      const handleSearch = (query) => {
        setText(query);
        startTransition(() => setSearchQuery(query));
      };
    
      return (
        <div>
          <h1>Optimized Update Filter Component</h1>
          <input
            type="text"
            placeholder="Search..."
            value={text}
            onChange={(e) => handleSearch(e.target.value)}
          />
          <div className="items-container">
            {isPending ? (
              <div className="loader">Loading...</div>
            ) : (
              <ItemsList searchQuery={searchQuery} />
            )}
          </div>
        </div>
      );
    };
    
    export default OptimizedUpdateFilterComponent;

    These are the results from Profiler: the first are total re-renders, the second below 8 ms:

    Performance Optimization with React - 4

    Performance Optimization with React - 5

    There are no re-renders higher than 8 ms! So, the performance improved indeed!

    Concurrent rendering caveats

    While the overall performance has been successfully enhanced in the above example, you probably noticed that the number of all re-renders was higher in the example optimized with concurrent rendering. Why? You must remember that when using useTransition you get two re-renders with every single state update. That’s because there is a single re-render for starting the operation (which in the process changes the isPending flag to true) and a second one for finishing it. So, imagine that we somehow manage to remove the 2-second delay from our data setting in the ItemsList component. Then, the re-renders wouldn’t be so slow in the first place. In such a case, using useTransition wouldn’t make much sense.

    What’s worse, it could even make our app less performant, especially if we add some other slow components. Look at this example:

    import { useState, useTransition } from "react";
    import ItemsList from "./ItemsList";
    
    const OptimizedUpdateFilterComponent = () => {
      const [text, setText] = useState("");
      const [searchQuery, setSearchQuery] = useState("");
      const [isPending, startTransition] = useTransition();
    
      const handleSearch = (query) => {
        setText(query);
        startTransition(() => setSearchQuery(query));
      };
    
      return (
        <div>
          <h1>Optimized Update Filter Component</h1>
          <input
            type="text"
            placeholder="Search..."
            value={text}
            onChange={(e) => handleSearch(e.target.value)}
          />
          <div className="items-container">
            {isPending ? (
              <div className="loader">Loading...</div>
            ) : (
              <ItemsList searchQuery={searchQuery} />
            )}
          </div>
          // Additional Slow Component
          <SomeOtherSlowComponent />
        </div>
      );
    };
    
    export default OptimizedUpdateFilterComponent;

    In this case, the fact that useTransition doubles re-renders would be even more troublesome. Remember that in React, when the state of the parent changes, every child is re-rendered. So, when one of them is very slow, the doubling of re-renders would be the last thing we want to do, as it can negatively affect the performance of the entire app. Of course, we can fix it by memoization of SomeOtherSlowComponent or moving the state down, but still, it shows us that we should be cautious when using concurrent rendering.

    Concurrent rendering: final words

    Summing up, concurrent rendering is a powerful change in React that can greatly help us optimize our apps, make the user interface more responsive, and ensure a fluid user experience. Yet, every React developer, even the most advanced one, should remember that it can cause some confusion. As the process of rendering and re-rendering changes when using it, we must implement it with caution, considering if our specific case is appropriate here. We shouldn’t use it for all state updates, as it only makes sense to use it for really slow ones. So, remember to always check if your case is adequate, for example, with the help of performance profilers such as React Profiler. The introduction of concurrent React doesn't make the other ways of optimizing React apps' performance obsolete. Often, it's better to stick to them and avoid unnecessary re-renders in the process.

    Furthermore, as the developers from the React team state in the official docs, transitions and other features available in React 18 are just the beginning of the concurrent React transformation. There are new features planned that will come with the next versions of React and possibly solve some of the aforementioned problems. While waiting for them, we should experiment with concurrent rendering in our React apps, but do it carefully.

    FAQ

    What is Concurrent Rendering in React 18?

    Concurrent Rendering in React 18 introduces new features like automatic batching and transitions to optimize UI performance and user experience. It works under the hood to manage rendering tasks efficiently.

    How does Concurrent Rendering improve performance in React apps?

    It allows React to interrupt non-urgent rendering tasks (using hooks like useTransition), prioritizing more critical updates and improving app responsiveness and user experience.

    What are the main React hooks related to Concurrent Rendering?

    The main hooks are useTransition and useDeferredValue, which help manage updates' priority and execution, enhancing UI performance by treating state updates with different urgencies.

    Can you explain the useTransition React hook?

    useTransition allows marking certain updates as non-urgent. This enables React to perform these updates in the background, thus preventing them from blocking the main thread.

    What is the useDeferredValue hook and how does it differ from useTransition?

    While useTransition wraps a state-updating function, useDeferredValue applies to values directly. Both aim to defer less critical updates to improve responsiveness.

    What are some practical use cases for Concurrent Rendering features?

    They are particularly useful in scenarios involving frequent updates, such as typing in search fields or switching between tabs, where maintaining smooth UI interaction is crucial.

    What are the potential downsides of using Concurrent Rendering in React?

    It can increase the number of re-renders and, if not used judiciously, may lead to performance degradation, especially when combined with other slow components.

    How should React developers approach Concurrent Rendering?

    Developers should use it cautiously, applying it only where it makes sense for performance, and utilizing tools like React Profiler to assess its impact on their apps.

    Curiosum React Developer Marta
    Marta Habdas React Developer

    Read more
    on #curiosum blog