Why I stopped using React's useState for async operations
useState handles synchronous state perfectly, but async operations need proper loading states, error handling, and race condition prevention.
Why I stopped using React's useState for async operations
I used to handle every async operation with useState. Need to fetch user data? Create state for the data, loading flag, and error message. Need to submit a form? Add another loading state and error state. It felt natural -- React's built-in hook for managing component state, perfect for tracking the different phases of async operations. Most tutorials showed this pattern, and it worked fine for simple API calls. Then I built a real-time messaging app where multiple async operations could happen simultaneously, and my component state management became a nightmare.
The breaking point came when I realized my chat component had race conditions. A user could send a message, then immediately switch to a different conversation. The original message request would complete after the conversation switch, updating the wrong chat's state. I had useState for message sending, message loading, conversation loading, user status fetching, and typing indicators. When operations overlapped, I'd get impossible states -- like showing "message sent successfully" while still displaying a loading spinner from a previous operation that never completed.
Here's what my old async handling looked like:
function ChatComponent() {
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [isSending, setIsSending] = useState(false);
const [sendError, setSendError] = useState(null);
useEffect(() => {
const fetchMessages = async () => {
setIsLoading(true);
setError(null);
try {
const response = await api.getMessages();
setMessages(response.messages);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
fetchMessages();
}, [conversationId]);
const sendMessage = async (text) => {
setIsSending(true);
setSendError(null);
try {
const message = await api.sendMessage(text);
setMessages(prev => [...prev, message]);
} catch (err) {
setSendError(err.message);
} finally {
setIsSending(false);
}
};
// More state management for typing indicators, user status, etc.
}This approach had serious problems. I was manually tracking loading and error states for every async operation. Race conditions happened when the user triggered multiple operations quickly -- switching conversations while a message was still sending would update the wrong chat's state. I had no way to cancel in-flight requests when the component unmounted or when newer requests made older ones irrelevant. The state updates were scattered across different functions, making it impossible to reason about the component's overall state at any given moment.
Now I use dedicated async state management with custom hooks that handle the complexity of async operations:
function useAsyncOperation(operation, dependencies = []) {
const [state, setState] = useState({
data: null,
isLoading: false,
error: null
});
const abortControllerRef = useRef(null);
const execute = useCallback(async (...args) => {
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await operation(...args, { signal: abortController.signal });
// Only update if this request wasn't cancelled
if (!abortController.signal.aborted) {
setState({ data: result, isLoading: false, error: null });
}
} catch (error) {
if (error.name !== 'AbortError') {
setState(prev => ({ ...prev, isLoading: false, error }));
}
}
}, dependencies);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return { ...state, execute };
}
function ChatComponent() {
const [messages, setMessages] = useState([]);
const messagesQuery = useAsyncOperation(
(conversationId, { signal }) => api.getMessages(conversationId, { signal }),
[conversationId]
);
const sendMessageMutation = useAsyncOperation(
(text, { signal }) => api.sendMessage(text, { signal })
);
useEffect(() => {
messagesQuery.execute(conversationId);
}, [conversationId]);
const handleSendMessage = async (text) => {
const newMessage = await sendMessageMutation.execute(text);
setMessages(prev => [...prev, newMessage]);
};
if (messagesQuery.isLoading) return <LoadingSpinner />;
if (messagesQuery.error) return <ErrorMessage error={messagesQuery.error} />;
return (
<div>
<MessageList messages={messages} />
<MessageForm
onSend={handleSendMessage}
isLoading={sendMessageMutation.isLoading}
error={sendMessageMutation.error}
/>
</div>
);
}This async-first approach solved my race condition problems completely. The useAsyncOperation hook automatically cancels previous requests when new ones start, preventing state updates from stale operations. I get consistent loading and error handling without manually tracking multiple boolean flags. The abort controller integration means I can cancel in-flight requests when the component unmounts or when the user navigates away.
The mental model shift was crucial. Instead of thinking "I need state for this data plus loading plus error," I started thinking "I need an async operation that manages its own lifecycle." This approach scales much better -- I can have multiple async operations in a single component without them interfering with each other.
For more complex scenarios, I've moved to libraries like TanStack Query or SWR, but the custom hook approach works perfectly for simpler async operations. The key insight was recognizing that async operations are fundamentally different from synchronous state changes. They have their own lifecycle, their own error scenarios, and their own cleanup requirements.
The benefits of proper async state management:
- No race conditions -- requests are automatically cancelled when superseded
- Consistent error handling -- every async operation follows the same pattern
- Automatic cleanup -- no memory leaks from components that unmount during async operations
- Better user experience -- loading states and error recovery are handled systematically
- Easier testing -- async operations are isolated and can be mocked independently
React's useState is perfect for managing synchronous component state. But async operations need specialized handling that accounts for their unique challenges. Once I stopped forcing async operations into synchronous state patterns, my components became more reliable and much easier to debug.