eMoosavi
Weekly frontend dev braindumps
Decoupling Data Fetching and UI in React with Suspense and Custom Hooks
Web Performance

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:294 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.

Creating useFetch Hook

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.

Article tags
reactsuspensecustom-hooksdata-fetchingoptimistic-updateserror-handling
Previous article

React Components

Building a Custom Animation Library with React Hooks

Next article

React Components

The Rise of React Server Components