Decoupling Data Fetching and UI in React with Suspense and Custom Hooks
Unlocking the magic of asynchronous rendering in React using Suspense with custom hooks for dynamic data fetching.
Jul 26, 2024 - 15:29 • 4 min read
As the React ecosystem evolves, one of the most exciting features that have emerged is the concept of Suspense. Originally introduced for code splitting, React Suspense has gradually expanded its role to include data fetching in a declarative manner. In this post, we’ll explore how to harness this power to decouple data fetching from component logic using custom hooks, enhancing both performance and readability.
Understanding React Suspense
At its core, Suspense allows components to 'wait' for something before rendering. This is particularly useful for data fetching. You can define a fallback UI while the data is loading, presenting a smooth user experience.
import React, { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
);
}
Using Custom Hooks for Data Fetching
Custom hooks are one of the best ways to encapsulate reusable logic. By combining them with Suspense, you can achieve a clean separation between data-fetching logic and the presentation layer. Let's create a custom hook, useFetch
, that utilizes Suspense for data fetching.
useFetch
Hook
Creating First, we need to create a fetchData
function that returns a promise. Then we modify our useFetch
hook to trigger a Suspense boundary when this data is being fetched:
function fetchData(url) {
return fetch(url)
.then(response => response.json())
.catch(error => { throw new Error('Error fetching data.'); });
}
function useFetch(url) {
let [data, setData] = React.useState(null);
let [error, setError] = React.useState(null);
React.useEffect(() => {
let isMounted = true;
fetchData(url)
.then(result => {
if (isMounted) setData(result);
})
.catch(err => {
if (isMounted) setError(err);
});
return () => { isMounted = false; };
}, [url]);
if (error) throw error; // Let Suspense handle the error
if (!data) throw fetchData(url); // Let Suspense handle the loading state
return data;
}
Implementing the Custom Hook in a Component
Now we can implement useFetch
in a functional component that displays some data:
function MyComponent() {
const data = useFetch('https://api.example.com/data');
return <div>{data.title}</div>;
}
Adding Error Boundary
While Suspense handles loading states well, it doesn't cover errors. To catch errors during data fetching, we need to add an Error Boundary:
Creating an Error Boundary
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('Error occurred:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Now, we can wrap our App
component with the ErrorBoundary
to catch any errors that arise.
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
);
}
Optimistic Updates with Suspense
One of the advanced patterns is to implement optimistic updates. This approach provides an immediate UI response for user actions while still waiting for confirmation from the server.
Assuming we have a POST
request that adds an item, the optimistic approach would involve immediately assuming success and updating the state. For example:
function addItem(item) {
return fetch('https://api.example.com/items', {
method: 'POST',
body: JSON.stringify(item),
});
}
function useAddItem() {
const [isPending, setIsPending] = React.useState(false);
const addItemOptimistically = async (item) => {
setIsPending(true);
const previousItems = [...currentItems]; // Clone current state
const newItems = [...previousItems, item]; // Add new item immediately
setItems(newItems); // Optimistically update state
try {
await addItem(item);
} catch (error) {
// Handle error and possibly revert state
setItems(previousItems);
throw error;
}
setIsPending(false);
};
return { isPending, addItemOptimistically };
}
Integrating data updates into the UI
Combining useAddItem
with our component can look like this:
function MyComponent() {
const { isPending, addItemOptimistically } = useAddItem();
const handleAddItem = async () => {
const newItem = { title: 'New Item' }; // Assuming we create an item
await addItemOptimistically(newItem);
};
return (
<div>
<button onClick={handleAddItem} disabled={isPending}>Add Item</button>
</div>
);
}
Conclusion
The powerful combination of React Suspense with custom hooks transforms how we handle asynchronous data fetching in our components. By decoupling the UI from the fetching logic, we enhance maintainability and code readability. Error handling via Error Boundaries complements this structure, providing a resilient user experience. Finally, optimistic updates can take user interactions to a new level of responsiveness and satisfaction, showcasing the potential of React's evolving ecosystem.
By exploring these techniques, we’ve only scratched the surface. The React ecosystem continues to advance, promising even more effective patterns in the future. Stay curious and keep experimenting with these tools for better user experience.