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.
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.
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:
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(() => {
fetch('/api/submit', { method: 'POST' }); // ❌ Fire-and-forget, isPending gets stuck
setData({ submitted: true });
});
};Good - Properly awaited:
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 });
});
};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:
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:
const [isPending, startTransition] = useTransition();
const [data, setData] = useState(null);
const handleClick = () => {
startTransition(async () => {
const result = await fetchData(); // ✓ Await before setState
setData(result); // ✓ Inside 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:
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:
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
}
});
};For Next.js 13+ or complex async operations, use server actions which automatically return Promises and provide better error handling.
Before - Complex client transition:
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
// Complex logic here
});
};After - Server action:
// 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
};useTransition specifically tracks Promises. If your action is synchronous, iPending will complete immediately (or never start).
Bad - Synchronous function:
const handleClick = () => {
startTransition(() => {
setData({ count: count + 1 }); // Synchronous, isPending won't work as expected
});
};Good - Explicitly async:
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);
});
};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:
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:
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const result = await submit();
setData(result);
});
};
return (
<button disabled={isPending}>
{isPending ? 'Loading...' : 'Submit'}
</button>
);Use React DevTools to inspect the transition state and browser console to verify your promises resolve.
Debug in the browser console:
// 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.
For reusable transition logic, create a helper hook that ensures proper promise handling.
Custom hook pattern:
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>;
};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.
Prop spreading could cause security issues
Prop spreading could cause security issues
Error: error:0308010C:digital envelope routines::unsupported
Error: error:0308010C:digital envelope routines::unsupported
React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render.
React Hook useEffect placed inside a condition
Hook can only be called inside the body of a function component
Hook can only be called inside the body of a function component
Rollup failed to resolve import during build
How to fix "Rollup failed to resolve import" in React