FrontendFeb 12, 20269 min read

Async React: Building Non-Blocking UIs with useTransition and useActionState

 

React is a powerful, open-source JavaScript library designed for building user interfaces (UIs), particularly for single-page applications where data changes over time. Developed and maintained by Meta (formerly Facebook), it revolutionized web development by introducing a component-based architecture.

Instead of treating a website as a single document, React allows developers to break the UI into small, isolated pieces of code called components, which can be managed independently and reused throughout an application.

The latest version of the technology, React 19, introduces a suite of new hooks designed to simplify asynchronous operations and form management, moving away from manual state tracking (like drilling-down the isLoading prop) toward more declarative patterns. These new patterns allow the framework to handle "pending" states and form lifecycles natively, reducing boilerplate while keeping the UI responsive. This shift is crucial for building modern, data-intensive applications where providing consistent user feedback is essential but often difficult to manage manually.

In the React 19 ecosystem, an "Action" is a specific technical term that refers to an asynchronous function passed to a transition.

In this guide, we will explore the core hooks enabling these patterns:

  • useTransition: Now natively supports async functions, allowing you to wrap any asynchronous action in a transition to manage pending states without blocking the UI.

  • useActionState: Streamlines the async lifecycle by bundling state management, data results, and automatic pending status into a single, cohesive hook.
     

While useTransition allows the UI to stay responsive during the wait, useActionState ensures that complex data updates remain atomic and sequential.
 


useTransition: Prioritizing UI Responsiveness

In React 18, useTransition was primarily used to keep the UI responsive by marking synchronous state updates (like filtering a list) as non-urgent. For a deep dive into the original React 18 behavior of useTransition for concurrent rendering and UI prioritization, I highly recommend reading the Official React 18 Transition Documentation.

React 19 upgrades useTransition to natively support Async Actions.

Previously, if you tried to use an async function inside startTransition, the "pending" state would resolve the moment the first await was reached. In React 19, the isPending state now stays true for the entire duration of the asynchronous operation. This makes useTransition the primary tool for managing "busy" indicators across your application.

To understand the shift in React 19, let’s compare a standard product search feature. Below, we contrast the Traditional Pattern—which requires manual state tracking—with the new Async Action pattern using useTransition. Notice how the modern approach replaces "state soup" with a natively managed lifecycle.


🚀 Working Example: You can explore the full code and interact with a live demo comparing traditional vs. React 19 patterns here: Filtering Results using useTransition

 

Before (Before React 19)

 

const [searchTerm, setSearchTerm] = useState("");
const [filteredProducts, setFilteredProducts] = useState(products);
// MANUAL LOADING STATE PATTERN:
// Problem: Need separate state to track loading status manually
const [isLoading, setIsLoading] = useState(false);
const getFilteredProducts = async (term) => {
// MANUAL LOADING STATE MANAGEMENT:
// Problem: Must manually set loading to true before async operation
setIsLoading(true);
    try {
       const data = await fetchFilteredProducts(term);
       setFilteredProducts(data);
   } catch (error) {
      console.error("Search failed", error);
  } finally {
   // MANUAL LOADING STATE CLEANUP:
  // Problem: Must remember to manually set loading to false after operation
  // Problem: Risk of forgetting to reset loading state in complex try/catch logic
setIsLoading(false);
  }
};
  const handleSearchChange = (e) => {
    const value = e.target.value;
    setSearchTerm(value);
    getFilteredProducts(value);
  };
  return (
    <div>
     <h3>Traditional Method</h3>
    <div>
      <input
         type="text"
        placeholder="Search products..."
        value={searchTerm}
       onChange={handleSearchChange}
 />
{/* MANUAL LOADING STATE DISPLAY: */}
{/* Problem: Uses manually managed loading state which adds boilerplate */}
{isLoading && <p>Searching Catalog...</p>}
{!isLoading && (
 <ul>
   {filteredProducts.map((product) => (
      <li key={product.id}>{product.name}</li>
      ))}
  </ul>
 )}
</div>
</div>
 );


After (useTransition)

 

const [searchTerm, setSearchTerm] = useState("");
const [filteredProducts, setFilteredProducts] = useState(products);
 // REACT 19 AUTOMATIC PENDING STATE:
// Benefit: isPending is automatically managed by React, eliminating the need for manual 'isLoading' state.
const [isPending, startTransition] = useTransition();
 const handleSearchChange = (e) => {
   const value = e.target.value;
   setSearchTerm(value);
    // REACT 19 ASYNC TRANSITION PATTERN:
    // Benefit: startTransition now supports async functions natively.
    // Benefit: isPending automatically becomes true when the async operation begins.
      startTransition(async () => {
      try {
        const data = await fetchFilteredProducts(value);
        setFilteredProducts(data);
  } catch (error) {
    console.error("Search failed", error);
   }
      // AUTOMATIC PENDING STATE CLEANUP:
     // Benefit: isPending automatically becomes false when the promise resolves.
    // Benefit: State updates inside the transition are bundled and prioritized correctly.   
    });
  };
  return (
   <div>
    <h3>useTransition Example</h3>
   <div>
     <input
        type="text"
        placeholder="Search products..."
        value={searchTerm}
         onChange={handleSearchChange}
   />
        {/* AUTOMATIC PENDING STATE DISPLAY: */}
        {/* Benefit: Uses the built-in isPending state to show feedback without extra boilerplate. */}
        {isPending && <p>Searching Catalog...</p>}
        {!isPending && (
         <ul>
           {filteredProducts.map((product) => (
              <li key={product.id}>{product.name}</li>
             ))}
          </ul>
        )}
     </div>
 </div>
  );


Generally, for standard useQuery or useMutation calls in libraries like Apollo Client or TanStack Query (React Query), useTransition is redundant because these libraries already natively manage the asynchronous lifecycle and provide their own isLoading or isPending states.

  • Imperative Client Calls: When using client.mutate() or client.query() directly inside a function (outside of the standard hook lifecycle).

  • Sequential Mutations: If you need to run three different mutations in a row, useTransition can provide a single isPending state that covers the entire sequence.

  • Manual Cache Updates: When you are performing complex logic to update the Apollo Cache locally after a mutation, wrapping that logic in a transition ensures the UI stays interactive while the cache re-computes.

     

Understanding Parallel Execution and UI Sync

It is important to note how useTransition manages concurrency when multiple updates occur at once. When you trigger several transitions in quick succession, React executes them in parallel rather than waiting for each previous async request to finish. Throughout this entire sequence the isPending state remains collective—it will stay true until the very last action in the chain has settled.

However when isPending tracks the overall duration, React does not natively guarantee sequential consistency for these transitions. They run in parallel, so there is no built-in mechanism to ensure that the order of results matches the order of the requests.

In our Product List example, if you type "ap" rapidly, React fires two separate requests: one for "a" and one for "ap". If the request for "ap" resolves in 500ms but the earlier request for "a" is delayed and takes 5 seconds, the UI state will be updated by the slower "a" request last. Consequently, the screen will display the results for "a" even though your most recent intent was "ap". This behavior can lead to stale data bugs where older results overwrite newer ones.

 

Expanding Beyond Network Calls

It is a common misconception that transitions are only for network requests. In reality, useTransition can provide feedback for any Promise-based operation that happens on the client side:

  • Image Processing: Wrapping a Canvas API operation (like resizing a user's avatar or applying a filter) in a transition so the UI doesn't stutter during heavy pixel manipulation.

  • File Processing: Handling large file reads via the FileReader API or generating a client-side PDF. isPending provides immediate feedback while the browser "crunches" the file data.

  • Web Workers: Keeping the UI responsive while a worker thread processes a heavy CSV or calculates complex physics.

  • Local Storage: Managing the lifecycle of complex disk I/O, such as syncing a local database.


🛠️ Live Demo: See how useTransition handles non-network tasks like saving an editor state in indexDB of browser: Managing Non-Network Async Actions with useTransition


A Crucial Caveat: Handling State After Await

State updates occurring after an await inside startTransition lose their non-blocking status. To maintain the transition context, you must wrap those specific post-await updates in an additional startTransition. The React team expects to automate this behavior in a future release, ensuring the entire async chain remains in context.

Migration Benefits:

Migrating to useTransition replaces manual "state soup" with a natively managed lifecycle. Adopting this hook offers several architectural advantages over traditional isLoading patterns

  • Removes Manual Loading State: No more useState for tracking loading statuses.

  • Simplifies Async Operations: Direct async function support in transitions.

  • Better User Experience: Keeps UI responsive during heavy async operations (Disk, Workers, or Network).

  • Reduced Boilerplate: Significantly less code needed for common async patterns.
     

While useTransition is the ideal tool for "fire-and-forget" scenarios or simple updates where you only need to track the pending lifecycle, it has its limits. If your asynchronous action needs to return specific data—such as server-side validation errors, a success payload, or the result of a calculation—back to the UI, useActionState is the more robust choice. It builds upon the foundation of transitions by integrating state management directly into the action’s execution. 

 

useActionState: Sequential & Atomic Updates

In React 19, useActionState is a hook that takes an Action function and an initial state and returns the current state of that action (the data returned by the function) and a "wrapped" version of the action that you can call. It essentially bundles State Management, Error Handling, and Pending Status into a single line of code.

const [state, formAction, isPending] = useActionState(fn, initialState);

The useActionState hook returns a three-part array that provides complete control over an Action's data and lifecycle. The state variable holds the current value returned by your async function (starting with your initialState and updating every time the action resolves). formAction is the "wrapped" version of your function that you pass to a form's action attribute or trigger via startTransition; it is responsible for capturing the execution and managing the background work. 

Finally, isPending is a built-in boolean that React toggles automatically, remaining true from the moment the action is triggered until the final state update is processed, allowing you to show loading indicators without any manual state logic.

 

Executing the Action: Transitions and Forms

When using useActionState, it is important to understand how to actually trigger the returned action function. To unlock the full power of React 19's lifecycle management, the action function must be called within a Transition context. This happens automatically if you pass the function directly to a form action attribute

However, if you are triggering the action outside of a form (such as from a button onClick or an event handler), you must wrap the call in startTransition. Failing to do so means React won't track the pending state, and your UI won't receive the automatic feedback benefits of the hook.


💡 Working Example: See how React 19 replaces manual state coordination and "stuck spinners" with an atomic lifecycle in this comparative "Like" button demo: Basic Example for useActionState


To understand the shift in React 19, let’s compare a "Like" toggle feature. Below, we contrast the Traditional Pattern—which requires manual state synchronization and lifecycle management—with the new Action pattern using useActionState. Notice how the modern approach replaces "manual coordination" with a natively managed lifecycle.
 

Before (Before React19)
 

// MANUAL STATE PATTERN:|
// Problem: Requires 5 separate states to track a single logical "Like" operation.
// Problem: High risk of these variables getting out of sync with each other.
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(120);
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [isError, setIsError] = useState(false);
const handleToggleLike = async () => {
// MANUAL LIFECYCLE MANAGEMENT:
// Problem: You must remember to reset every state variable manually before starting.
// Problem: If you forget to clear 'isError', the UI might show an old error during a new attempt.
setIsPending(true);
setMessage("");
setIsError(false);
 try {
const newStatus = await toggleLikeInDB(postId, isLiked);
 // MANUAL STATE SYNCHRONIZATION:
// Problem: These are separate renders. If one fails, the UI becomes inconsistent.
// Problem: This is boilerplate logic that must be duplicated for every similar feature.
setIsLiked(newStatus);
setLikeCount((prev) => (newStatus ? prev + 1 : prev - 1));
setMessage(newStatus ? "Liked!" : "Unliked.");
} catch (e) {
// MANUAL ERROR HANDLING:
// Problem: Requires explicit boilerplate to catch and transform errors into state.
setIsError(true);
setMessage("Error: " + e.message);
} finally {
// MANUAL LIFECYCLE CLEANUP:
// Problem: You MUST remember the 'finally' block, or the button stays disabled forever.
// Problem: This "stuck loader" bug is one of the most common issues in traditional React.
setIsPending(false); } };
 return (
<div> <div style={{ margin: "30px" }}>Traditional Way</div>
<div
style={{
padding: "15px",
border: "1px solid #333",
borderRadius: "8px",
minWidth: "250px",
}}
>
<h4>Post Id: 1</h4>
<p>Likes: {likeCount}</p>
 {/* MANUAL PENDING UI: */}
{/* Problem: The disabled state depends on a manual boolean, not the actual transition lifecycle. */}
<button onClick={handleToggleLike} disabled={isPending}>
{isPending ? "Updating..." : isLiked ? "❤️ Unlike" : "🤍 Like"}
</button>
 {/* MANUAL FEEDBACK DISPLAY: */}
{/* Problem: Success and Error logic is handled via fragile string/boolean checks. */}
{message && (
<p style={{ color: isError ? "red" : "green", marginTop: "10px" }}>
{message}
</p>
)}
</div>
</div>
);


After (useActionState)
 

// REACT 19 ACTION STATE PATTERN:
// Benefit: Manages current state, the trigger (formAction), and lifecycle (isPending) in one hook.
// Benefit: 'state' acts as the single source of truth returned by your async logic.
const [state, formAction, isPending] = useActionState(
async (prevState, id) => {
// REACT 19 ASYNC ACTION LOGIC:
// Benefit: Supports async utility calls directly within the Action body.
try {
const newStatus = await toggleLikeInDB(id, prevState.isLiked);
// ATOMIC STATE UPDATE:
// Benefit: likeCount and isLiked are updated in a single transaction, ensuring UI consistency.
return {
isLiked: newStatus,
likeCount: newStatus
? prevState.likeCount + 1 :
prevState.likeCount - 1,
message: newStatus ? "Liked!" : "Unliked.",
isError: false, };
} catch (e) {
// CONSISTENT ERROR HANDLING:
// Benefit: We return the error state directly to the UI without separate error state hooks.
// Benefit: isPending is guaranteed to flip to false even if the API throws an error.
return {
...prevState,
message: "Error: " + e.message,
isError: true, };
}
},
// INITIAL STATE:
{ isLiked: false, likeCount: 120, message: "", isError: false }
);
return (
<div>
<div style={{ margin: "30px" }}>Using useActionState</div>
<div
style={{
padding: "15px",
border: "1px solid #333",
borderRadius: "8px", minWidth: "250px",
}}
>
<h4>Post Id: 1</h4>
<p>
Likes: <strong>{state.likeCount}</strong>
</p>
 {/* REACT 19 ACTION TRIGGER: */}
{/* Benefit: startTransition allows useActionState to track the pending status manually. */}
<button
onClick={() => startTransition(() => formAction(postId))}
disabled={isPending} // AUTOMATIC PENDING STATE
>
{/* AUTOMATIC PENDING UI FEEDBACK: */}
{/* Benefit: isPending is automatically true as long as the async Action is in flight. */}
{isPending ? "Updating..." : state.isLiked ? "❤️ Unlike" : "🤍 Like"}
</button>
 {/* AUTOMATIC STATE FEEDBACK: */}
{/* Benefit: The UI automatically reflects the message or error returned by the latest Action. */}
{state.message && (
<p
style={{
color: state.isError ? "red" : "green",
marginTop: "10px",
}}
>
{state.message}
</p>
)}
</div>
</div>
);


This modern approach is better for three key reasons:

  • Native Lifecycle Sync: isPending is a direct window into the Promise's status. It’s true while running and false when settled. You can't "forget" to turn off a loader.

  • Zero-Boilerplate Cleanup: You no longer need finally { setIsPending(false) }. Removing this boilerplate eliminates the common "stuck spinner" bug.

  • Declarative Logic: You define a transformation (Old State → New State) rather than a procedure (Start → Fetch → Update → Stop). This makes logic easier to test and reason about.


 

The Action Queue: Sequential Consistency

When multiple actions are triggered rapidly, React 19 manages them using an internal action queue. Unlike traditional async patterns where out-of-order responses can cause race conditions, useActionState ensures sequential consistency. Each action waits for the previous one to finish before the next begins, guaranteeing that every action receives the most accurate previousState.

To maintain UI stability, React 19 performs an atomic commitment. While actions run one-by-one in the background, React batches the results and updates the UI only once the entire queue is empty. This prevents the interface from "flickering" through intermediate states, while the isPending flag remains true throughout the entire synchronization process to signal that background work is still settling.

Consider this GameShop example, where a player spends gold on equipment. This is a perfect example for useActionState because each purchase depends on the "dwindling resource" (Gold) where the success of one purchase depends entirely on the outcome of the one before it.


🎮 Working Example: Test the sequential consistency and gold-spending logic yourself in this interactive shop demo: GameShop Action Queue Sandbox


Sequential Consistency: In the GameShop, useActionState ensures sequential consistency to prevent "double-spending" bugs. If a player with 500g rapidly buys a Sword (200g) and then a Shield (150g), the Shield purchase is forced to wait for the Sword's result to correctly calculate the remaining 300g. This logic remains bulletproof even during failures: if a Sword purchase fails because you don't have enough gold, the queue passes that unchanged balance to the next item—like a cheaper Potion—allowing it to succeed based on accurate, real-time data.

Atomic Commitment: To avoid flickering through intermediate gold counts, useActionState uses atomic commitment. It processes every clicked item in the background but waits until the entire queue is empty before updating the screen. This ensures a stable UI that jumps directly to the final state, while our local state (queuedItems) provides instant feedback that each click was registered.
 

Migration Benefits

Migrating your data-heavy interactions to useActionState is about more than just cleaner code; it’s about leveraging React’s internal engine to handle complex state synchronization. This shift from procedural logic (managing variables) to declarative logic (defining state transformations) provides these key benefits:

  • Reliability: Automatic state management eliminates human errors like forgetting to reset a loader or clear an old error.

  • Performance: Built-in optimizations and state batching ensure that UI updates are efficient and non-blocking.

  • Maintainability: Centralizes your state logic within the Action function, making the component easier to read and debug.

  • Future-Ready: Your code is perfectly aligned with React’s architectural direction, specifically for Server Components and concurrent rendering.

  • Developer Experience: Significantly less boilerplate and clearer intent; the code describes what should happen, not how to manage the variables.

  • Testing: It is much easier to test "Actions" as pure functions independently of the UI component.

  • Consistency: Guaranteed state transitions—if an action throws an error, isPending automatically becomes false, and the UI state remains in its last valid state rather than being left in a "broken" half-loaded phase.


 

Embrace New Patterns

The shift to an "Async React" standard with transitions and action states allows developers to eliminate the common "stuck spinner" bugs and manual cleanup logic. These tools provide architectural reliability and performance by centralizing state logic and guaranteeing sequential consistency. Embracing these patterns ensures your application is modular, easy to test, and perfectly aligned with the future of concurrent rendering.

 

Related Articles