Improving Async Error Handling with React Query in a Production Web App
How moving async lifecycle logic from UI components into React Query hooks made request state, cache updates, and user feedback easier to maintain.
I recently worked on improving how async operations and user feedback are handled in a production web application.
The application consumes several backend endpoints to load data, save user preferences, and update settings. Because of that, handling loading, success, and error states consistently is an important part of the user experience.
The Problem
Some flows were relying on local try/catch blocks directly inside UI components.
A simplified version looked like this:
async function handleSave() {
try {
setLoading(true)
await saveSettings(payload)
showSuccess('Settings saved successfully')
await refetchSettings()
} catch {
showError('Could not save settings')
} finally {
setLoading(false)
}
}
This works, but it creates a few problems as the application grows:
- UI components start doing too much.
- Loading state is controlled manually in multiple places.
- Error handling becomes inconsistent.
- Success and error notifications are duplicated across the app.
- Cache updates and refetch logic are spread across components.
- Future changes become harder because each flow may implement async behavior slightly differently.
The problem was not try/catch itself. The problem was having request lifecycle logic inside the presentation layer.
The Refactor
The solution was to move async state management to React Query and expose each operation through custom hooks.
API functions remain small and focused:
type SettingsPayload = Record<string, boolean>
export const saveSettings = async (settings: SettingsPayload) => {
await api.put('/api/example/settings', settings)
}
Then the mutation hook owns the request lifecycle:
export function useSaveSettings() {
const queryClient = useQueryClient()
const { notify } = useSnackbar()
return useMutation({
mutationFn: (payload: SettingsPayload) => saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settings })
notify({
severity: 'success',
message: 'Settings saved successfully'
})
},
onError: () => {
notify({
severity: 'error',
message: 'Could not save settings'
})
}
})
}
Now the component only consumes the hook:
export function SettingsPanel() {
const { mutate: save, isPending: isSaving } = useSaveSettings()
function handleSave() {
save(selectedSettings)
}
return (
<Button onClick={handleSave} loading={isSaving} disabled={isSaving}>
Save
</Button>
)
}
This makes the component much cleaner. It no longer needs to know how the request is executed, how errors are handled, which query should be invalidated, or how the user should be notified.
The same pattern also works well for reset flows:
export function useResetSettings(options?: { onAfterReset?: () => void }) {
const queryClient = useQueryClient()
const { notify } = useSnackbar()
return useMutation({
mutationFn: resetSettings,
onSuccess: (settings) => {
queryClient.setQueryData(queryKeys.settings, settings)
notify({
severity: 'success',
message: 'Settings restored successfully'
})
options?.onAfterReset?.()
},
onError: () => {
notify({
severity: 'error',
message: 'Could not restore settings'
})
}
})
}
What This Solved
This refactor created a clearer separation of responsibilities:
API function -> talks to the backend
React Query hook -> manages async state, cache, success and error behavior
Notification hook -> standardizes user feedback
Component -> renders UI and triggers actions
It also removed the need for repeated manual state like:
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
React Query already provides this through mutation and query state:
const { mutate, isPending, isError, isSuccess } = useSaveSettings()
Benefits
The main benefits were:
- Cleaner UI components.
- Less duplicated error handling.
- More consistent success and error notifications.
- Centralized cache invalidation.
- Fewer manual loading states.
- Easier testing and debugging.
- Better developer experience when adding new API flows.
- Lower risk of forgetting error handling or user feedback in a new feature.
For developers, the maintenance benefit is significant.
When a new save, reset, or update flow is needed, the pattern is already clear:
- Create a focused API function.
- Wrap it in a React Query hook.
- Handle cache and notifications inside the hook.
- Keep the component focused on rendering and user interaction.
This reduces decision fatigue and makes the codebase easier to scale.
Final Thoughts
In a production web application, reliability is not only about the backend returning the right data. It is also about how the frontend handles request states, failures, retries, and user feedback.
Moving async lifecycle logic from components into React Query hooks made the code more predictable, easier to maintain, and safer to evolve.
It was not just a refactor to reduce lines of code. It was a refactor to improve ownership of responsibilities across the application.
Elmano Neto - Senior Software Engineer
Comments