Skip to content
Codeloom
React

React useTransition: Advanced Patterns for Responsive UIs

Go beyond basic useTransition usage with patterns for search, navigation, tab switching, and priority-based rendering in React concurrent mode.

·6 min read · By Codeloom
Advanced 11 min read

What you'll learn

  • How useTransition marks updates as non-urgent
  • Patterns for search, filtering, and tab switching
  • Combining useTransition with Suspense for navigation
  • How to use isPending to design responsive loading states
  • Performance tradeoffs and when transitions hurt

Prerequisites

None — this post is self-contained.

React processes state updates synchronously by default. When a state change triggers expensive rendering, the UI freezes until it is done. useTransition marks an update as non-urgent, letting React keep the UI responsive while processing the expensive work in the background. This article covers the patterns that make transitions useful in real applications.

The mental model

useTransition returns [isPending, startTransition]. When you wrap a state update in startTransition, React treats it as a low-priority update. If a higher-priority update comes in (like a keystroke), React interrupts the transition to handle it first, then resumes the transition later.

const [isPending, startTransition] = useTransition();

function handleChange(value: string) {
  // Urgent: update the input immediately
  setInputValue(value);

  // Non-urgent: update the filtered list in the background
  startTransition(() => {
    setFilteredItems(items.filter(item => item.name.includes(value)));
  });
}

The input stays responsive because React never blocks on the filtering work. The filtered list updates slightly after the keystroke, but the delay is imperceptible in practice.

Pattern 1: search with deferred results

Search is the textbook use case. The input field must respond to every keystroke instantly. The search results can lag behind.

'use client';
import { useState, useTransition } from 'react';

type Product = { id: string; name: string; category: string };

export function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(products);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value);

    startTransition(() => {
      if (value === '') {
        setResults(products);
      } else {
        setResults(
          products.filter(
            (p) =>
              p.name.toLowerCase().includes(value.toLowerCase()) ||
              p.category.toLowerCase().includes(value.toLowerCase())
          )
        );
      }
    });
  }

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={handleSearch}
        placeholder="Search products..."
      />
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        {results.map((product) => (
          <div key={product.id}>
            <strong>{product.name}</strong> - {product.category}
          </div>
        ))}
      </div>
    </div>
  );
}

The isPending flag dims the results while they are being recalculated. This gives the user a visual cue that the results are updating without blocking their typing.

Pattern 2: tab switching without flicker

When tabs contain expensive content, switching tabs can freeze the UI. Wrapping the tab change in a transition keeps the current tab visible until the new one is ready.

'use client';
import { useState, useTransition, Suspense, lazy } from 'react';

const AnalyticsTab = lazy(() => import('./AnalyticsTab'));
const SettingsTab = lazy(() => import('./SettingsTab'));
const UsersTab = lazy(() => import('./UsersTab'));

const tabs = {
  analytics: AnalyticsTab,
  settings: SettingsTab,
  users: UsersTab,
} as const;

type TabKey = keyof typeof tabs;

export function Dashboard() {
  const [activeTab, setActiveTab] = useState<TabKey>('analytics');
  const [isPending, startTransition] = useTransition();

  function switchTab(tab: TabKey) {
    startTransition(() => {
      setActiveTab(tab);
    });
  }

  const TabComponent = tabs[activeTab];

  return (
    <div>
      <nav>
        {(Object.keys(tabs) as TabKey[]).map((tab) => (
          <button
            key={tab}
            onClick={() => switchTab(tab)}
            className={activeTab === tab ? 'active' : ''}
            style={{ opacity: isPending ? 0.6 : 1 }}
          >
            {tab}
          </button>
        ))}
      </nav>
      <Suspense fallback={<TabSkeleton />}>
        <TabComponent />
      </Suspense>
    </div>
  );
}

Without the transition, clicking a tab would immediately show the Suspense fallback while the lazy component loads. With the transition, the current tab stays visible. The user sees the tab buttons dim slightly (via isPending) and then the new content appears when ready.

Pattern 3: list filtering with large datasets

When filtering thousands of items, the rendering cost can block the main thread. Transitions let you separate the filter input from the list rendering.

'use client';
import { useState, useTransition, useMemo } from 'react';

export function FilterableTable({ data }: { data: Record<string, string>[] }) {
  const [filter, setFilter] = useState('');
  const [deferredFilter, setDeferredFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  const filteredData = useMemo(
    () =>
      deferredFilter === ''
        ? data
        : data.filter((row) =>
            Object.values(row).some((val) =>
              val.toLowerCase().includes(deferredFilter.toLowerCase())
            )
          ),
    [data, deferredFilter]
  );

  function handleFilter(e: React.ChangeEvent<HTMLInputElement>) {
    setFilter(e.target.value);
    startTransition(() => {
      setDeferredFilter(e.target.value);
    });
  }

  return (
    <div>
      <input value={filter} onChange={handleFilter} placeholder="Filter..." />
      {isPending && <span className="loading-indicator">Updating...</span>}
      <table>
        <tbody>
          {filteredData.slice(0, 100).map((row, i) => (
            <tr key={i}>
              {Object.values(row).map((val, j) => (
                <td key={j}>{val}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Two pieces of state track the filter: filter for the input value (updated immediately) and deferredFilter for the actual filtering (updated in a transition). The useMemo ensures the expensive filter runs only when deferredFilter changes.

Pattern 4: form submissions with server actions

useTransition integrates with Server Actions. Wrapping a server call in startTransition gives you the isPending state for free.

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

export function ProfileForm({ profile }: { profile: { name: string; bio: string } }) {
  const [isPending, startTransition] = useTransition();

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await updateProfile(formData);
    });
  }

  return (
    <form action={handleSubmit}>
      <input name="name" defaultValue={profile.name} disabled={isPending} />
      <textarea name="bio" defaultValue={profile.bio} disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

The form disables itself during submission and shows a saving indicator. No separate loading state needed.

Pattern 5: navigation with stale-while-revalidate

In frameworks like Next.js, route transitions can be wrapped in startTransition to keep the current page visible while the next page loads.

'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  const [isPending, startTransition] = useTransition();
  const router = useRouter();

  return (
    <a
      href={href}
      onClick={(e) => {
        e.preventDefault();
        startTransition(() => {
          router.push(href);
        });
      }}
      data-pending={isPending || undefined}
    >
      {children}
    </a>
  );
}

Style [data-pending] in CSS to show a progress bar or dim the link. The current page remains interactive while the navigation loads.

useTransition vs useDeferredValue

Both tools mark updates as non-urgent. The difference is control. useTransition wraps a state setter, giving you control over when the transition starts and the isPending flag. useDeferredValue wraps a value, deferring its update automatically.

Use useTransition when you control the state update. Use useDeferredValue when you receive a prop and want to defer its effect on rendering.

// useTransition: you control the setter
const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(value));

// useDeferredValue: you defer a prop
const deferredQuery = useDeferredValue(query);
const results = filterItems(deferredQuery);

When transitions do not help

Transitions are not a general performance fix. They help when the bottleneck is rendering, not computation. If your filter function takes 500ms to run, a transition will not make it faster. It will keep the input responsive, but the results will lag by 500ms. In that case, debouncing, virtualization, or moving the computation to a Web Worker is a better solution.

Transitions also add complexity. If your list has 50 items and filtering is instant, wrapping it in a transition adds code for no benefit. Use transitions when you can feel the lag, not preemptively.