How to fix useTransition isPending flag not updating after state change in React

ReactINTERMEDIATEHIGHUpdated Dec 9, 2025· 113 views

The isPending flag in React 18's useTransition hook can appear stuck or fail to update correctly when state changes occur during transitions. This happens when actions don't properly report their completion status, when state updates aren't wrapped in startTransition, or when asynchronous operations lack proper error handling.

Quick fix

Fixes useTransition isPending flag not updating after state change

const [isPending, startTransition] = useTransition();

const handleSubmit = () => {
  startTransition(() => {
    fetch('/api/submit', { method: 'POST' }); // ❌ Fire-and-forget, isPending gets stuck
    setData({ submitted: true });
  });
};
  1. Ensure your action returns a Promise and properly awaits all async operations
  2. Move all state updates inside the startTransition action
  3. Handle errors properly within the transition
  4. Create a server action for complex transitions
react — error
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(() => {
fetch('/api/submit', { method: 'POST' }); // ❌ Fire-and-forget, isPending gets stuck
setData({ submitted: true });
});
};
useTransition isPending flag not updating after state change

What this error means

In React 18, the useTransition hook provides isPending and startTransition to mark non-urgent updates. When isPending stays true after a state change completes, React doesn't recognize that the transition has finished. This typically occurs because the action passed to startTransition doesn't properly report its completion—either it's not actually returning a promise that resolves, it's a synchronous function, or error handling is incomplete. The isPending flag relies on Promise resolution to know when a transition ends, so any mismatch between what the action does and what isPending expects causes the flag to get stuck in a true state, leaving your UI frozen in a loading state even after the data has updated. Understanding this requires knowing that useTransition uses promises internally to track completion. If your action doesn't return a promise, resolves too early, or throws an error that isn't caught, React can't determine when the transition finishes. The flag essentially becomes orphaned, remaining true indefinitely until the component unmounts or another transition occurs.

Typical symptoms

  • Loading spinner or disabled button remains visible even after data updates
  • isPending flag stays true long after the state change completes
  • Form submission appears stuck despite successful API call
  • UI becomes unresponsive with frozen loading states
  • Data updates in the component but the transition never completes
  • isPending toggles briefly then gets stuck on true
  • No console errors but the UI behavior is clearly broken
  • Refreshing the page is required to clear the stuck state

Common causes

  1. Passing a synchronous function to startTransition instead of one that returns a Promise
  2. Action function completes but doesn't properly await all async operations
  3. State update calls are not wrapped in the transition-tracked action
  4. API call fails silently with unhandled error, preventing promise resolution
  5. Multiple setState calls where only some are inside the transition action
  6. Using useState to track loading state separately instead of relying on isPending
  7. Action function resolves before the setState actually commits to the DOM
  8. Missing await keywords on async operations inside the action
  9. useTransition used in combination with unstable_flushSync causing race conditions

How to fix "useTransition isPending flag not updating after state change"

1Ensure your action returns a Promise and properly awaits all async operations

The action function passed to startTransition must return a Promise that only resolves when all updates are complete. Every async operation inside must be awaited.

Bad - Missing await:

javascript
const [isPending, startTransition] = useTransition();

const handleSubmit = () => {
  startTransition(() => {
    fetch('/api/submit', { method: 'POST' }); // ❌ Fire-and-forget, isPending gets stuck
    setData({ submitted: true });
  });
};

Good - Properly awaited:

javascript
const [isPending, startTransition] = useTransition();

const handleSubmit = () => {
  startTransition(async () => {
    const response = await fetch('/api/submit', { method: 'POST' }); // ✓ Awaited
    const result = await response.json();
    setData({ submitted: true, result });
  });
};
2Move all state updates inside the startTransition action

Every setState call that's part of the transition must be inside the action function. Updates outside startTransition won't be tracked.

Bad - State update outside transition:

javascript
const [isPending, startTransition] = useTransition();
const [data, setData] = useState(null);

const handleClick = () => {
  setData({ loading: true }); // ❌ Outside transition
  startTransition(async () => {
    const result = await fetchData();
    setData(result); // isPending may finish before this
  });
};

Good - All updates inside transition:

javascript
const [isPending, startTransition] = useTransition();
const [data, setData] = useState(null);

const handleClick = () => {
  startTransition(async () => {
    const result = await fetchData(); // ✓ Await before setState
    setData(result); // ✓ Inside transition
  });
};
3Handle errors properly within the transition

If the action throws an error, isPending may get stuck. Wrap async operations in try-catch to ensure the promise always completes.

Bad - Unhandled error:

javascript
const handleSubmit = () => {
  startTransition(async () => {
    const response = await fetch('/api/submit');
    const data = await response.json(); // ❌ Throws if JSON parsing fails
    setData(data);
  });
};

Good - Error handling:

javascript
const [error, setError] = useState(null);

const handleSubmit = () => {
  startTransition(async () => {
    try {
      const response = await fetch('/api/submit');
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data = await response.json();
      setData(data);
      setError(null);
    } catch (err) {
      setError(err.message); // ✓ Handle error, promise still resolves
    }
  });
};
4Create a server action for complex transitions

For Next.js 13+ or complex async operations, use server actions which automatically return Promises and provide better error handling.

Before - Complex client transition:

javascript
const [isPending, startTransition] = useTransition();

const handleSubmit = () => {
  startTransition(async () => {
    // Complex logic here
  });
};

After - Server action:

javascript
// app/actions.ts
'use server';

export async function submitForm(formData) {
  const result = await db.insert(formData);
  revalidatePath('/');
  return result;
}

// In your component
'use client';
import { submitForm } from './actions';

const [isPending, startTransition] = useTransition();

const handleSubmit = (formData) => {
  startTransition(() => submitForm(formData)); // ✓ Server action returns Promise
};
5Verify the action is actually async and returns a Promise

useTransition specifically tracks Promises. If your action is synchronous, iPending will complete immediately (or never start).

Bad - Synchronous function:

javascript
const handleClick = () => {
  startTransition(() => {
    setData({ count: count + 1 }); // Synchronous, isPending won't work as expected
  });
};

Good - Explicitly async:

javascript
const handleClick = () => {
  startTransition(async () => {
    await new Promise(resolve => setTimeout(resolve, 0)); // Ensures Promise
    setData({ count: count + 1 });
  });
};

// Or better, for truly async operations:
const handleClick = () => {
  startTransition(async () => {
    const result = await fetchData();
    setData(result);
  });
};
6Avoid mixing useTransition with manual loading state

Don't use separate useState for loading state when you have isPending. This creates state synchronization issues and confusion about which state is authoritative.

Bad - Redundant loading state:

javascript
const [isPending, startTransition] = useTransition();
const [isLoading, setIsLoading] = useState(false); // ❌ Redundant

const handleSubmit = () => {
  setIsLoading(true);
  startTransition(async () => {
    const result = await submit();
    setData(result);
    setIsLoading(false); // ❌ Two sources of truth
  });
};

Good - Single source of truth:

javascript
const [isPending, startTransition] = useTransition();

const handleSubmit = () => {
  startTransition(async () => {
    const result = await submit();
    setData(result);
  });
};

return (
  <button disabled={isPending}>
    {isPending ? 'Loading...' : 'Submit'}
  </button>
);
7Check browser DevTools to confirm Promise behavior

Use React DevTools to inspect the transition state and browser console to verify your promises resolve.

Debug in the browser console:

javascript
// Inside your action function:
const handleSubmit = () => {
  startTransition(async () => {
    console.log('Transition started');
    try {
      const response = await fetch('/api/data');
      const data = await response.json();
      console.log('Data fetched:', data);
      setData(data);
      console.log('State updated, transition should end now');
    } catch (err) {
      console.error('Transition error:', err);
    }
  });
};

If you see "Transition started" and "Data fetched" but isPending stays true, the promise isn't resolving after setState. Check if setState is batched correctly or if there's a race condition.

8Use useTransitionCallback for custom async logic

For reusable transition logic, create a helper hook that ensures proper promise handling.

Custom hook pattern:

javascript
function useAsyncTransition() {
  const [isPending, startTransition] = useTransition();
  
  const run = (asyncFn) => {
    startTransition(async () => {
      try {
        await asyncFn();
      } catch (err) {
        console.error('Transition failed:', err);
        throw err; // Re-throw if you want to handle it elsewhere
      }
    });
  };
  
  return [isPending, run];
}

// Usage
const MyComponent = () => {
  const [isPending, runTransition] = useAsyncTransition();
  
  const handleClick = () => {
    runTransition(async () => {
      const data = await fetchData();
      setData(data);
    });
  };
  
  return <button disabled={isPending} onClick={handleClick}>Load</button>;
};

Advanced notes

Promise Microtask Timing: React 18 transitions use Promise resolution to determine when to set isPending to false. This happens in the microtask queue, so if setState batching delays the actual DOM update, isPending might clear before React re-renders. This is usually fine but can cause visual inconsistencies. Use flushSync sparingly if you need to force immediate DOM updates.

Server Component Transitions: In Next.js with Server Components, useTransition on the client can wrap server action calls. The action itself returns a Promise, so isPending works correctly. However, ensure the server action properly completes all revalidations before returning.

Suspense Integration: useTransition works alongside Suspense boundaries. If your transition action triggers a Suspense boundary, the transition completion is determined by the boundary's resolution, not just the promise in startTransition. Be aware that nested async operations can affect timing.

Race Conditions: If the user clicks multiple times rapidly, multiple transitions queue up. Each maintains its own isPending state. Be careful about state updates from previous transitions completing after newer transitions start. Consider debouncing or disabling the button during transitions.

Testing useTransition: In Jest/React Testing Library, ensure you use screen.findBy or waitFor when testing isPending changes, as they happen asynchronously. Mocking fetch and returning resolved promises helps simulate proper transition completion.

Redux Integration: If using Redux, dispatch actions inside the transition function. The store updates trigger React re-renders, but isPending follows the promise, not Redux state. Ensure your action creators return promises.

Performance Consideration: Each transition has a minor performance cost. For simple synchronous updates, useTransition may be overkill. Reserve it for API calls, file uploads, or long computations. Transitions prioritize user input over transition updates, so they naturally optimize interactivity.

Frequently asked questions

What does "useTransition isPending flag not updating after state change" mean?
In React 18, the useTransition hook provides isPending and startTransition to mark non-urgent updates. When isPending stays true after a state change completes, React doesn't recognize that the transition has finished. This typically occurs because the action passed to startTransition doesn't properly report its completion—either it's not actually returning a promise that resolves, it's a synchronous function, or error handling is incomplete.
How do I fix "useTransition isPending flag not updating after state change"?
The isPending flag in React 18's useTransition hook can appear stuck or fail to update correctly when state changes occur during transitions. This happens when actions don't properly report their completion status, when state updates aren't wrapped in startTransition, or when asynchronous operations lack proper error handling.
What causes "useTransition isPending flag not updating after state change"?
Passing a synchronous function to startTransition instead of one that returns a Promise Action function completes but doesn't properly await all async operations State update calls are not wrapped in the transition-tracked action
Is the "useTransition isPending flag not updating after state change" error serious?
Yes. This is a high-severity error that usually blocks work until it is fixed, but the steps above resolve it.

Official resources & further reading