Copyright © 2026 Juraj Hamran | Software Engineer
React Data Fetching: Best Practices with TanStack, SWR & RTK Query
Blog React Data Fetching: Best Practices with TanStack, SWR & RTK Query 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:
1 import { useState , useEffect } from "react" ;
2
3 interface User
Back to Blog
{
4
id
:
number
;
5
name
:
string
;
6
}
7
8
function
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:
1 import { useQuery , useMutation , useQueryClient } from "@tanstack/react-query" ;
2
3 interface User {
4 id : number ;
5 name : string ;
6 }
7
8 function 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 }
Copy
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:
1 import useSWR from "swr" ;
2
3 interface User {
4 id : number ;
5 name : string ;
6 }
7
8 const 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
14 function 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 }
Copy
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
2 import { createApi , fetchBaseQuery } from "@reduxjs/toolkit/query/react" ;
3
4 interface User {
5 id : number ;
6 name : string ;
7 }
8
9 export 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
29 export const { useGetUsersQuery , useAddUserMutation } = api ;
30
31
32 function 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 }
Copy 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 + fetch works 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.