Keep the React app responsive even during large screen updates with startTransition API introduced in React 18


The most significant update of React 18 comes with concurrent rendering. Concurrency is the ability to execute multiple tasks simultaneously by prioritizing the tasks. This concept is explained nicely by Dan Abramov with a simple analogy of phone calls.

React 18 exposed a few APIs to allow users to have some control over concurrency. One of them is the startTransition API, which indicates that the actions wrapped in startTransition may take time.

Let’s explore in detail the startTransition API.

Sometimes we come across applications that become unresponsive due to heavy or complex operations.

Consider an example of searching a photo from a large list. When we type the photo name in the search input, we expect the typed character to appear on the screen without any delay. It becomes frustrating when we don’t see the characters quickly and, the first reaction which comes up in our minds is “Ahh, this app is so slow!”

Why is the search a bit laggy?

Let’s see the code.

// SearchPhotos.jsx

import React, { useState } from "react";
import PhotoCard from "./PhotoCard";
import {
  Container, TextField
} from "@material-ui/core";
import { makeStyles } from "@material-ui/styles";

const SearchPhotos = () => {
  const useStyles = makeStyles({
    container: {
      marginTop: '100px',
    }
  });
  const [title, setPhotoTitle] = useState("");
  const classes = useStyles();

  const onChange = (e) => {
    // Urgent update
    setPhotoTitle(e.target.value);
  };

  return (
    <Container className={classes.container}>
      <TextField id="standard-basic" label="Search by photo title" onChange={onChange} value={title}/>
      <PhotoCard searchParam={title} />
    </Container>
  );
};

export default SearchPhotos;

//PhotoCard.jsx

import React, { useEffect, useState } from "react";
import photosJson from "./photos.json";
import PhotoListCard  from './PhotoListCard'

const PhotoCard = React.memo(({ searchParam }) => {
  const [photos, setPhotos] = useState();

  const fetchPhotos = (title) => {
    return new Promise((res) => {
      setTimeout(() => {
        if (!title) {
          return res(photosJson);
        }
        return res(photosJson.filter((photo) => photo.title.includes(title)));
      }, 500);
    });
  };

  useEffect(() => {
    fetchPhotos(searchParam).then((res) => {
      // Non-urgent update
      setPhotos(res);
    });
  }, [searchParam]);

  const photoData = photos?.map((p) => ({
    key: p.id,
    name: p.title,
    thumbnailUrl: p.thumbnailUrl
  }));

  return <PhotoListCard data={photoData}  />;
});

export default PhotoCard;

Whenever the user types a photo name in the search, two different updates need to happen.

First, we save the typed value in the state.

 const onChange = (e) => {
    // Urgent update
    setPhotoTitle(e.target.value);
  };

Then, we use the stored value to search for photos.

 useEffect(() => {
    fetchPhotos(searchParam).then((res) => {
      // Non-urgent update
      setPhotos(res);
    });
  }, [searchParam]);

The first update is an urgent one, to change the value of the input field and display the typed character. The second one is to show the results.

Until React 18, all the updates were treated as urgent. Though the user assumes that showing search results may take time, both the updates would be rendered at the same time. It would block the user from seeing the feedback making it feel a bit unresponsive.

Priorizing updates with the startTransition API to improve user interaction

The new startTransition API helps to categorize the updates as urgent and non-urgent. The events like click, select, etc., which need an immediate response, should be treated as urgent ones. Other updates like displaying search results, text highlighting, etc., which are not expected to be immediate can be marked as transition or non-urgent. This can be done by wrapping the transition into startTransition.

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

Can we use setTimeout instead of the startTransition API?

We would think of using setTimeout to delay the search result as below -

  const onChange = (e) => {
    const value = e.target.value
    setPhotoTitle(value);
    setTimeout(() => {
      setSearchQuery(value);
    }, 0);
  };

Debouncing and throttling are other techniques that could also be used.

Let’s see the advantage of using startTransition over other options.

Unlike setTimeout, startTransition is not scheduled for later. The function passed to startTransition runs synchronously, but any updates inside of it are marked as ‘transitions’. Based on this information React decides how to render the update.

React starts rendering the updates earlier than if it were wrapped in setTimeout. On a fast device, there would be a little delay between the two updates. Whereas on a slow device the delay would be larger, but the UI would remain responsive.

How to handle the pending transition?

To inform the user about the ongoing background work, React provides isPending flag, allowing us to show a spinner while the user waits.

We can modify our SearchPhotos component as below to show the spinner -

const SearchPhotos = () => {
  const useStyles = makeStyles({
    container: {
      marginTop: '100px',
    }
  });
  const [title, setPhotoTitle] = useState("");
  const [searchQuery, setSearchQuery] = useState("");
  const [isPending, startTransition] = useTransition();
  const classes = useStyles();

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

  return (
    <Container className={classes.container}>
      <TextField id="standard-basic" label="Search by photo title" onChange={onChange} value={title}/>
      {isPending && <LinearProgress/>}
      <PhotoCard searchParam={searchQuery} />
    </Container>
  );
};

We found some interesting discussions on the below topics -

  1. Reason for including React.startTransition and useTransition
  2. Batching inside startTransition

To know more about the startTransition API, check out the WG discussion and a quick overview on the behavior of startTransition API.