Honest talk: If you’re like me, you probably have a few thousand lines of code that look exactly the same: useState for data, useState for loading, useState for error, and a messy useEffect to tie it all together.
React 19’s new use() API completely retires this pattern, allowing you to read the result of a Promise right inside your component's render function. It genuinely simplifies asynchronous data fetching.
Let's look at the new flow, focusing on the two biggest questions developers have: loading states and error handling.
1. The New Loading State: Automatic Suspense
The biggest shift with use() is that you no longer manage the loading state inside your component. Instead, you manage it around your component using Suspense.
When a component calls use(promise):
- If the Promise is pending, React doesn't crash or freeze; it suspends the component's rendering.
- React looks up the component tree for the nearest
<Suspense> wrapper.
- It automatically displays the
fallback UI provided in that wrapper.
Once the data is ready, the component rerenders and displays the final result.
2. Handling Errors: The Clean Error Boundary
What happens when your network call fails? You don't need the messy try...catch block inside your component anymore.
If the Promise passed to use() rejects (e.g., a 404 or 500 status), the error automatically "bubbles up" the component tree. It will be caught by the nearest Error Boundary.
Code Example: Error Handling
To catch and handle different error types (like a 404 vs a connection timeout), you simply ensure your Error Boundary component can inspect the thrown error object:
// App.jsx
<ErrorBoundary fallback={<GeneralErrorPage />}>
<Suspense fallback={<UserSkeleton />}>
<UserComponent userPromise={fetchUser(userId)} />
</Suspense>
</ErrorBoundary>
Your custom ErrorBoundary component gets the raw error. You can check the error's properties (like error.status or error.message) to render specific messages:
// ErrorBoundary.jsx (Conceptual logic)
class ErrorBoundary extends React.Component {
// ... boilerplate methods like getDerivedStateFromError
render() {
if (this.state.hasError) {
if (this.state.error.status === 404) {
return <h1>User Not Found! ️♀️</h1>;
}
return this.props.fallback; // General error page
}
return this.props.children;
}
}
This separation of concerns, data fetching in the component, loading above, and errors above that makes our components much more focused and easier to maintain. It feels weird to let go of manual control, but the code is so much cleaner.
Have you tried refactoring your old fetching logic yet? Let me know what you think!