DEV Community

Emmanuel Sunday
Emmanuel Sunday

Posted on

The Senior Dev Approach to Data Fetching in React

The beauty of React is... flexibility.

In 2013, React was released as a client manipulation framework.

I know it's a library, Charlie ;)

Two years later, a young man named Michael Jackson built React Router, so React could manage full-stack applications.

But that was not enough.

Tanner Linsley built React Query to support the notion that useEffect was a bad idea for managing APIs.

The effrontery.

But this has been the beauty of React.

The flexibility to do stuff your way.

The effrontery to create a personal approach in your cabinet and call it a better approach.

This has been the beauty.

And just maybe, a downside…

Reusability in UI

React perfectly solved the problem of UI reusability.

They introduced "composition," where a component is a cocktail of JavaScript, CSS, and HTML.

Stay with me.

You create a component, and that is all you need.

Changes in your UI are a function of a state change.

Hence, UI = f(state).

This means all you have to worry about is how the "state" in your application changes, and the UI will respond appropriately.

This was pure brilliance —

and it destroyed the likes of AngularJS, BackboneJS, and EmberJS, which relied on an MVC architecture and complex jargon to manage HTML, CSS, and JS.

However, there's a problem.

The problem is that in the real world, there's more to building an app than just the UI layer.

Yes, React solved the complexity of UI, but maybe not yet for non-static sites.

Reusability in Logic

Where and how does data fetching fit into this brilliance React came with?

The earlier versions of React never had a thing like hooks.

So it was worse.

We would use UI components and class components.

...where class components handled non-UI logic (e.g., data fetching, etc.).

And UI components, like the name implies, handled UI logic.

Author's Note: These class components and their methods became the building blocks for hooks.

Let's be a little practical.

This is what fetching user data looked like in React a few years back.

Stay with me.


import React from "react";

export function withUsers(WrappedComponent) {

  return class extends React.Component {

    state = { users: [] };

    componentDidMount() {

      fetch("/api/users")
        .then(res => res.json())
        .then(data => this.setState({ users: data }));

    }

    render() {

      return <WrappedComponent users={this.state.users} {...this.props} />;

    }

  };

}
Enter fullscreen mode Exit fullscreen mode

Author's Note: componentDidMount() is a "lifecycle" method available in React classes, which tracks and renders only the first instance of a component mounting. This, alongside other "lifecycle" methods, was later implemented in the useEffect hook.

import React from "react";

export default function UserList({ users }) {

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );

}
Enter fullscreen mode Exit fullscreen mode

The first snippet is a class component, and the second is a UI component.

We put them together here…

import { withUsers } from "./withUsers";
import UserList from "./UserList";

export default withUsers(UserList);
Enter fullscreen mode Exit fullscreen mode

The issue with this is that it's cumbersome.

And very error-prone.

The more you think about the above logic, the more you see bugs.

React knew better and created hooks.

They solved UI reusability, so why not solve logic reusability too?

The Advent of Hooks

In 2018, at one of the year's most prominent tech conferences, React Today and Tomorrow, Dan Abramov introduced the concept of hooks.

By February 2019, hooks were officially released with React 16.8.

This was bliss.

This was a game-changer.

We had probably solved logic reusability.

Or so we thought.

Rather than extending the React class component and calling componentDidMount, componentDidUpdate, or any other lifecycle method, we could use a single useEffect hook.

Notabene:

The useEffect hook encapsulates three class lifecycle methods:

  1. componentDidMount – runs once when the component first appears.
  2. componentDidUpdate – runs when props or state change.
  3. componentWillUnmount – runs cleanup when the component is removed.

A good refactor of our data-fetching illustration using the useEffect hook could look like this…

import React, { useState, useEffect } from "react";

export default function UserList() {

  const [users, setUsers] = useState([]);

  useEffect(() => {

    fetch("/api/users")
      .then(res => res.json())
      .then(data => setUsers(data));

  }, []);

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );

}
Enter fullscreen mode Exit fullscreen mode

Much easier on the eyes.

The Issue With useEffect

useEffect can actually get the job done for data fetching.

Yes.

And for our little illustration above, it may be sufficient if we add a few lines of code to handle loading state, error state, error reset state, race conditions during unmount, caching, cache invalidation, fallback for network failures, stale request management, and more.

Yikes!

You see, using useEffect to fetch data in React is "tutorial" code.

It works

...but there are a ton of things that could go wrong.

There are a ton of edge cases.

A supposedly better version of our illustration could look like this.

... with loading and error states, plus a filter parameter to filter users.

import React, { useState, useEffect } from "react";

export default function UserList({ filter }) {

  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {

    setLoading(true);

    fetch(`/api/users?filter=${filter}`)
      .then(res => {
        if (!res.ok) throw new Error("Network response was not ok");
        return res.json();
      })
      .then(data => setUsers(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));

  }, [filter]);

  if (loading) return <p>Loading users...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );

}
Enter fullscreen mode Exit fullscreen mode

There are already two hidden bugs in this code.

Suppose the request throws an error the first time, and the error state beautifully handles it.

Our app will keep showing the error state even after the next successful request.

But this is an easy fix.

We initialize every request with setError(null).

Yes.

But suppose, again, a user clicks the first time and it takes so long to load that they click a second time.

Perhaps Cloudflare went down during the first request but queued it, and the second one went through.

Our requests will run into a race condition.

The second response arrives, and whenever the first one finishes loading, it interrupts.

If this is a time- or resource-sensitive request, it could be consequential.

You could get an old bank statement, an old market price, inconsistent information, rate-limited responses, and the list goes on.

Now, you could try to solve all of these problems and end up with code like this…

import React, { useState, useEffect } from "react";

export default function UserList({ filter }) {

  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {

    const controller = new AbortController(); // Create an abort controller
    const signal = controller.signal;

    setLoading(true);
    setError(null);

    fetch(`/api/users?filter=${filter}`, { signal })
      .then(res => {
        if (!res.ok) throw new Error("Network response was not ok");
        return res.json();
      })
      .then(data => setUsers(data))
      .catch(err => {
        if (err.name !== "AbortError") { // Ignore abort errors
          setError(err);
        }
      })
      .finally(() => {
        if (!signal.aborted) setLoading(false); // Only update loading if not aborted
      });

    // Cleanup function cancels fetch if component unmounts or filter changes
    return () => {
      controller.abort();
    };

  }, [filter]);

  if (loading) return <p>Loading users...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );

}
Enter fullscreen mode Exit fullscreen mode

Even with this, if our server data changes after the component mounts, our component never updates automatically unless the filter changes again.

If the request fails due to a temporary network glitch, the fetch just errors out, and users must manually reload their tab.

There's zero caching.

No deduplication.

And even if you present this to your manager at work, they'll simply say "Abstract" — because there's a lot going on.

I could keep bringing a lot of sad paths around this for the next 5mins.

React Query In The Chat

Here's what our app would look like with React Query.

import React from "react";
import { useQuery } from "@tanstack/react-query";

export default function UserList({ filter }) {

  const { data: users = [], isLoading, error } = useQuery(
    ["users", filter],                     // Query key: unique per filter
    () => fetch(`/api/users?filter=${filter}`).then(res => {
      if (!res.ok) throw new Error("Network response was not ok");
      return res.json();
    })
  );

  if (isLoading) return <p>Loading users...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );

}
Enter fullscreen mode Exit fullscreen mode

Every edge cases addressed.

As Fireship, in one of its popular article puts it, React Query solves the 5 O'Clock Rule.

It reduces boilerplate to the bearest minimum.

  • Intelligent cache management
  • Automatic cache invalidation
  • Auto-refetching when data goes stale
  • Scroll position recovery
  • Offline support
  • Refetching on window focus
  • Dependent queries
  • Paginated queries
  • Request cancellation
  • Prefetching for faster experiences
  • Polling for real-time updates
  • Mutations to safely update data
  • Infinite scrolling support
  • Data selectors for efficient consumption
  • …and much more

React Query makes one thing fundamentally clear.

Server state and client state are fundamentally different things.

  1. Client state — like whether a modal is open or what a user has typed into a form — lives in your app and is always up to date.
  2. Server state, on the other hand, lives remotely, can change without your app knowing, and needs to be fetched, kept fresh, and synchronised.

Managing that with useEffect was, is, and will always be a mismatch.

React Query is the senior developer approach to data fetching in REACT.


And that is a wrap.

Uhm, btw, I'm a solo developer who's a free agent currently. Openly looking for software engineering roles. My portfolio is at www.me.soapnotes.doctor .

Thank you so much!

Top comments (0)