FrontendApr 27, 2026 11 min read

Async React: Streamlining UX with useOptimistic, use API, and useFormStatus


 

 

In a previous article, we explored how React 19’s Actions—specifically useTransition and useActionState—transformed background processing. Transitions keep the UI responsive, but users still face a latency gap while waiting for server responses. React 19 addresses this gap by moving away from manual state orchestration toward an architecture that either masks perceived latency with predictive updates or streamlines the wait through declarative resource handling.

Managing the in-between states of an application—the moments between a user's click and the server's response—has historically required a lot of boilerplate code. Whether it was passing loading props down multiple levels or manually rolling back state when an API call failed, the friction was real for both developers and users.

React now provides a more declarative way to handle these scenarios. In this guide, we will explore the core APIs that enhance perceived performance and resource access. These include:

  • useFormStatus: Enables nested components to check if their parent form is currently submitting, perfect for disabling buttons or showing spinners without passing props.

  • useOptimistic: Lets you update the UI immediately with an "optimistic" value before a server request finishes, reverting automatically if the request fails.

  • use(): A versatile new API for reading Promises or Context directly in render; it is unique because it can be used inside if statements and loops.


useOptimistic: Predictive UI and Automatic Rollbacks

The useOptimistic hook is a React 19 API designed to provide a perceived zero-latency experience. It allows you to update the UI optimistically before an asynchronous operation (like a server request) even finishes. By predicting the success of an action, your app feels instantaneous while the real data synchronization happens quietly in the background.
 

const [optimisticState, addOptimistic] = useOptimistic(passthroughState, updateFn);


The hook returns a two-part array. The optimisticState is the version of your data currently being displayed to the user—it will show the "predicted" result while an action is pending and the "real" result once it settles. addOptimistic is the function you call within a transition to trigger that prediction. Crucially, as soon as the real state update resolves, React automatically discards the optimistic value and synchronizes the UI with the actual source of truth.


Working Example: See how useOptimistic eliminates manual "Snapshot & Rollback" boilerplate compared to traditional methods in this live Todo list: useOptimistic Todo Sandbox.

In a Todo list, waiting for database confirmation makes the app feel sluggish. Below, we contrast the manual "Snapshot & Rollback" pattern with the modern useOptimistic approach.

Before - Traditional Way

 

  const [todos, setTodos] = useState([]);
  const handleAddTodo = async (event) => {
    event.preventDefault();
    const title = new FormData(event.target).get("title");
    event.target.reset();
    // MANUAL OPTIMISTIC UPDATE:
    // Benefit: Provides immediate feedback
    // Problem: We are manually duplicating data and creating a "pseudo-state"
    // that isn't yet confirmed by the server.
    const todo = { id: Date.now(), title };

    // Save current state for potential rollback
    const previousTodos = [...todos];

    // Step 1: Manually update UI immediately
    setTodos((prev) => [...prev, { ...todo, sending: true }]);

    try {
      // Step 2: Fire mutation
      const savedTodo = await saveTodoToDatabase(todo);

      // Step 3: Success - Manually swap the "temp" item with the "real" server item
      setTodos((current) =>
        current.map((t) => (t.id === todo.id ? savedTodo : t))
      );
    } catch (error) {
      // MANUAL ROLLBACK PATTERN:
      // Problem: Developer is 100% responsible for "undoing" the UI change.
      // If you forget this block, the UI stays out of sync with the database forever.
      setTodos((current) => current.filter((t) => t.id !== todo.id));
      console.error("Failed to save todo", todo);
    }
  };

  return (
    <div style={{ padding: "20px", maxWidth: "400px" }}>
      <h2>My Tasks (Manual Optimistic)</h2>
      <form onSubmit={handleAddTodo}>
        <input name="title" placeholder="What needs to be done?" required />
        <button type="submit">Add</button>
      </form>

      <ul>
        {/* MANUAL STATE DISPLAY:
            Problem: Requires custom 'sending' flags to track in-flight items.
            The logic for what is "real" vs "temporary" is spread across the component. */}
        {todos.map((todo) => (
          <li key={todo.id} style={{ color: todo.sending ? "#888" : "#000" }}>
            {todo.title} {todo.sending && " (Saving...)"}
          </li>
        ))}
      </ul>
    </div>
  );


After - React 19 useOptimistic Way
 

 const [todos, setTodos] = useState([]);

  // REACT 19 OPTIMISTIC STATE PATTERN:
  // Benefit: Immediate UI feedback with automatic rollback on errors
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, todo) => {
      if (state.findIndex(({ id }) => id === todo.id) === -1) {
        return [...state, { ...todo, sending: true }];
      }
      return state;
    }
  );

  const handleAddTodo = async (event) => {
    event.preventDefault();
    const title = new FormData(event.target).get("title");
    event.target.reset();

    // REACT 19 TRANSITION PATTERN:
    // Benefit: Simpler async handling and marks update as non-urgent
    startTransition(async () => {
      const todo = { id: Date.now(), title };

      // REACT 19 OPTIMISTIC UPDATE:
      // Benefit: Immediate UI feedback before server confirmation
      addOptimisticTodo(todo);

      try {
        // Background sync with server
        const savedTodo = await saveTodoToDatabase(todo);

        // Update real source of truth once server confirms
        setTodos((current) => [...current, savedTodo]);
      } catch (error) {
        // Error handling: useOptimistic will automatically
        // revert the state to 'todos' upon transition failure.
        console.error("Failed to save todo");
      }
    });
  };

  return (
    <div style={{ padding: "20px", maxWidth: "400px" }}>
      <h2>My Tasks (useOptimistic)</h2>
      <form onSubmit={handleAddTodo}>
        <input name="title" placeholder="What needs to be done?" required />
        <button type="submit">Add</button>
      </form>

      <ul>
        {/* OPTIMISTIC STATE DISPLAY:
            Benefit: Shows immediate feedback on user interaction */}
        {optimisticTodos.map((todo) => (
          <li key={todo.id} style={{ color: todo.sending ? "#888" : "#000" }}>
            {todo.title} {todo.sending && " (Saving...)"}
          </li>
        ))}
      </ul>
    </div>
  );


useOptimistic is perfect for low-stakes UI updates like adding items to a list or toggling a "Like" button. Unlike the gold balance in our GameShop (which requires strict sequential accuracy), a Todo list allows for perceived performance. The user perceives the app as lightning fast because the UI responds at the speed of a click, while the data integrity logic follows at its own pace.

This modern approach is an improvement for the following reasons:

  • Addition of Native Rollback: The most difficult part of optimistic UI is the undo logic. useOptimistic handles this natively; if the transition fails, the temporary state is simply discarded.

  • Eliminates Snapshot Boilerplate: You no longer need to manually save previous state versions or map through arrays to swap temporary IDs with real ones.

  • Maintains State Integrity: React acts as the arbiter between your prediction and reality, ensuring the UI never gets stuck in a state that doesn't match the database.


How useOptimistic works internally

To truly master useOptimistic, we need to understand how React 19 manages state priorities using a system called Lanes. Lanes act as different priority tracks for updates, ensuring that high-priority interactions (like typing) aren't blocked by lower-priority background work.

When you trigger an optimistic update, React performs a sophisticated multi-stage operation across these Lanes:

  1. Immediate Feedback (Sync Lane): React immediately schedules an update in the Sync Lane. This is the highest priority track, ensuring the UI reflects your predicted state (e.g., the added Todo) in the very next frame.

  2. Queueing and Reversion: Simultaneously, React adds the update to a Pending Action Queue and schedules a cleanup update in the Revert Lane—a low-priority transition lane.

  3. The Pending State: As long as the transition is in flight, React intentionally ignores the Revert Lane. It continues to prioritize the optimistic state from the queue, giving the user a seamless experience.

  4. Final Reconciliation: Once the async transition completes, React clears the pending queue and processes the scheduled Revert Lane update. During this final render, useOptimistic ignores the (now empty) pending updates and returns only the finalized Source of Truth.

     


Concurrency and the Persistent Queue

Consider our TodoList example when a user adds ‘Item A’ and then ‘Item B’ in rapid succession. Initially, both appear in the "Saving..." state. When ‘Item A’ is confirmed, the Source of Truth updates to include it, but the transition is still considered pending because ‘Item B’ is still in flight. In the list, Item A should appear confirmed while ‘Item B’ remains in the pending state.

In this case, because a transition is still pending, React calculates a new optimistic state by taking the new Source of Truth and re-running all pending actions in the queue against it. You might wonder why we need the logic to skip items in the reducer; the reason is that even though Item A is already saved, it remains in the Pending Queue. This is because the queue is only flushed once the entire batch of transitions completes. The findIndex logic allows the reducer to detect that Item A is already present in the new Source of Truth and skip it, preventing Item A from appearing twice—once from the real state and once from the optimistic queue—while Item B continues to load.

Try it yourself; If you remove the findIndex check, you will see ‘Item A’ duplicate in the list the moment the server confirms it, only returning to a single entry once ‘Item B’ also completes and the queue finally clears.

Summary Table: How React calculates the UI
 

Scenario What React does internally Result
Normal Render No pending updates? Just return the passthrough. UI = Real Data (if no pending action)

UI = Optimistic Updated Data (if actions are pending)
New Optimistic Action Add update to queue; run reducer once. UI = Real Data + Optimistic Change
Real Data or Source of Truth Changes Re-run reducer using the new data as the starting point and all pending actions UI = New Real Data + Optimistic Change
Transition Finishes Remove updates from the queue; stop calling reducer. UI = Real Data

Migration Benefits

 

  1. Reliability: Automatic rollback eliminates manual error state management and prevents UI inconsistencies.
  2. Performance: Immediate UI updates provide instant feedback without waiting for server responses.

  3. Maintainability: Centralized optimistic update logic, easier to debug state transitions.

  4. Future-Ready: Aligned with React's concurrent features and Suspense architecture.

  5. Developer Experience: Less boilerplate for optimistic updates, clearer intent than manual state juggling.

  6. Testing: Easier to test optimistic behavior with predictable state transitions.

  7. Consistency: Guaranteed state synchronization—if async operation fails, state automatically reverts to previous valid state.
     


use: Reading Resources Declaratively

The use API is a new addition to React 19 that allows you to read the value of a resource—typically a Promise or Context—directly within the render phase. While its name follows the "use" convention, it is technically an API, not a hook, because it can be used inside loops and conditional statements.

Key features of the use API include:

  • Promise Reading: Directly read Promise values in components without manual state management

  • Context Reading: Alternative way to read Context values with conditional support

  • Suspense Integration: Automatically suspends components while Promises are pending

  • Error Boundaries: Throws errors that can be caught by Error Boundaries

  • Conditional Usage: Unlike other hooks, use can be called conditionally and in loops

  • Declarative Reading: Directly consumes stable, pre-cached promises during the render phase to avoid redundant fetches.
     

// Reading a Promise
const data = use(fetchUserData(userId));

// Reading Context conditionally
if(condition){
  const theme = use(ThemeContext);
}


Traditionally, fetching data required a combination of useEffect to trigger the fetch and useState to manually track the data, loading status, and errors. When a Promise is passed to use, the component suspends until the Promise resolves, then renders with the resolved value. If the Promise rejects, the error is thrown and can be caught by an Error Boundary.

Imagine a dashboard with three tabs: Overview, Statistics, and Permissions. We want a single loading spinner and error handler at the top level, but we want each tab component to define its own data source.

📊 Working Example: Compare the traditional "State Soup" pattern with the declarative React 19 resource model use API in this multi-tab dashboard demo: Making a dashboard with multiple tabs using use API

Before - Traditional Way

 

// MANUAL STATE PATTERN:
// Problem: Every tab needs its own loading/error booleans.
// Problem: If you want a single spinner at the top, you have to "lift" 
// all loading states up to the parent, creating "Prop Drilling" nightmares.

const Dashboard = () => {
  const [activeTab, setActiveTab] = useState('overview');
  
  // Traditional apps often end up with "State Soup" at the top level
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  return (
    <Layout>
       <Tabs onChange={setActiveTab} />
       {loading ? (
        <Spinner />
      ) : (
        <TabContent
          activeTab={activeTab}
          setLoading={setLoading}
          setError={setError}
        />
      )}
    </Layout>
  );
};


After - React 19 use way
 

// REACT 19 RESOURCE PATTERN:
// Benefit: Individual components define their data, but parents define the UX.
// Benefit: No manual 'isLoading' or 'hasError' states in any component or passing of setter function to handle common loading and error

const Dashboard = ({ overviewPromise, statsPromise, permsPromise }) => {
  const [activeTab, setActiveTab] = useState('overview');

  return (
    <div className="dashboard">
      <Tabs active={activeTab} onChange={setActiveTab} />

      {/* GLOBAL BOUNDARIES: Handles any tab that is "Pending" or "Failed" */}
      <ErrorBoundary fallback={<ErrorMessage />}>
        <Suspense fallback={<SkeletonLoader />}>
          
          {activeTab === 'overview' && <OverviewTab dataPromise={overviewPromise} />}
          {activeTab === 'stats' && <StatsTab dataPromise={statsPromise} />}
          {activeTab === 'perms' && <PermissionsTab dataPromise={permsPromise} />}
          
        </Suspense>
      </ErrorBoundary>
    </div>
  );
};

const StatsTab = ({ dataPromise }) => {
  // REACT 19 API: 
  // 1. This component "suspends" here until the promise is resolved. 
  // 2. React will automatically look up the component tree for the 
  //    nearest <Suspense> boundary to show a fallback (like a 
  //    SkeletonLoader) while this promise is pending.
  const stats = use(dataPromise); 

  return <StatsGrid data={stats} />;
};


The modern React 19 approach is better because it replaces "State Soup" and messy useEffect boilerplate with a declarative resource model. By using the use API, you eliminate the need to manually track loading and error booleans or write complex cleanup logic to prevent race conditions. Instead, React natively orchestrates the UI through Suspense and Error Boundaries, ensuring that the dashboard remains performant and synchronized even if a user switches tabs rapidly.

Migration Benefits:

  • Reliability: Automated error handling, consistent loading states, and prevention of race conditions

  • Performance: Fewer re-renders, request deduplication, and optimized Suspense

  • Maintainability: Simpler, more declarative code with easier testing

  • Future-Ready: Aligns with React's concurrent features, Server Components, and streaming SSR

  • Developer Experience: Intuitive API, better DevTools, and a simplified mental model

  • Testing: Predictable behavior, easier error testing, and mock-friendly

  • Consistency: Unified async patterns for loading, errors, and resource management
     


useFormStatus: Accessing Form Lifecycle from Child Components

The useFormStatus hook is a React 19 API that allows components nested deep within a <form> to access information about that form's submission status (such as pendingdatamethod, and action). Its primary purpose is to eliminate "Prop Drilling" by allowing sub-components—like a submit button or a status message—to react to a form's lifecycle without the parent form needing to pass down state explicitly.

const { pending, data, method, action } = useFormStatus();


The useFormStatus hook does not take any parameters. It returns a status object containing four specific properties: pending (a boolean that is true if the parent form is currently submitting), data (a FormData object containing the information currently being processed), method (a string indicating the HTTP method, such as 'GET' or 'POST'), and action (a reference to the function passed to the parent form’s action prop). Because this hook relies on the internal React Form context, it must be called from a component that is rendered inside a <form> to return meaningful data.

While most React hooks reside in the core react package, useFormStatus is located in react-dom because it is strictly coupled to the web’s Document Object Model. Since the hook specifically interacts with HTML <form> elements and the browser's native FormData API, it belongs in the platform-specific renderer rather than the platform-agnostic core library used by environments like React Native.

But here’s an important constraint to consider: useFormStatus will only return status information for a component that is rendered inside a <form> (or a component using the formAction attribute). It behaves similarly to a specialized Context provider that React manages automatically for every form.


📝 Working Example: Compare the complexity of manual prop-drilling with the self-aware React 19 form status pattern in this live demo: Username Form using useFormStatus

Consider the example where you have a form to take input of username for the user and you give a feedback to the user if form submission is pending. In traditional React, if a submit button needs to know both the loading status and the value of an input (like the username), the parent must manage multiple states and drill them all down manually.

Before - Traditional way
 

// TRADITIONAL PROP DRILLING PATTERN:
// Problem: Requires "State Soup"—managing both 'isSaving' and 'username' states in the parent.
// Problem: Manual syncing—you must use an 'onChange' handler just to keep the username available for the button.
// Problem: Heavy Prop Drilling—the parent must pass multiple props down the tree to the button.

const SubmitButtonTraditional = ({ isLoading, username }) => {
  return (
    <button type="submit" disabled={isLoading} style={buttonStyle}>
      {isLoading ? `Saving ${username}...` : "Update Profile"}
    </button>
  );
};

const ProfileFormTraditional = () => {
  const [isSaving, setIsSaving] = useState(false);
  const [username, setUsername] = useState(""); // Extra state just for the button's UI

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSaving(true);
    try {
      await updateProfile(new FormData(e.target));
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Problem: Must manually sync input to state to share it with the button */}
      <input 
        name="username" 
        value={username} 
        onChange={(e) => setUsername(e.target.value)} 
      />
      
      {/* Problem: Drilling multiple props down to the child */}
      <SubmitButtonTraditional isLoading={isSaving} username={username} />
    </form>
  );
};


With useFormStatus, the button retrieves the username directly from the form's data object and becomes "self-aware” about the form submission status. You don't need onChange handlers or extra states in the parent component. It automatically connects to the parent form's lifecycle, allowing the parent code to remain clean and focused on the logic of the action itself.

After - React 19 useFormStatus way
 

// REACT 19 STATUS AWARENESS PATTERN:
// Benefit: Eliminates Prop Drilling—the button component retrieves its own status.
// Benefit: Decouples components—you can move the button anywhere inside the form without changing props.
// Benefit: Automatically synchronizes with the 'isPending' state of the form's Action.

const SubmitButtonModern = () => {
  // REACT 19 API: 
  // Retrieves status from the nearest parent <form>.
  const { pending, data, method, action } = useFormStatus();

  return (
    <button type="submit" disabled={pending} style={buttonStyle}>
      {pending ? `Saving ${data?.get("username")}...` : "Update Profile"}
    </button>
  );
};

const ProfileFormModern = () => {
  // Benefit: The parent form no longer needs to track "loading" state.
  return (
    <form action={updateProfileAction}>
      <input name="username" />
      {/* CLEANER COMPONENT TREE: No props required for status tracking. */}
      <SubmitButtonModern />
    </form>
  );
};

 

Migration Benefits:

  • Reduces Boilerplate: Removes the need for manual useState and finally blocks to handle loading states and the need to pass the props.

  • Component Modularity: Enables creating "Smart Buttons" that work in any form without configuration.

  • Declarative Feedback: Replaces imperative state-syncing with native access to submitted FormData.

  • Action Synergy: Pairs perfectly with useActionState to provide a fully automated form lifecycle.

React 19's new toolkit shifts the burden of async state management from your business logic to the framework. By masking latency with useOptimistic and managing resources natively with the use API and useFormStatus, we move beyond state soup toward a declarative, responsive UI. Adopting these patterns today doesn't just result in cleaner code—it builds a future-proof, resilient user experience where the UI remains fluid even when the network is slow.

 

Related Articles

Blog by This Author