React useTransition: performance game changer or...?
Tags: react, webdev, javascript, tutorial
Published: 2023-10-23 09:17:00


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ptoh866vkfq9dg4kv0bw.jpg)

### Table of Contents

* Let's implement a slow state update
* Concurrent Rendering and useTransition for slow state updates
* The dark side of useTransition and re-renders
* How to use useTransition, then?
* What about useDeferredValue?
* Can I use useTransition for debouncing?

---

Unless you've lived under a rock for the last two years, you've probably heard the magic words "concurrent rendering" here and there. React was rewritten from scratch to support it, it's an entirely new architecture that gives us control over _transitions_ through `useTransition` and `useDeferredValue` hooks, and it's supposed to be a game changer for the performance of our UI interactions. Even Vercel is improving their performance with [transitions](https://vercel.com/blog/how-react-18-improves-application-performance).

But is it truly a game changer? No caveats, for real? Can we use these transitions everywhere? What are they to begin with, and why do we need them? Let's investigate together and find out.

## Let's implement a slow state update

First, let's implement something real with a performance problem. In the [docs](https://react.dev/reference/react/useTransition#updating-the-parent-component-in-a-transition), they use the "tabs" component as an example where `useTransition` is useful, so let's implement exactly that. No copy-pasting, let's do it from scratch!

We'll have an `App` component that renders three tabs (Issues, Projects, and Reports) and conditionally renders the content of those tabs - the list of the latest issues, projects, and reports for our mini-competitor to Jira and Linear. The `App` will hold the state that switches between the tabs and renders the correct component. Easy-peasy so far:

```tsx
export default function App() {
  const [tab, setTab] = useState('issues');

  return (
    <div className="container">
      <div className="tabs">
        <TabButton
          onClick={() => setTab('issues')}
          name="Issues"
        />
        <TabButton
          onClick={() => setTab('projects')}
          name="Projects"
        />
        <TabButton
          onClick={() => setTab('reports')}
          name="Reports"
        />
      </div>
      <div className="content">
        {tab === 'issues' && <Issues />}
        {tab === 'projects' && <Projects />}
        {tab === 'reports' && <Reports />}
      </div>
    </div>
  );
}
```

Now, the problem that transitions should help with: what if one of the pages is very heavy and slow to render? Let's say the `Projects` page renders a list of 100 recent projects, and the components in that list are _very_ heavy and take something like 10ms to mount each. 100 items is not unreasonable, in theory. And although 10ms per component is a bit of a stretch, it could still happen, especially on a slow laptop. As a result, it will take 1 second to mount the `Projects` page.

Play around with the implemented code example here:

{% embed https://codesandbox.io/embed/tabs-with-one-slow-tab-k5mzxn?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

See how it takes _forever_ to render the Projects page when you click the "projects" button? Now try to navigate quickly between those tabs. If I try to navigate from Issues to Projects and then immediately to Reports, I can't do that: the interface is not responsive. This is not exactly the best user experience: even if the Projects page is supposed to be that heavy and I can't optimize it now, the least I should do for the users is not to block the page from other interactions.

This is happening because a state update, despite popular belief, is _not_ _asynchronous_. The triggering of it usually is: we do it asynchronously from various callbacks as a response to user interactions. But once the state update is triggered, React will very synchronously work on calculating all the necessary updates that need to be done, re-render all components that need to be rendered, commit those changes to the DOM so that they can appear on the screen, and only then let the browser go and take notice of what was happening while it was busy.

If we click a tab button during that, the state update from the click will be put in a queue of tasks and will be executed after the main task (the slow state update) is done. You can see this behavior in the console output of the slow example: all re-renders that you trigger via clicking the tabs will be logged, even if the screen was frozen at the time. Tasks and the queue of tasks is how JavaScript is processed by the browser. If you're not exactly sure how it works, I wrote a simple overview of them in this article: [Say no to "flickering" UI: useLayoutEffect, painting and browsers story](https://www.developerway.com/posts/no-more-flickering-ui) with a few more links at the bottom.

## Concurrent Rendering and useTransition for slow state updates

The fact that a typical state update blocks the main task is what [Concurrent Rendering](https://react.dev/blog/2022/03/29/react-v18#what-is-concurrent-react) aims to combat. With it, we can explicitly mark some state updates and the re-rendering caused by them as "non-critical". As a result, React will calculate these updates in the "background" instead of blocking the main task. If something "critical" happens (i.e., a normal state update), React will pause its "background" rendering, execute the critical update, and then either return to the previous task or abandon it completely and start a new one.

"Background" is just a useful mental model here, of course: JavaScript is single-threaded. React will just periodically check the main queue while it's busy with the "background" task. If something new appears in the queue, it will take priority over the "background" work.

But enough words, let's return to writing code. In theory, our situation with one of the tabs being very slow and blocking user interactions is exactly what concurrent rendering can help with. All we need to do is mark the rendering of the Projects page as "non-critical".

We can do this with the [useTransition](https://react.dev/reference/react/useTransition) hook. It returns a "loading" boolean as the first argument and a function as the second. Inside that function, we'll call our `setTab("projects")`, and from that point on, that state update will be calculated in the "background" without blocking the page. Additionally, we can use the `isPending` boolean to add a loading state while we're waiting for that update to finish. To indicate to the user that something is happening.

Just three simple additions to the previous code, as simple as this:

```tsx
export default function App() {
  const [tab, setTab] = useState('issues');

  // add the useTransition hook
  const [isPending, startTransition] = useTransition();

  return (
    <div className="container">
      <div className="tabs">
        ...
        <TabButton
          // indicate that the content is loading
          isLoading={isPending}
          onClick={() => {
            // call setTab inside a function
            // that is passed to startTransition
            startTransition(() => {
              setTab('projects');
            });
          }}
          name="Projects"
        />
        ...
      </div>
      ...
    </div>
  );
}
```

And look how cool this is:

{% embed https://codesandbox.io/embed/example2-tabs-with-one-slow-tab-and-transition-wwmm6z?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

When I click on the "Projects" tab button, the loading indicator shows up, and if I click on "Reports," I'm navigated there immediately. No more frozen interface, total magic!

## The dark side of useTransition and re-renders

Okay, now that the navigation to the Projects page and back is fixed, let's take it a bit further. In real life, any of these tabs could potentially be heavy. Especially the Reports page. I imagine it will have a bunch of very heavy charts there. Why not anticipate this in advance, mark transitions between all of these tabs as non-critical, and update the state inside `startTransition` for all of them?

All I need to do is abstract that transition into a function:

```tsx
const onTabClick = (tab) => {
  startTransition(() => {
    setTab(tab);
  });
};
```

And then use this function on all the buttons instead of setting the state directly:

```tsx
<div className="tabs">
  <TabButton
    onClick={() => onTabClick('issues')}
    name="Issues"
  />
  <TabButton
    onClick={() => onTabClick('projects')}
    name="Projects"
  />
  <TabButton
    onClick={() => onTabClick('reports')}
    name="Reports"
  />
</div>
```

That's it, all state updates originating from these buttons will now be marked as "non-critical," and if both the Reports and Projects pages happen to be heavy, their rendering won't block the UI.

If we play around with the live example below, we'll see that…

I just made the page _**worse**_…

If I navigate to the Projects page and then try to navigate away from it either to Issues or Reports, it doesn't happen instantaneously anymore! I haven't changed anything on those pages, they both render just a string at the moment, but both of them behave as if they are heavy. What's going on?

{% embed https://codesandbox.io/embed/example3-tabs-with-all-transitions-vgd6d9?codemirror=1&hidedevtools=0&view=preview %}

The problem here is that if I wrap the state update in a transition, React doesn't _just_ trigger the state update on the "background". It's actually a two-step process. First, an immediate "critical" re-render with the old state is triggered, and the boolean `isPending` that we extract from `useTransition` hook moves from `false` to `true`. The fact that I'm able to use it in the render output should've been a big clue. Only after that critical "traditional" re-render has finished, will React start with the non-critical state update.

In short, `useTransition` [causes two re-renders](https://github.com/facebook/react/issues/24269) instead of one. As a result, we see the behavior as in the example above. If I'm on the Projects page and click on the Issues tab, first, the initial re-render is triggered with `tab` state still being "projects". The very heavy Projects component blocks the main task for 1 second while it re-renders. Only after this is done will the non-critical state update from "projects" to "issues" be scheduled and executed.

## How to use useTransition, then?

### Memoization to everything

In order to fix the performance degradation from the above, we need to make sure that the additional first re-render is as lightweight as possible. Typically, it would mean that we need to memoize everything that can slow it down:

- all heavy components should be wrapped in `React.memo`, with their props memoized with `useMemo` and `useCallback`
- all heavy operations are memoized with `useMemo`
- `isPending` is not passed as a prop or dependency to anything from the above

In our case, just simply wrapping our page components should do it:

```tsx
const IssuesMemo = React.memo(Issues);
const ProjectsMemo = React.memo(Projects);
const ReportsMemo = React.memo(Reports);
```

They don't have any props, so we can just render them:

```tsx
<div className="content">
  {tab === 'issues' && <IssuesMemo />}
  {tab === 'projects' && <ProjectsMemo />}
  {tab === 'reports' && <ReportsMemo />}
</div>
```

And voila, the problem is fixed, the heavy Projects page re-render doesn't block clicking on tabs anymore:

{% embed https://codesandbox.io/embed/example4-tabs-with-all-transitions-with-memo-rg5kds?codemirror=1&hidedevtools=0&view=preview %}

But this just shows that `useTransition` is definitely not a tool for everyday use: one small mistake in memoization, and you're making your app visibly worse than it was before `useTransition`. And doing memoization properly is actually quite hard. For example, can you say, off the top of your head, whether `IssuesMemo` will re-render here if the `App` re-renders because of the initial transition?

```tsx
const ListMemo = React.memo(List);
const IssuesMemo = React.memo(Issues);

const App = () => {
  // if startTransition is triggered, will IssuesMemo re-render?
  const [isPending, startTransition] = useTransition();

  return (
    ...
    <IssuesMemo>
      <ListMemo />
    </IssuesMemo>
  )
}
```

The answer here is - yep, it will. Re-renders all the way! Issues are not memoized properly. If you're not sure why, here is the video for you: [https://youtu.be/G7RNVYaRS3E](https://youtu.be/G7RNVYaRS3E). It explains what is happening, why, and how to fix it.

### Transition from nothing to heavy

Another way to make sure that this additional initial re-render is as lightweight as possible is to use `useTransition` only when transitioning from "nothing" to "very heavy stuff". A typical example of that might be data fetching and putting that data into the state afterward. I.e. this:

```tsx
const App = () => {
  const [data, setData] = useState();

  useEffect(() => {
    fetch('/some-url').then((result) => {
      // lots of data
      setData(result);
    })
  }, [])

  if (!data) return 'loading'

  return ... // render that lots of data when available
}
```

In this case, if there is no data, we just return a loading state, which is unlikely to be heavy. So if we wrap that `setData` in `startTransition`, the initial re-render caused by this won't be bad: it will re-render it with the empty state and loading indicator.

## What about useDeferredValue?

There is another hook that allows us to tap into the power of concurrent rendering: `useDeferredValue`. It works similarly to `useTransition`, allowing us to mark some updates as non-critical and move them to the "background". It's usually recommended for use when you don't have access to the state update function. When the value is coming from props, for example.

In our case, we would use it if we extract our tabs' content components into their own component and pass the active tab as a prop:

```tsx
const TabContent = ({ tab }) => {
  // mark the "tab" value as non-critical
  const tabDeffered = useDeferredValue(tab);

  return (
    <>
      {tabDeffered === 'issues' && <Issues />}
      {tabDeffered === 'projects' && <Projects />}
      {tabDeffered === 'reports' && <Reports />}
    </>
  );
};
```

However, the problem of double rendering is present here as well. Check it out in the implementation below.

{% embed https://codesandbox.io/embed/example5-tabs-with-deferred-value-vcp66d?codemirror=1&hidedevtools=0&view=preview %}

So the solution here will be exactly the same as for `useTransition`. Mark updates as non-critical only if:

- everything that is affected is memoized;
- or if we're transitioning from "nothing" to "heavy", and never in the opposite direction;

## Can I use useTransition for debouncing?

Another use case that sometimes shows up here and there for `useTransition` is debouncing. When we're typing something fast in an input field, we don't want to send requests to the backend on every keystroke - it might crash our server. Instead, we want to introduce a bit of delay, so that only the full text is sent.

Normally, we'd do it with something like the `debounce` function from lodash (or equivalent):

```tsx
function App() {
  const [valueDebounced, setValueDebounced] = useState('');

  const onChangeDebounced = debounce((e) => {
    setValueDebounced(e.target.value);
  }, 300);

  useEffect(() => {
    console.log('Value debounced: ', valueDebounced);
  }, [valueDebounced]);

  return (
    <>
      <input type="text" onChange={onChangeDebounced} />
    </>
  );
}
```

The `onChange` callback is debounced here, so `setValueDebounced` is only triggered 300ms after I stop typing in the input field.

What if instead of the external library, I use `useTransition`? Seems reasonable enough: setting state inside transitions is interruptible by definition, that's the whole point of it. Will something like this work?

```tsx
function App() {
  const [value, setValue] = useState('');
  const [isPending, startTransition] = useTransition();

  const onChange = (e) => {
    startTransition(() => {
      setValue(e.target.value);
    });
  };

  useEffect(() => {
    console.log('Value: ', value);
  }, [value]);

  return (
    <>
      <input type="text" onChange={onChange} />
    </>
  );
}
```

The answer is: nope, it won't. Or, to be precise, the debouncing effect won't happen in this example. React is just too fast, it's able to calculate and commit the "background" value between keystrokes. We'll see every value change in the console output in this example.

You can compare actual debouncing and the `useTransition` attempt here:

{% embed https://codesandbox.io/embed/example6-debouncing-pdsql4?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

---

That is all for today. Hope it's a bit more clear now what Concurrent Rendering is, what hooks related to it are, and how to use them.

If you are to remember just one single thing from the article, then it's this: concurrent rendering hooks cause double re-renders. So, never use them for all state updates. Their use case is very specific and requires a very deep understanding of the React lifecycle, re-renders, and memoization.

As for me, I probably am not going to use `useTransition` or `useDeferredValue` any time soon. There are too many things to remember to get it right when we're talking about performance. The cost of mistakes is too high: the last thing that I need is to accidentally make performance worse. And for debouncing, it's just too unpredictable. I think I prefer the ["old-school" way](https://www.developerway.com/posts/debouncing-in-react) of doing it.

---

👉🏼 By the way, did you know that I wrote a book about advanced React patterns? It's called [Advanced React](https://advanced-react.com), and it's a must-read for any React developer 😉.

---

Originally published at [https://www.developerway.com](https://www.developerway.com). The website has more articles like this 😉

[Subscribe to the newsletter](https://www.developerway.com), [connect on LinkedIn](https://www.linkedin.com/in/adevnadia/) or [follow on Twitter](https://twitter.com/adevnadia) to get notified as soon as the next article comes out.