RTK Query vs React Query

When building dynamic React applications, efficient server-state management is crucial for optimal performance and user experience. For smaller projects, straightforward solutions like Axios work perfectly fine. However, as projects grow more complex—especially at the enterprise level—you might need more powerful tools that handle data fetching and caching seamlessly.
React Query (also known as TanStack Query) and RTK Query are two popular solutions. Both simplify data handling in React applications, but they differ in underlying philosophies, setups, and how they integrate into your application.
In this article, I will compare React Query and RTK Query based on their features, ease of use, and ideal use cases, helping you determine which tool best suits your project’s needs.
Introduction
React Query is an independent library that excels at data fetching, caching, and synchronizing server state with your UI. It is known for its simplicity and highly customizable hooks.
RTK Query, on the other hand, comes bundled with Redux Toolkit. If you’re already using Redux for global state management, RTK Query integrates seamlessly, reducing boilerplate and complexity when handling asynchronous operations.
Under the Hood
React Query
- Smart caching: Caches responses to avoid redundant network requests.
- Automatic updates: Refreshes stale data in the background so the UI always shows fresh content.
- Garbage collection: Removes unused or outdated data automatically to optimize resource usage.
RTK Query
- Redux integration: Extends Redux Toolkit for data fetching, letting you manage server-state alongside client-state in one place.
- Efficiency: Minimizes unnecessary refetching and re-renders.
- Caching and re-fetching: Stores and reuses fetched data, refetching only when it becomes stale or invalidated.
Common Features
Both React Query and RTK Query provide:
- Automatic Caching: Previously fetched data is stored and reused.
- Background Synchronization: Data can be refetched automatically without blocking the UI.
- Optimistic Updates: The UI can update instantly as if a mutation succeeded, then roll back if the server request fails.
Example Scenario
Imagine we need to fetch a list of users from an API endpoint (/users
) and display them in our application. We have a User
type and a simple UserList
component.
// ../types.ts
// Define a User type that matches the API's response structure.
export type User = {
id: number
firstName: string
lastName: string
email: string
phoneNumber: string
}
import React from "react"
import { User } from "../types"
// The UserList component receives an array of users and displays them.
type UserListProps = {
users: User[]
}
export const UserList: React.FC<UserListProps> = ({ users }) => {
return (
<div>
{users.map((user) => (
<div key={user.id}>
{user.firstName} {user.lastName}
</div>
))}
</div>
)
}
API Services
React Query Service Example:
// ../react-query/api.ts
// This function fetches users from the server and returns them as an array of User objects.
import { User } from "../types"
export const fetchUsers = async (): Promise<User[]> => {
const response = await fetch("http://localhost:8000/users")
if (!response.ok) {
throw new Error("Network response was not ok")
}
return response.json()
}
RTK Query Service Example:
// ../rtk-query/api.ts
// Here, we define a service using RTK Query's createApi. It automatically generates hooks for data fetching.
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
import { User } from "../types"
export const usersAPI = createApi({
reducerPath: "usersAPI", // A unique key for this API slice in the Redux store
baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:8000/" }), // The base URL for all requests
endpoints: (builder) => ({
getAllUsers: builder.query<User[], void>({
query: () => "users", // The endpoint to fetch all users
}),
}),
})
// Automatically generated hook to fetch users
export const { useGetAllUsersQuery } = usersAPI
Data Fetching and State Management
React Query handles fetching and caching internally without relying on global state. It uses hooks like useQuery
for fetching and useMutation
for creating, updating, or deleting data.
RTK Query integrates neatly with Redux Toolkit. It uses a similar approach but stores fetched data in the Redux store. If your app already uses Redux, this can greatly simplify how you handle server-state.
Fetching Data in the UI
React Query Example:
import React from "react"
import { useQuery } from "react-query"
import { fetchUsers } from "./api"
import { UserList } from "./UserList"
// This component fetches data from the server and passes it to UserList.
export const UsersDataContainer: React.FC = () => {
// useQuery fetches data on mount and provides loading and error states.
const { data: users, isLoading, isError } = useQuery(["users"], fetchUsers)
if (isLoading) return <div>Loading...</div>
if (isError) return <div>Error loading users.</div>
// Pass the fetched data to UserList. If data is undefined, fallback to an empty array.
return <UserList users={users || []} />
}
RTK Query Example:
import React from "react"
import { useGetAllUsersQuery } from "./api"
import { UserList } from "./UserList"
// Similar to React Query, but the data comes from Redux store managed by RTK Query.
export const UsersDataContainer: React.FC = () => {
const { data: users, isLoading, isError } = useGetAllUsersQuery()
if (isLoading) return <div>Loading...</div>
if (isError || !users) return <div>Error loading users.</div>
return <UserList users={users} />
}
Automatic Caching and Background Updates
Both libraries cache fetched data automatically. They also support background refetching to keep data fresh.
React Query: Configure features like refetchInterval
and refetchOnWindowFocus
to determine when data should be automatically refreshed.
RTK Query: Use options like pollingInterval
to periodically refetch data.
Example: Background Updates
React Query with refetchInterval:
import React from "react"
import { useQuery } from "react-query"
import { fetchUsers } from "./api"
import { UserList } from "./UserList"
export const UsersDataContainer: React.FC = () => {
const { data: users, isLoading, isError } = useQuery(["users"], fetchUsers, {
refetchInterval: 60000, // Refetch data every 60 seconds
refetchOnWindowFocus: true, // Refetch when the window regains focus
})
if (isLoading) return <div>Loading...</div>
if (isError) return <div>Error loading users.</div>
return <UserList users={users || []} />
}
RTK Query with pollingInterval:
import React from "react"
import { useGetAllUsersQuery } from "./api"
import { UserList } from "./UserList"
// RTK Query allows polling the endpoint at a given interval.
export const UsersDataContainer: React.FC = () => {
const { data: users, isLoading, isError } = useGetAllUsersQuery(undefined, {
pollingInterval: 60000, // Refetch every 60 seconds
})
if (isLoading) return <div>Loading...</div>
if (isError || !users) return <div>Error loading users.</div>
return <UserList users={users} />
}
State Management Integration
React Query:
No global state manager required. The library manages its own internal cache and states.
RTK Query:
Built on top of Redux Toolkit. Integrating into your Redux store is simple:
// ../rtk-query/store.ts
// This sets up a Redux store with the RTK Query reducer and middleware.
import { configureStore } from "@reduxjs/toolkit"
import { usersAPI } from "./api"
import { setupListeners } from "@reduxjs/toolkit/query"
export const store = configureStore({
reducer: {
[usersAPI.reducerPath]: usersAPI.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(usersAPI.middleware),
})
// This enables features like refetchOnFocus/refetchOnReconnect
setupListeners(store.dispatch)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Declarative APIs
Both solutions are declarative, letting you specify what data to fetch rather than how to fetch it.
- React Query: Minimal setup. Just define a query function and pass it to
useQuery
. - RTK Query: Define endpoints and rely on generated hooks. If you’re already using Redux, it provides a clean, integrated solution.
Query Invalidation and Optimistic Updates
Query Invalidation: Trigger refetches of outdated data whenever mutations occur.
Optimistic Updates: Immediately update the UI as if a mutation succeeded, then revert if the server fails. This approach improves user experience by making the app feel more responsive.
Optimistic Updates with React Query
// ../react-query/api.ts
// Example mutation function for adding a new user.
export const addUser = async (userData: User) => {
const response = await fetch("http://localhost:8000/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData),
})
if (!response.ok) {
throw new Error("Failed to add user.")
}
return response.json()
}
import React, { useState } from "react"
import { useMutation, useQueryClient } from "react-query"
import { addUser } from "./api"
import { User } from "../types"
// This component uses optimistic updates:
// 1. Immediately updates the UI to show the new user before the server confirms.
// 2. If the server request fails, the UI reverts to the previous state.
const AddUserComponent = () => {
const [userData, setUserData] = useState<User>({
id: 0,
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
})
const queryClient = useQueryClient()
// Setup the mutation with optimistic updates.
const { mutateAsync, isLoading, isError, error } = useMutation(addUser, {
onMutate: async (newUser) => {
// Cancel any ongoing fetches for "users" to avoid overwriting our optimistic update
await queryClient.cancelQueries("users")
// Get the current cached data and remember it to revert if something goes wrong
const previousUsers = queryClient.getQueryData<User[]>(["users"]) || []
// Optimistically update the cached data by adding the new user
queryClient.setQueryData(["users"], [...previousUsers, newUser])
// Return the old data so we can roll back if needed
return { previousUsers }
},
onError: (err, newUser, context: any) => {
// Roll back to the previous users array if the mutation fails
queryClient.setQueryData(["users"], context.previousUsers)
},
onSettled: () => {
// Refetch the data from the server to ensure it's up-to-date
queryClient.invalidateQueries(["users"])
},
})
const handleAddUser = async () => {
await mutateAsync(userData)
}
return (
<div>
<h2>Add User</h2>
<input
value={userData.firstName}
onChange={(e) =>
setUserData({ ...userData, firstName: e.target.value })
}
placeholder="First Name"
/>
<input
value={userData.lastName}
onChange={(e) =>
setUserData({ ...userData, lastName: e.target.value })
}
placeholder="Last Name"
/>
<input
value={userData.email}
onChange={(e) =>
setUserData({ ...userData, email: e.target.value })
}
placeholder="Email"
/>
<input
value={userData.phoneNumber}
onChange={(e) =>
setUserData({ ...userData, phoneNumber: e.target.value })
}
placeholder="Phone Number"
/>
<button onClick={handleAddUser} disabled={isLoading}>
Add User
</button>
{isError && <div>Error: {error?.toString()}</div>}
</div>
)
}
Optimistic Updates with RTK Query
// Within the RTK Query API, define a mutation endpoint for adding a user.
// This endpoint uses tag invalidation and the onQueryStarted lifecycle hook
// for optimistic updates.
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
import { User } from "../types"
export const usersAPI = createApi({
reducerPath: "usersAPI",
baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:8000/" }),
tagTypes: ["Users"],
endpoints: (builder) => ({
getAllUsers: builder.query<User[], void>({
query: () => "users",
providesTags: ["Users"],
}),
addUser: builder.mutation<User, User>({
query: (newUser) => ({
url: "users",
method: "POST",
body: newUser,
}),
invalidatesTags: ["Users"],
onQueryStarted: async (userData, { dispatch, queryFulfilled }) => {
// Optimistically update the cache by modifying the existing data
const patchResult = dispatch(
usersAPI.util.updateQueryData("getAllUsers", undefined, (draft) => {
return [...draft, userData]
})
)
try {
// Wait for the server to confirm the mutation
await queryFulfilled
// Once confirmed, invalidate tags to refetch the updated list of users
dispatch(usersAPI.util.invalidateTags(["Users"]))
} catch {
// If the mutation fails, roll back the optimistic update
patchResult.undo()
}
},
}),
}),
})
export const { useGetAllUsersQuery, useAddUserMutation } = usersAPI
import React, { useState } from "react"
import { User } from "../types"
import { useAddUserMutation } from "./api"
// This component demonstrates optimistic updates with RTK Query.
// It updates the user list optimistically upon submission, and reverts if the server call fails.
const AddUserComponent = () => {
const [userData, setUserData] = useState<User>({
id: 0,
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
})
const [addUser, { isLoading, isError, error }] = useAddUserMutation()
const handleAddUser = async () => {
await addUser(userData)
}
return (
<div>
<h2>Add User</h2>
<input
value={userData.firstName}
onChange={(e) =>
setUserData({ ...userData, firstName: e.target.value })
}
placeholder="First Name"
/>
<input
value={userData.lastName}
onChange={(e) =>
setUserData({ ...userData, lastName: e.target.value })
}
placeholder="Last Name"
/>
<input
value={userData.email}
onChange={(e) => setUserData({ ...userData, email: e.target.value })}
placeholder="Email"
/>
<input
value={userData.phoneNumber}
onChange={(e) =>
setUserData({ ...userData, phoneNumber: e.target.value })
}
placeholder="Phone Number"
/>
<button onClick={handleAddUser} disabled={isLoading}>
Add User
</button>
{isError && <div>Error: {error?.toString()}</div>}
</div>
)
}
Developer Experience
Feature | React Query | RTK Query |
---|---|---|
Ease of Integration | Standalone, works with any React project. | Best if already using Redux; adds complexity if not using Redux. |
Learning Curve | Simple and intuitive for React devs. | Requires familiarity with Redux patterns. |
Documentation and Community | Extensive, well-organized docs, large community. | Strong docs and large Redux ecosystem support. |
Conclusion
Choose React Query if you do not use Redux and want a lightweight, straightforward solution. It is easy to integrate and understand.
Choose RTK Query if your app already uses Redux, or if you prefer managing all application state (client and server) from a single store. RTK Query helps reduce boilerplate and ensures a clean, unified state management experience.
Both React Query and RTK Query offer robust capabilities to improve developer productivity and application quality. Your choice will depend on your project’s architecture, existing tooling, and team preferences.
Further Reading
Credits
Photo by Ferenc Almasi on Unsplash.