Unblocking Transitions In React Components

A Tabbed Panel Component

Imagine a UI element with tab buttons that allow you to switch between different content.

You have tab buttons labeled A, B, and C to switch between contents. If you click tabB and quickly switch to either tab A or C, the UI might not respond. This is because when tab B is clicked, there’s a time-consuming “process” happening within the component TabContentB to display its content.

import { useState } from "react";
import { TabContentA, TabContentB, TabContentC } from "./components/TabContent";
import styles from "./components/Tabbed.module.css";

function App() {
  const [tab, setTab] = useState("a");

  function handleTabChange(tab: "a" | "b" | "c") {
    setTab(tab);
  }

  return (
    <div className={styles.Tabbed}>
      <header>
        <button onClick={() => handleTabChange("a")}>Tab A</button>
        <button onClick={() => handleTabChange("b")}>Tab B ( slow )</button>
        <button onClick={() => handleTabChange("c")}>Tab C</button>
      </header>

      <div>
        {tab === "a" && <TabContentA />}
        {tab === "b" && <TabContentB />}
        {tab === "c" && <TabContentC />}
      </div>
    </div>
  );
}

export default App;

You experience this problem because React state updates are synchronous. If you examine the code, you’ll see that TabContentB renders 1,500 instances of a component named SlowComponent, which has a time-consuming loop.

Here’s a helpful resource to understand the synchronous and asynchronous nature of JavaScript.

Let’s write down the steps:

  1. When you click the tabB button, setTab(“b”) triggers a re-rendering of our app.
  2. The new tab will be “B”, and TabContentB will be displayed.
  3. However, the for-loop in TabContentB begins, and a <SlowComponent/> is pushed into the array. Keep in mind that our slow component takes 1 ms to complete its work. This step takes time and causes our app to block. The ongoing loop can’t be interrupted even if the user wants to initiate another interaction. Our interface becomes unresponsive. The loop proceeds to execute the remaining steps in the same manner.
  4. Once the for-loop in TabContentB completes and a JSX array of 1,500 SlowComponent is generated, TabContentB finishes, and our app can be rendered with the new “b” tab state.

To address this issue, React provides a hook named useTransition that helps maintain a responsive UI by unblocking time-consuming tasks to allow user actions (like clicks) to continue seamlessly.

useTransition hook

useTransition allows us to mark state changes that we think might cause blocking and make our interface laggy, like the setTab setter function in our example, as non-critical. This enables the state changes to be interruptible. So, if the user decides to cancel and initiate another action, the ongoing operation can be stopped, and the new request can be executed.

This hook returns an array that has isPending as the first item and a function that you can use to mark any state update you want as non-critical.

import {useTransition} from "react";
  export default function App(){
    //isPending, is a conventional name used in react docs. Its value can be true or  false. 
    // startTransition ( another conventional name ), is the  function that we will keep our *non-critical* state setter function inside its callback argument
    const [isPending, startTransition] = useTransition();
  }

Let’s refine our code with useTransition

import { useState, useTransition } from "react";
import { TabContentA, TabContentB, TabContentC } from "./components/TabContent";
import styles from "./components/Tabbed.module.css";

function App() {
  const [tab, setTab] = useState("a");
  const [ isPending,  startTransition ] = useTransition();

  function handleTabChange(tab: "a" | "b" | "c") {
    startTransition( ()=>{  setTab(tab);  } )
   
  }

  return (
    <div className={styles.Tabbed}>
      <header>
        <button onClick={() => handleTabChange("a")}>Tab A</button>
        <button onClick={() => handleTabChange("b")}>Tab B ( slow ) {isPending && "pending..."}</button>
        <button onClick={() => handleTabChange("c")}>Tab C</button>
      </header>

      <div>
        {tab === "a" && <TabContentA />}
        {tab === "b" && <TabContentB />}
        {tab === "c" && <TabContentC />}
      </div>
    </div>
  );
}

export default App;

We’ve updated our code with the startTransition function from the useTransition hook. Now, when you click the tab B button, it begins processing the time-consuming loop, but you can switch to other tabs without getting blocked.

This seems to solve the problem. However, there’s a new issue. If you click tab B and wait for its content to fully load, then try switching to another tab, there’s still a noticeable delay. This happens because useTransition ends up rendering TabContentB twice due to its approach to managing transitions.

Need for Memoization…

To mitigate the slowdown, we can use React’s memo function to wrap our slow component and avoid unnecessary re-renders unless the props change.

import {memo} from "react";
  export const TabContentB = memo(function TabContentB() {
  console.log("render");
  const heavyContent = [];
  for (let i = 0; i < 1500; i++) {
    heavyContent.push(<SlowComponent key={i} index={i} />);
  }

  return <>{heavyContent}</>;
});

!!! --- This document is not yet complete. --- !!!