React features beyond useState and useEffect that improve user experience

Post on X
Copy URL
Share

React is one of the most widely used libraries in web development. New versions continue to add useful features and components, but some developers may still find themselves using little beyond useState and useEffect, without knowing much about the other options.

This article introduces useful Hooks and components that can improve the user experience. A good user experience matters more than it may seem. If an application simply feels slow, users may stop using it or close the page before giving it a chance. That would be a missed opportunity.

If you are not very familiar with React’s features or have struggled to keep up with its updates, this is a good opportunity to catch up and expand your React toolkit.

Note: Most of the features covered in this article were introduced in React 18, React 19, or later versions.

Avoid making users wait

Fetching and updating data takes time. A loading screen is commonly displayed while an operation is running, but a long wait or a loading indicator that briefly flashes on the screen can make the experience worse.

Show each part as soon as it is ready with <Suspense>

The <Suspense> component displays a fallback until its children have finished loading. By wrapping multiple components in separate <Suspense> boundaries, each part can appear as soon as it is ready, reducing the sense of waiting.

The following example places the user list, user profile, and related articles in separate <Suspense> boundaries. While each section is loading, it displays the spinner specified by fallback. Each section then appears as soon as it is ready.

import { Suspense } from "react";

export const UserPage = () => {
  // ----- Only the relevant sections are shown. -----
  return (
    <div className="loading-suspense">
      <Suspense fallback={<Spinner />}>
        <UserList onClick={onClick} resource={users} />
      </Suspense>
      <Suspense fallback={<Spinner />}>
        <UserProfile resource={currentUser} />
      </Suspense>
      <Suspense fallback={<Spinner />}>
        <RelatedArticles resource={articles} />
      </Suspense>
    </div>
  );
}

In production projects, React is often paired with state management and data-fetching libraries such as Jotai and TanStack Query, or with a framework such as Next.js. Since these tools support Suspense, adding <Suspense> can be surprisingly straightforward.

Even without a Suspense-enabled library, you can use it with React’s use or lazy. For details, see the official documentation, “Suspense – React.”

Lower the priority of state updates with useTransition

Consider a UI that displays the price of a product. Each interaction triggers a price calculation and updates the displayed value. If the calculation takes a little time, frequent changes can make the value update repeatedly and create an awkward experience.

That is where the useTransition Hook can help. State updates executed inside the returned startTransition() function are treated as lower-priority updates. A lower-priority state update can be interrupted by a higher-priority update such as user input. The other value returned by useTransition, the isPending flag, indicates whether the update is still in progress.

The following demo performs both the calculation and the total-price state update inside startTransition(). If the calculation is triggered repeatedly, updates to the total price are interrupted, and the result is shown only after the final calculation finishes. The calculation itself is not skipped; only the state update is interrupted.

export const Transition = () => {
  // ----- Only the relevant sections are shown. -----
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(2700);

  // useTransition Hook
  const [isPending, startTransition] = useTransition();

  const handleQuantityChange = (event) => {
    const nextQuantity = Number(event.target.value);
    setQuantity(nextQuantity);
    startTransition(async () => {
      // Use startTransition for a time-consuming calculation
      const calculated = await calculateTotal(color, size, nextQuantity);
      startTransition(() => {
        // Update state inside startTransition again
        setTotal(calculated);
      });
    });
  };

  return (
    <div className="transition">
      <div className="transition__controls">
        {/** Change the quantity */}
        <NumberInput
          label="Quantity"
          value={quantity}
          onChange={handleQuantityChange}
        />
      </div>

      <dl className="transition__results">
        <dt>Total:</dt>
        {/** Show "calculating..." while pending */}
        <dd>{isPending ? "calculating..." : `¥${total.toLocaleString()}`}</dd>
      </dl>
    </div>
  );
};

Try changing the color, size, and quantity quickly. In the version without useTransition, the number changes repeatedly. In the version that uses it, “calculating…” appears while the calculation is running, and the result is displayed only once when the calculation completes.

Keep the previous display visible during processing with useDeferredValue

The useDeferredValue Hook can be useful in cases similar to those handled by useTransition.

The next demo lets users search for people by name or role. Because the search takes time, the screen switches to a spinner on every keystroke and appears to flicker.

useDeferredValue delays rendering the UI that depends on the value it receives. This rendering can be interrupted. In the demo, the search result resource is passed to useDeferredValue.

Every rapid keystroke still triggers a search, but rendering based on each result can be interrupted. The previous search results remain visible until the latest results are ready, so the spinner does not flicker.

export const Deferred = () => {
  // ----- Only the relevant sections are shown. -----
  const [query, setQuery] = useState("");
  const [searchResource, setSearchResource] = useState(() => searchPeople(""));

  // Keep showing the previous results until the search finishes
  const deferredResource = useDeferredValue(searchResource);
  const isSearching = searchResource !== deferredResource;

  const handleSearchChange = (nextQuery) => {
    setQuery(nextQuery);
    setSearchResource(searchPeople(nextQuery));
  };

  return (
    <div className="deferred">
      <SearchBox onChange={handleSearchChange} value={query} />
      <div className="deferred__list">
        <Suspense fallback={<Spinner />}>
          <SearchResults
            {/** Pass deferredResource */}
            resource={deferredResource}
            isSearching={isSearching}
          />
        </Suspense>
      </div>
    </div>
  );
};

Update the UI before the API finishes with useOptimistic

Simple operations such as “Like” or “Add to favorites” can make an application feel slow if they display a loading screen. The useOptimistic Hook is designed for optimistic updates, where the UI is updated before the API response arrives.

In the following example, the screen updates immediately when the Like button is pressed. The API operation has an intentional one-second delay, so its response arrives after the UI has already changed.

export const Optimistic = () => {
  // ----- Only the relevant sections are shown. -----

  // Store the confirmed like state
  const [liked, setLiked] = useState(false);
  // Store the optimistic like state
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked);
  const [isPending, startTransition] = useTransition();

  const handleLike = () => {
    const nextLiked = !optimisticLiked;
    startTransition(async () => {
      // Apply the optimistic update before saving the like
      setOptimisticLiked(nextLiked);
      const saved = await saveLike(nextLiked);
      // Commit the confirmed value after saving
      setLiked(saved);
    });
  };

  return (
    <>
      <div className="optimistic__controls">
        <button
          disabled={isPending}
          onClick={handleLike}
          type="button"
        >
          <img alt="" src={heart} width="28" height="28" />
        </button>
      </div>
      <div className="optimistic-labels">
        {/** optimisticLiked updates immediately. liked updates after saveLike() resolves. */}
        <p>{isPending ? "Saving..." : "Saved"}</p>
        <p>optimisticLiked: <span>{`${optimisticLiked}`}</span></p>
        <p>liked: <span>{`${liked}`}</span></p>
      </div>
    </>
  );
};

While “Saving…” is displayed below the button, optimisticLiked changes immediately. Once the save completes, liked changes. Without useOptimistic, pressing the button would not update the UI right away, making the application feel slow. If saveLiked() fails, the value reverts to its previous state.

The useOptimistic Hook is used together with a function passed to startTransition() or with form updates.

Preserve user input

After partially filling out a form, closing it, and opening it again, it is disappointing to find that the input has disappeared. In multi-step forms, preserving the values entered in fields that are not currently visible can be helpful.

Previously, this required keeping the values in global state or taking other steps to prevent them from disappearing when the component unmounted.

Preserve state with <Activity>

The <Activity> component allows child components to preserve their state while hidden.

In the following example, the forms wrapped in <Activity> are hidden when mode is set to "hidden". However, the state of the child <ProfileForm> and <QuestionnaireForm> components is preserved, so the entered values remain when the forms are shown again.

import { Activity, useState } from "react";

const Preservation = () => {
  // ----- Only the relevant sections are shown. -----
  const [tab, setTab] = useState("profile");

  return (
    <section className="preservation">
      <Activity mode={tab === "profile" ? "visible" : "hidden"}>
        <ProfileForm />
      </Activity>
      <Activity mode={tab === "questionnaire" ? "visible" : "hidden"}>
        <QuestionnaireForm resource={countries} />
      </Activity>
    </section>
  );
};

const ProfileForm = () => {
  // ----- Only the relevant sections are shown. -----
  // Activity preserves this state value
  const [name, setName] = useState("");

  return (
    <div className="form">
      <TextInput
        onChange={(e) => setName(e.target.value)}
        label="Name"
        value={name}
      />
    </div>
  );
}

Enter a value in the form, then move between the screens using the “Prev” and “Next” buttons. In the version that uses <Activity>, the entered value remains in the form.

Complete expensive work before users see it

The <Suspense> section showed how to reveal the results of expensive work in stages. However, if the work can run before the user needs it, there is no need to show a spinner in the first place.

Display components faster through prerendering

Another useful application of the <Activity> component is prerendering.

With a regular component, the code inside it runs when the component is displayed. A component that includes a slow API call is mounted, waits for the operation to finish, and then appears, while a spinner or another loading indicator is shown. With <Activity>, the component is rendered at a lower priority even while it is hidden. As a result, data fetching with use() and similar work can begin before the component is shown.

In the form shown earlier, retrieving the options for the “Country” field on the second screen takes about one second. The version that uses <Activity> displays the field quickly, while the version without it starts loading only after the user clicks “Next,” resulting in a noticeable wait.

const Preservation = () => {
  // ----- Only the relevant sections are shown. -----

  // Retrieve countries with fetchCountries and pass them to
  // <QuestionnaireForm /> wrapped in <Activity>
  const [countries] = useState(() => fetchCountries());

  return (
    <section className="preservation">
      <Activity mode={tab === "questionnaire" ? "visible" : "hidden"}>
        <QuestionnaireForm resource={countries} />
      </Activity>
    </section>
  );
};

const QuestionnaireForm = ({ resource }) => {
  // Resolve countries with use()
  // Components wrapped in <Activity /> are rendered at a lower priority
  // so they do not interfere with the visible UI
  // The component renders even while hidden, so the countries operation runs
  // By the time <QuestionnaireForm /> becomes visible, countries has already
  // loaded, so the user does not have to wait
  const countries = use(resource);
  const [country, setCountry] = useState("");

  return (
    <div className="form">
      <SelectBox
        label="Favorite Country"
        value={country}
        onChange={(e) => setCountry(e.target.value)}
        list={countries}
      />
    </div>
  );
};

Prerendering should be limited to components that are likely to be shown. If a component is rarely displayed, prerendering it only performs unnecessary work when the user never opens it.

Conclusion

This article covered useful React components and Hooks that can improve the user experience. Combining them effectively makes it possible to build responsive applications that react immediately to user actions and input.

The next article will introduce useful features that improve the developer experience. Stay tuned!

Find ICS MEDIA articles more easily on Google

Add ICS MEDIA as a preferred source to see our articles more often in Top Stories and AI Search.

Add as a preferred source on Google
Share on social media
Your shares help us keep the site running.
Post on X
Copy URL
Share
KITAGAWA Kyoko

Started in apparel and office work, then transitioned into engineering. Joined ICS after full-stack experience in both backend and frontend development. Special skill: English.

Articles by this staff
New articleCreating an RPG-style save point effect with Three.js