DEV Community

Cover image for useTransition hook in React/Next.js
Agbo, Daniel Onuoha
Agbo, Daniel Onuoha

Posted on

useTransition hook in React/Next.js

Mastering React's useTransition: The Secret to Non-Blocking UIs

In the past, we focused on making code run faster. Today, with React 18+ and Next.js, we focus on keeping the interface responsive, even when the computer is working hard. If you aren't using useTransition, you risk freezing your user's browser while data processes—a sure way to kill user experience.

Here is a deep dive into useTransition, how it creates that "illusion" of speed, and how to implement it.

The Problem: The "Frozen" UI

To understand the solution, we must first understand the problem. In standard React rendering (pre-18), all updates were treated equally.

Imagine a user types into a search bar that filters a list of 10,000 items.

  1. User types "A".
  2. React updates the input value.
  3. React calculates the filtered list.
  4. React renders the list.

If step #3 takes 500ms, the screen effectively freezes for that half-second. The user tries to type "B", but the input box won't reflect it until the list finishes rendering. This is Blocking Rendering.

The Solution: Concurrent Rendering

React 18 introduced the concept of Concurrency. It allows React to interrupt a heavy render to handle something more important (like a keystroke) and then go back to the heavy work.

useTransition is the API that lets you tell React: "This update is heavy and non-urgent. Do it in the background, but keep the UI responsive for the user."

Urgent vs. Non-Urgent Updates

React classifies state updates into two categories:

  • Urgent: Direct interaction (typing, clicking, pressing). These need immediate feedback.
  • Transition (Non-Urgent): UI transitions (switching views, filtering lists, saving data). These can wait a few milliseconds without the user noticing.

How to Implement useTransition

The hook returns an array with two values:

  1. isPending: A boolean telling you if the transition is currently active (useful for loading spinners).
  2. startTransition: A function that wraps your state update.

Scenario 1: The Client-Side Filter (React)

Instead of blocking the user's typing, we mark the list filtering as a "transition."

import { useState, useTransition } from 'react';

export default function SearchList({ items }) {
  const [query, setQuery] = useState("");
  const [list, setList] = useState(items);

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

  const handleChange = (e) => {
    const value = e.target.value;

    // 1. Urgent Update: Update the input field immediately
    setQuery(value);

    // 2. Transition Update: Filter the list in the "background"
    startTransition(() => {
      const filtered = items.filter(item => item.includes(value));
      setList(filtered);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />

      {/* Optional: Show a subtle loading state while the list processes */}
      {isPending && <p>Updating list...</p>}

      <ul>
        {list.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Scenario 2: Server Actions (Next.js)

This is the specific use case mentioned in your image ("upload silently behind the scenes"). In Next.js, useTransition is essential for invoking Server Actions without blocking the client.

It prevents the UI from freezing while the server processes the database request, and it allows you to display a loading state specifically for that action.

'use client';
import { useTransition } from 'react';
import { updateProfile } from './actions'; // A Server Action

export default function ProfileForm() {
  const [isPending, startTransition] = useTransition();

  const handleSubmit = (formData) => {
    // The UI remains interactive while this runs!
    startTransition(async () => {
      const result = await updateProfile(formData);
      if (result.error) {
        alert("Failed to save");
      }
    });
  };

  return (
    <form action={handleSubmit}>
      <input name="username" type="text" />

      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Update Profile"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The "Illusion" of Immediate Updates

The image mentions giving users the "illusion that the data was updated immediately."

While useTransition handles the pending state, to truly achieve the illusion of instant data updates (before the server even responds), you often pair useTransition with useOptimistic.

  1. User clicks "Like".
  2. useOptimistic immediately switches the heart icon to red (The Illusion).
  3. useTransition manages the background request to the server.
  4. Server responds: If successful, the real data syncs. If it fails, the UI reverts.

This combination creates the "speed and clean user experience" described in the post.

When NOT to use useTransition

Do not use useTransition for controlled inputs.

If you wrap setInputValue(e.target.value) inside a startTransition, the input field will feel laggy. The user will type, but the letters will appear with a slight delay because you told React that updating the text box is "non-urgent."

Rule of Thumb:

  • Input/Typing: Urgent (Do NOT use startTransition).
  • Resulting Data/Fetching: Non-Urgent (Use startTransition).

Summary

  • Performance: useTransition stops heavy rendering or network requests from freezing your app.
  • UX: It allows the user to keep interacting with the page while work happens in the background.
  • Next.js: It is the standard way to handle loading states for Server Actions.

If you aren't using it yet, you aren't utilizing the full power of the React engine.

Top comments (0)