Client-Side Data Fetching in React: A Practical Guide

This is the raw, uncut version of an article written for Sloth Bytes. Read the published version

Web development has gotten a lot more complicated over time. There's always a new JavaScript framework, a new 'best practice,' and another blog post saying you're doing things wrong.

Ironically, one of the most chaotic parts of web dev is also one of the simplest: fetching data.

There are a million ways to do it, and the "best" way is always situational. It'd be impossible and boring to cover every way to fetch data, so we are going to be breaking down the most common and sane ways people fetch data on the client in React and why its craziness may be justified.

Where It All Started: fetch

It all started with fetch.

fetch is a built-in web standard that works in every modern browser. It's simple, powerful, and fast—probably the first thing you used when you started building web apps. You might have moved on to other libraries, thinking fetch was too basic, only to realize later that it's actually a solid tool. The real question isn't if fetch is good; it's how to use it efficiently.

The Core Problem: Client-Side Data Persistence

When you move from one page to another, any data you fetched on the previous page is lost because the component that got it is removed, and JavaScript cleans it up. To get the data again, you have a few options:

  • Refetch it from the server
  • Store it somewhere global / around the routing context
  • Or get fancy with HTTP cache headers

In real apps, data is often shared across many components. Most people choose the second option: fetch the data, then store it in something like:

Then, you only make a new server request if the data is missing or stale.

But fetching the data is just one part; you also need to handle things like:

  • Loading states
  • Error states
  • Retry logic
  • Synchronization
  • Cache invalidation
  • Authentication headers

So you start putting this together, and the most common starting point is a useEffect that looks like this:

useEffect(() => {
async function fetchData() {
setIsLoading(true)
setError(null)
try {
const response = await fetch("/api/data")
const data = await response.json()
setData(data)
} catch (err) {
setError("Something went wrong")
} finally {
setIsLoading(false)
}
}
fetchData()
}, [])

Then someone tells you:

"Fetching in useEffect is an anti-pattern."

They're right; this is generally seen as an anti-pattern in the community. The React docs don't say it directly, but they basically suggest you should use a library for this, because at this point, you're now:

  • Fetching in useEffect
  • Syncing the data to a store
  • Maybe writing custom hooks
  • Trying to figure out caching lifetime logic
  • Aborting the request if the component unmounts during a fetch

And all of this is for something that was supposed to be simple.

There are some alternatives to fetch, like Axios, which is basically fetch, but with:

  • Better defaults
  • Interceptors - e.g., redirect when the response says unauthenticated
  • Automatic JSON handling

It's a solid option and has been around for a long time. But it doesn't really fix the main problems; it just makes getting the data a bit easier.

So fetch itself isn't the problem—the real challenge is managing the state around the data you fetch. That's why Senior engineers like fetch: it handles network requests without extra dependencies. But managing all the states is a different story, and that's where other tools start to make sense.

SWR (by Vercel)

SWR is one of the simplest ways to manage state for a fetch request. With SWR, you still use fetch, axios, or any fetch client you like; it just takes care of all the pain points mentioned earlier:

  • Automatic loading/error states
  • Caching
  • Request deduplication, e.g., multiple components can fetch the same data in multiple places, but only one network request is made
  • Revalidation
  • Sharing data across components (so you don't need a global store)

With SWR, instead of the earlier useEffect example, you could write something like this:

const fetcher = (...args) => fetch(...args).then(res => res.json())
const { data, error, isLoading } = useSWR('/api/data', fetcher)

SWR is a great tool because it's lightweight, easy to use, and works well for many apps.

But as your app grows, the relationships between different pieces of data can get complicated. You might find that when a user updates something, you have to invalidate and refetch a lot of other data. For example, in a project management app, a single "mark as complete" action can trigger many side effects that need to be reflected in the UI, such as updating the percentage of completed tasks, updating the tasks displayed based on the filter status, and updating productivity metrics.

At that point, it's not just about fetching data or managing state; it's about coordinating everything. Add in things like optimistic updates, and it gets much more complicated. This is where SWR can struggle, and other tools become more useful.

TanStack Query (formerly React Query)

TanStack Query offers the same basics as SWR - fetching, caching, deduplication, and shared server data, but its real strength lies in managing relationships and data validity when users make changes.

Fetching data is pretty simple. Here's what it may look like:

function useTasks() {
return useQuery({
// Query key uniquely identifies this piece of server data
queryKey: ['tasks'],
// Function that actually fetches the data - still uses fetch
queryFn: () =>
fetch('/api/tasks').then(res => res.json())
})
}

You'll need to add some boilerplate code to your app to use TanStack Query, but when you use this hook, it gives you an object with everything you need: the data, loading, and error states, refetch methods, and more. It's like SWR, but with more detailed options.

So if we go back to our previously mentioned example of when a user marks a task as complete, we want to:

  • Update the task itself
  • Invalidate the project summary query so progress percentages update
  • Refetch filtered task lists like "assigned to me" or "overdue."
  • Update dashboard metrics showing daily or weekly productivity
  • Have the update be optimistic so the UI feels snappy

In Tanstack, we use something called a mutation to help orchestrate this. Here is what it may look like:

import { useMutation, useQueryClient } from '@tanstack/react-query'
function useCompleteTask() {
const queryClient = useQueryClient()
return useMutation({
// The actual server-side mutation
mutationFn: taskId =>
fetch(`/api/tasks/${taskId}/complete`, { method: 'POST' }),
// Runs before the mutation happens
// Perfect place for optimistic updates
onMutate: async taskId => {
// Stop any in-flight refetches for tasks
await queryClient.cancelQueries({ queryKey: ['tasks'] })
// Snapshot the current state so we can roll back if needed
const previousTasks = queryClient.getQueryData(['tasks'])
// Optimistically update the cache
queryClient.setQueryData(['tasks'], tasks =>
tasks.map(task =>
task.id === taskId
? { ...task, completed: true }
: task
)
)
// Return context for error handling
return { previousTasks }
},
// If the mutation fails, roll back to the previous state
onError: (_error, _taskId, context) => {
queryClient.setQueryData(['tasks'], context.previousTasks)
},
// Runs whether the mutation succeeds or fails
// Used to bring everything back in sync to the server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['projectSummary'] })
queryClient.invalidateQueries({ queryKey: ['productivityStats'] })
}
})
}

It's a fair amount of code and might seem intimidating at first, but it's much less than if you built this yourself. Most of the extra code is for optimistic updates, which your app might not even need, but a fast, responsive UI is something most apps aim for.

When your app moves from just fetching data to managing lots of related server state, TanStack Query really shines and scales well. As your app grows, you'll probably want something like this, which is why many people use TanStack Query as their default.

So far, we've talked about the main pain point in data fetching: managing the state around it. But there's another big issue: API routes often lack type safety.

The Type Safety Problem

Even with TanStack Query, there's still a big issue:

There's no type-safe contract between your frontend and backend, and you have probably run into cases where you asked yourself?

  • "What was that route called again?"
  • "Did I typo this URL?"
  • "Wait... what does this endpoint actually return?"

TypeScript helps inside your app, but it doesn't protect the boundary between client and server.

tRPC

This is where tRPC comes in. It creates a type-safe boundary between your server and client. On the client side, it uses TanStack Query, so you get all its benefits. You always know what data you'll get back and never have to guess a route's name. It's a great tool, and if your app uses TypeScript on the backend, it's a popular choice.

Using tRPC has been my best experience for fetching data from server to client. The downside is that it takes some setup on your API side, which can be annoying. Luckily, there are plenty of templates to help you get started, so you don't have to write all the boilerplate yourself.

The main limitation of tRPC is that you need a TypeScript backend, which might not work for everyone, or you might not need one at all. For example, many mobile apps use databases like Supabase, which let you fetch data directly on the client. In those cases, you can use something like react-safe-query, which offers many of the same benefits as tRPC but doesn't require a TypeScript backend or any backend at all.

You can also use something like OpenAPI (not OpenAI) with React Safe Query, plain React, or TanStack Query. This helps with knowing the types for each API route, but the downside is you have to manually keep your API-generated code in sync with your server, which can be a hassle.

The Endpoint Proliferation Problem

Even after you solve fetching, caching, invalidation, optimistic updates, and type safety, there's still one big problem: managing lots of endpoints and how they relate. As your data gets more connected, one user action can trigger updates across many parts of your server state.

Take a notes app, for example. Opening or interacting with a note might:

  • Update the note content itself /api/notes/{id}
  • Update the last read, which may affect a recent activity endpoint like /api/notes/recent
  • Update tags related to note for categorization, which could affect /api/notes/tags
  • Update a status like if the note is "favorited," which could affect /api/notes/favorites

With TanStack Query, you can just invalidate everything linked to the notes query key, and it will all be refetched. But you still have to define many endpoints, which can be a hassle, especially if the backend and frontend teams are separate. This is part of why tools like GraphQL were created. The real challenge comes with optimistic updates, which most modern apps need. Without them, you'll see loading indicators everywhere when you update a note. To make optimistic updates work, you have to keep track of all the server data that needs to be updated or invalidated, and it can get messy fast.

Newer tools like Convex and TanStack DB are working to solve this problem, and they're doing a good job so far. Instead of keeping track of how all your data is connected or defining lots of endpoints, you just query what you need. When you make updates, these tools know what data is now outdated and needs to be refetched. They can even help manage optimistic states, so your UI stays fast without all the extra work.

Both Convex and TanStack DB are also working on better support for local-first apps, which is another complex topic. Still, it's something many applications really need.

The Recommendation: There Is No Best Way

There's no silver bullet.

If someone tells you there's only one 'correct' way to fetch data, they're either lying or trying to sell you something.

Personal Take on Type Safety

From my experience: if you can use the same language on both the frontend and backend, go for it.

Type safety between client and server is a big deal. It helps catch mistakes early and gives you confidence that you haven't mistyped an API route and always know what data you'll get back. The productivity boost is real.

Practical Advice

Usually, people say not to add abstractions too early in an app. But for data fetching, it can actually help to set them up early. Your app's needs can change as it grows, and new tools are always coming out that might make things easier. Just watch out for 'shiny object syndrome' and don't go overboard with abstractions. A good starting point is to have one hook per data resource, so you don't have to update lots of places if you change how you fetch data later.

TL;DR: Data fetching is tough. Every solution comes with its own trade-offs. Pick the one you understand, and move on.