This error occurs in React 18 when trying to use async/await inside startTransition callbacks, causing state updates after await to lose their transition marking. React 18 requires synchronous functions for startTransition, though this limitation is resolved in React 19.
In React 18, the `startTransition` API requires the callback function to be synchronous. React immediately executes this function and marks all state updates that happen during its synchronous execution as transitions. When you use `await` inside a `startTransition` callback, any state updates that occur after the `await` statement are no longer marked as transitions. This is a JavaScript limitation caused by React losing the scope of the async context. Once execution crosses an `await` boundary, React can no longer track which state updates should be treated as non-urgent transitions versus urgent updates. This means async operations like data fetching cannot be directly wrapped in `startTransition` in React 18. The React team acknowledged this as a known limitation that would be addressed in future versions. In React 19, this restriction has been lifted, and `startTransition` now supports async callbacks natively, allowing developers to perform asynchronous operations without workarounds.
In React 18, manually wrap any state updates that occur after await statements in another startTransition call:
import { startTransition, useState } from 'react';
function SearchComponent() {
const [results, setResults] = useState([]);
const handleSearch = async (query: string) => {
// Fetch can happen outside startTransition
const data = await fetchSearchResults(query);
// Wrap the state update in startTransition
startTransition(() => {
setResults(data);
});
};
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
<ResultsList results={results} />
</div>
);
}This ensures the state update after the async operation is marked as a transition.
The useTransition hook provides isPending state and better control over async transitions:
import { useTransition, useState } from 'react';
function SearchComponent() {
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = async (query: string) => {
const data = await fetchSearchResults(query);
startTransition(() => {
setResults(data);
});
};
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
}The isPending flag helps show loading states during transitions.
Keep the async operation outside and only wrap the state update:
import { startTransition, useState } from 'react';
function DataComponent() {
const [data, setData] = useState(null);
const loadData = async () => {
// Async work happens outside
const response = await fetch('/api/data');
const result = await response.json();
// Only the state update is in startTransition
startTransition(() => {
setData(result);
});
};
return (
<button onClick={loadData}>
Load Data
</button>
);
}This pattern separates concerns and makes the transition boundary clear.
React 19 removes this limitation entirely by supporting async callbacks in startTransition:
// React 19 - async functions work directly
import { startTransition, useState } from 'react';
function SearchComponent() {
const [results, setResults] = useState([]);
const handleSearch = (query: string) => {
startTransition(async () => {
// Async function works directly in React 19
const data = await fetchSearchResults(query);
setResults(data);
});
};
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
<ResultsList results={results} />
</div>
);
}If you're starting a new project or can upgrade, React 19 provides a cleaner API.
For search inputs and filters, useDeferredValue can be a simpler alternative:
import { useDeferredValue, useState, useEffect } from 'react';
function SearchComponent() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const [results, setResults] = useState([]);
useEffect(() => {
async function search() {
if (deferredQuery) {
const data = await fetchSearchResults(deferredQuery);
setResults(data);
}
}
search();
}, [deferredQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ResultsList results={results} />
</div>
);
}This defers the expensive update without manually managing transitions.
Why This Limitation Exists
JavaScript's async/await is implemented using promises and microtasks. When execution crosses an await boundary, the function is suspended and the call stack unwinds. React's transition tracking relies on synchronous call stack inspection, which breaks when the function is suspended. This is why React 18 cannot automatically track state updates after await statements.
Future AsyncContext Proposal
The TC39 AsyncContext proposal aims to solve this at the JavaScript language level by providing a way to propagate context across async boundaries. Once AsyncContext is standardized and available, React 18 could theoretically support async transitions without manual wrapping.
Performance Considerations
Transitions are meant for non-urgent updates that can be interrupted. If your async operation is critical (like form submission), don't use startTransition at all - let it be an urgent update. Use transitions only for UI updates that can wait, like filtering large lists or switching tabs.
Concurrent Rendering
startTransition is part of React's concurrent rendering features. It tells React that certain updates are less urgent and can be interrupted if more important work comes in. This improves perceived performance by keeping the UI responsive, but it requires React to know which updates are transitions - hence the synchronous requirement in React 18.
Testing Transition Behavior
When testing components that use startTransition, be aware that transitions may not complete synchronously. Use act() and await for proper test isolation:
import { act, render, screen } from '@testing-library/react';
test('handles transition updates', async () => {
render(<SearchComponent />);
await act(async () => {
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'query' }
});
// Wait for async transition to complete
await new Promise(resolve => setTimeout(resolve, 100));
});
expect(screen.getByText('Results')).toBeInTheDocument();
});Debugging Transitions
Use React DevTools Profiler with "Record why each component rendered" enabled to verify that your state updates are being marked as transitions. Non-transition updates will show as high-priority renders, while transition updates show as low-priority.
React Hook useCallback has a missing dependency: 'variable'. Either include it or remove the dependency array react-hooks/exhaustive-deps
React Hook useCallback has a missing dependency
Cannot use private fields in class components without TS support
Cannot use private fields in class components without TS support
Cannot destructure property 'xxx' of 'undefined'
Cannot destructure property of undefined when accessing props
useNavigate() may be used only in the context of a <Router> component.
useNavigate() may be used only in the context of a Router component
Cannot find module or its corresponding type declarations
How to fix "Cannot find module or type declarations" in Vite