Issac Lau

Stop using useEffect to fetch data and manage server state

December 3, 2024
11 min read
Loading

How should we manage server state in React? Many people first think of sending requests in useEffect and managing state with useState.

Let's look at the problems with this approach, how to fix them, and then better alternatives.

Fetch in useEffect and manage state with useState

We usually write something like this:

function App() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => res.json())
      .then((data) => setUsers(data));
  }, []);
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

As a demo this is fine, but it has obvious issues:

  1. It does not track request state, so the user has no idea data is loading.
  2. It does not handle errors, so failures are silent.

Let's fix that:

function App() {
  const [users, setUsers] = useState();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  useEffect(() => {
    setIsLoading(true);
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => {
        if (!res.ok) {
          throw new Error("Failed to fetch users");
        }
        return res.json();
      })
      .then((data) => {
        setUsers(data);
        setIsLoading(false);
        setError(null);
      })
      .catch((error) => {
        setError(error);
        setIsLoading(false);
      });
  }, []);
  if (isLoading) {
    return <div>Loading...</div>;
  }
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

At first glance, adding multiple useState hooks makes the code more complex but still manageable. But your boss asks for search, and you might write something like this:

function App() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [search, setSearch] = useState("");
  useEffect(() => {
    setIsLoading(true);
    fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
      .then((res) => {
        if (!res.ok) {
          throw new Error("Failed to fetch users");
        }
        return res.json();
      })
      .then((data) => {
        setUsers(data);
        setIsLoading(false);
        setError(null);
      })
      .catch((error) => {
        setError(error);
        setIsLoading(false);
      });
  }, [search]);
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  return (
    <div>
      <input
        type="text"
        value={search}
        onChange={(e)=> setSearch(e.target.value)}
      />
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

This looks fine, but it hides a problem. Every keystroke fires a request. If the user types "a" then "b", you send two requests for "a" and "ab". Server response times vary. If the response for "a" is slower than "ab", your UI will show results for "ab" first and then overwrite with results for "a". The issue is:

  1. The final result does not match the UI intent. We want the "ab" results.

How do we fix it?

You might think of debouncing requests, delaying the fetch a bit. But debounce doesn't solve the core issue: response times vary. Even with debounce, a slight pause can still trigger multiple requests and cause races.

What we actually need is: when a new request starts, cancel or ignore the previous one. That avoids races. You can find a similar approach in the React docs.

function App() {
  const [users, setUsers] = useState();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [search, setSearch] = useState("");
  useEffect(() => {
    let ignore = false;
    setIsLoading(true);
    fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
      .then((res) => {
        if (!res.ok) {
          throw new Error("Failed to fetch users");
        }
        return res.json();
      })
      .then((data) => {
        if (ignore) return;
        setUsers(data);
        setIsLoading(false);
        setError(null);
      })
      .catch((error) => {
        if (ignore) return;
        setError(error);
        setIsLoading(false);
      });
    return () => {
      ignore = true;
    };
  }, [search]);
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  return (
    <div>
      <input
        type="text"
        value={search}
        onChange={(e)=> setSearch(e.target.value)}
      />
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Because React runs cleanup before the next effect, we can set ignore and skip setState when the request is outdated.

At this point the request logic is getting complex. The same pattern is needed for every request, so you might extract a shared hook.

export function useQuery({ url }) {
  const [data, setData] = useState();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  useEffect(() => {
    let ignore = false;
    setIsLoading(true);
    fetch(url)
      .then((res) => {
        if (!res.ok) {
          throw new Error("Failed to fetch users");
        }
        return res.json();
      })
      .then((data) => {
        if (ignore) return;
        setData(data);
        setIsLoading(false);
        setError(null);
      })
      .catch((error) => {
        if (ignore) return;
        setError(error);
        setIsLoading(false);
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return {
    data,
    isLoading,
    error,
  };
}
import { useQuery } from "./useQuery";
function App() {
  const [search, setSearch] = useState("");
  const {
    data: users,
    isLoading,
    error,
  } = useQuery({
    url: `https://jsonplaceholder.typicode.com/users?q=${search}`,
  });
  if (isLoading) {
    return <div>Loading...</div>;
  }
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  return (
    <div>
      <input
        type="text"
        value={search}
        onChange={(e)=> setSearch(e.target.value)}
      />
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Now the custom hook looks nice, but if you use it widely you will run into issues:

  1. Request deduplication: our useQuery doesn't dedupe requests. Using it in multiple components with the same URL triggers redundant requests.
  2. Caching: our useQuery doesn't cache. High-performance apps often need caching because not every screen requires real-time data. As the saying goes, caching and naming are hard.
  3. Refetching: should we refetch data when the user returns to the page after some time (coffee break, screen lock, etc.)?
  4. Coupling to fetch: our useQuery bakes in fetch. Ideally we should accept a queryFn and queryKey, return a promise from queryFn, and compare queryKeys efficiently to know when to refetch.

Use a third-party library

After all that, the point is clear: managing server request state is complex. If you care about great UX, use a third-party library. The most popular in this space is react-query, which provides:

  1. Declarative API: declare a query and changing its parameters automatically reruns it.
  2. Automatic caching
  3. Automatic request deduplication
  4. Backend-agnostic
  5. Automatic retries
  6. Prefetching
  7. Request cancellation
  8. More

For more details, see the react-query docs.

Let's rewrite the example with react-query:

import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  );
}

function Example() {
  const [search, setSearch] = useState("");
  const { data, isPending, error } = useQuery({
    queryKey: ["users", search],
    queryFn: () =>
      fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`).then(
        (res) => {
          if (!res.ok) {
            throw new Error("Failed to fetch users");
          }
          return res.json();
        }
      ),
  });
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  return (
    <div>
      <input
        type="text"
        value={search}
        onChange={(e)=> setSearch(e.target.value)}
      />
      {isPending ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

All done. The API looks much cleaner. Hopefully you get a chance to use it in your projects.

评论

Loading