React Data Fetching: Best Practices with TanStack, SWR & RTK Query

Fetching data in React is something every developer does, but the approach you take dramatically affects maintainability, performance, and developer experience.
Classic Approach: useEffect with Async/Await
A common pattern is using useEffect with async/await. It’s simple but quickly becomes limiting:
1import { useState, useEffect } from "react"; 2 3interface User { 4 id: number; 5 name: string; 6} 7 8function UserList() { 9 const [users, setUsers] = useState<User[]>([]); 10 const [loading, setLoading] = useState(true); 11 const [error, setError] = useState<Error | null>(null); 12 13 useEffect(() => { 14 const fetchUsers = async () => { 15 try { 16 const res = await fetch("/api/users"); 17 if (!res.ok) throw new Error("Network response was not ok"); 18 const data: User[] = await res.json(); 19 setUsers(data); 20 } catch (err: unknown) { 21 setError(err as Error); 22 } finally { 23 setLoading(false); 24 } 25 }; 26 27 fetchUsers(); 28 }, []); 29 30 if (loading) return <p>Loading...</p>; 31 if (error) return <p>Error: {error.message}</p>; 32 33 return ( 34 <ul> 35 {users.map(user => ( 36 <li key={user.id}>{user.name}</li> 37 ))} 38 </ul> 39 ); 40}
Why This Becomes Problematic
- No caching: Every mount triggers a fetch.
- No automatic refetch: Changes on the server require manual updates.
- Error and loading states must be implemented repeatedly.
- Race conditions and memory leaks can occur if the component unmounts during a fetch.
- No built-in invalidation or synchronization across components.
For small demos this is fine. For production apps, it quickly becomes unmanageable.
Dedicated Libraries: The Better Approach
Dedicated libraries abstract away repetitive fetching logic and add caching, invalidation, background updates, and mutation support. Here’s a rundown of the main options.
1. TanStack Query (formerly React Query)
TanStack Query is a feature-rich solution for server state management:
1import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 2 3interface User { 4 id: number; 5 name: string; 6} 7 8function UserList() { 9 const queryClient = useQueryClient(); 10 11 const { data: users, isLoading, error } = useQuery<User[], Error>( 12 ["users"], 13 async () => { 14 const res = await fetch("/api/users"); 15 if (!res.ok) throw new Error("Network error"); 16 return res.json(); 17 } 18 ); 19 20 const addUser = useMutation<User, Error, { name: string }>( 21 async newUser => { 22 const res = await fetch("/api/users", { 23 method: "POST", 24 body: JSON.stringify(newUser), 25 headers: { "Content-Type": "application/json" }, 26 }); 27 if (!res.ok) throw new Error("Failed to add user"); 28 return res.json(); 29 }, 30 { 31 onSuccess: () => queryClient.invalidateQueries(["users"]), 32 } 33 ); 34 35 if (isLoading) return <p>Loading...</p>; 36 if (error) return <p>Error: {error.message}</p>; 37 38 return ( 39 <div> 40 <ul> 41 {users?.map(user => ( 42 <li key={user.id}>{user.name}</li> 43 ))} 44 </ul> 45 <button onClick={() => addUser.mutate({ name: "New User" })}> 46 Add User 47 </button> 48 </div> 49 ); 50}
Advanced Features:
- Cache invalidation: Automatically or manually invalidate queries after mutations.
- Background refetching: Keeps stale data fresh without blocking UI.
- Pagination & infinite scrolling: Out-of-the-box support.
- Optimistic updates: Update the UI before server confirmation.
- Stale-while-revalidate: Data is instantly available from cache, then updated in background.
2. SWR (Stale-While-Revalidate)
SWR is lightweight and focuses on simplicity:
1import useSWR from "swr"; 2 3interface User { 4 id: number; 5 name: string; 6} 7 8const fetcher = async (url: string): Promise<User[]> => { 9 const res = await fetch(url); 10 if (!res.ok) throw new Error("Network error"); 11 return res.json(); 12}; 13 14function UserList() { 15 const { data: users, error, mutate } = useSWR<User[]>("/api/users", fetcher); 16 17 const addUser = async () => { 18 await fetch("/api/users", { 19 method: "POST", 20 body: JSON.stringify({ name: "New User" }), 21 headers: { "Content-Type": "application/json" }, 22 }); 23 mutate(); // revalidate cache 24 }; 25 26 if (!users) return <p>Loading...</p>; 27 if (error) return <p>Error: {error.message}</p>; 28 29 return ( 30 <div> 31 <ul> 32 {users.map(user => ( 33 <li key={user.id}>{user.name}</li> 34 ))} 35 </ul> 36 <button onClick={addUser}>Add User</button> 37 </div> 38 ); 39}
Key SWR Features:
- Automatic caching and revalidation.
- Optimistic UI with
mutate(). - Focus tracking: refetches when the tab/window comes into focus.
- Lightweight compared to TanStack Query.
3. RTK Query (Redux Toolkit Query)
RTK Query is perfect if your project already uses Redux:
1// apiSlice.ts 2import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; 3 4interface User { 5 id: number; 6 name: string; 7} 8 9export const api = createApi({ 10 reducerPath: "api", 11 baseQuery: fetchBaseQuery({ baseUrl: "/api" }), 12 tagTypes: ["Users"], 13 endpoints: builder => ({ 14 getUsers: builder.query<User[], void>({ 15 query: () => "users", 16 providesTags: ["Users"], 17 }), 18 addUser: builder.mutation<User, Partial<User>>({ 19 query: newUser => ({ 20 url: "users", 21 method: "POST", 22 body: newUser, 23 }), 24 invalidatesTags: ["Users"], 25 }), 26 }), 27}); 28 29export const { useGetUsersQuery, useAddUserMutation } = api; 30 31 32function UserList() { 33 const { data: users, isLoading } = useGetUsersQuery(); 34 const [addUser] = useAddUserMutation(); 35 36 if (isLoading) return <p>Loading...</p>; 37 38 return ( 39 <div> 40 <ul> 41 {users?.map(user => ( 42 <li key={user.id}>{user.name}</li> 43 ))} 44 </ul> 45 <button onClick={() => addUser({ name: "New User" })}>Add User</button> 46 </div> 47 ); 48}
Why RTK Query is powerful:
- Generates hooks automatically from endpoints.
- Integrates directly with Redux store.
- Automatic cache invalidation with
invalidatesTags. - Built-in support for optimistic updates and polling.
Takeaways
useEffect+fetchworks for small apps but doesn’t scale.- Dedicated libraries handle caching, background refetching, invalidation, optimistic updates, and mutations.
- Choice of library depends on your project:
- TanStack Query: Full-featured, framework-agnostic.
- SWR: Lightweight, simple, excellent for Next.js.
- RTK Query: Best for projects already using Redux.
Switching from useEffect to a dedicated solution reduces boilerplate, makes your components simpler, and gives your users a faster, more consistent experience.