Choosing RTK Query vs. Thunks for Server Data and How to Structure Client‑Only State
Decide whether to use RTK Query or hand‑written async thunks for server data, and how to structure client‑only state with Redux Toolkit. A compact comparison table, trade‑off discussion, and a typed store implementation are included.
15 Jan 2026, 01:07 UTC

Decision Context
When building a React + Redux application with Redux Toolkit (RTK), you must decide how to fetch server data and how to keep client‑only UI state in the store. The two mainstream options for server data are:
- RTK Query – a declarative, cache‑aware data layer built into RTK.
- Hand‑written async thunks – the classic
createAsyncThunkpattern.
Client‑only state (e.g., UI flags, wizard progress, or local selections) is typically kept in normal createSlice reducers. The store shape and middleware configuration are managed by configureStore, which wires up DevTools, thunk, and serializability checks by default.
Constraints & Decision Drivers
- Cache requirements: Do you need automatic caching, deduplication, and refetching based on server state?
- Invalidation granularity: Can you map server updates to tag‑based invalidation (e.g.,
providesTags/invalidatesTags)? - Bundle size sensitivity: Is the extra runtime cost of RTK Query acceptable?
- Imperative flows: Are there one‑off actions that don't fit a query/mutation pattern?
- Team familiarity: Does the team prefer explicit thunk flows or declarative hooks?
Options Comparison
| Feature | RTK Query | Async Thunks |
|---|---|---|
| Declarative API | ✓ Auto‑generated hooks, automatic loading/error states | ✗ Manual dispatch of pending/fulfilled/rejected actions |
| Cache & deduplication | ✓ Built‑in per‑endpoint cache, request deduping | ✗ Requires manual cache or local storage |
| Invalidation | ✓ Tag‑based providesTags / invalidatesTags | ✗ Manual refetch logic |
| Bundle size | +~150 KB (RTK + Immer + RTK Query) | +~80 KB (RTK + Immer) |
| Learning curve | ✓ Familiar hook pattern, but need to understand tags | ✓ Thunk pattern is familiar to Redux users |
| TypeScript support | ✓ Strongly typed queries/mutations, auto‑generated types | ✓ Custom types but more boilerplate |
| Server‑only data duplication | ✓ Avoids duplicate state – data lives in RTK Query cache | ✗ Risk of mirroring data in slices |
| Imperative flows | ✓ Mutations can be used but limited to defined endpoints | ✓ Full control over dispatch sequence |
Trade‑off Summary
- Use RTK Query when your server data can be expressed as queries/mutations with clear tag relationships. It eliminates boilerplate, provides caching, and keeps server data out of normal slices, reducing staleness bugs.
- Use async thunks for flows that don’t fit the query/mutation model (e.g., complex multi‑step processes, side‑effect‑heavy flows) or when you need fine‑grained control over the dispatch sequence.
- Keep client‑only UI state in
createSlicereducers. This state should not be duplicated in RTK Query’s cache. - Structure the store with
configureStoreto automatically add thunk middleware, DevTools, and serializability checks. Add the RTK Query API reducer underapi.reducerand its middleware underapi.middleware.
Concrete Implementation Example
Below is a minimal, type‑safe setup that demonstrates:
- Creating a UI slice for client‑only state.
- Setting up an RTK Query API with tag‑based invalidation.
- Configuring the store with
configureStore. - Using typed hooks in a component.
1. UI Slice (client‑only state)
import { createSlice } from '@reduxjs/toolkit';
interface UiState {
isModalOpen: boolean;
selectedItemId: string | null;
}
const initialState: UiState = {
isModalOpen: false,
selectedItemId: null,
};
export const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
openModal(state) {
state.isModalOpen = true;
},
closeModal(state) {
state.isModalOpen = false;
},
setSelectedItem(state, action) {
state.selectedItemId = action.payload;
},
},
});
export const { openModal, closeModal, setSelectedItem } = uiSlice.actions;
2. RTK Query API
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Item'],
endpoints: (builder) => ({
getItems: builder.query<Item[], void>({
query: () => '/items',
providesTags: ['Item'],
}),
updateItem: builder.mutation<Item, Partial<Item>>({
query: (data) => ({
url: `/items/${data.id}`,
method: 'PUT',
body: data,
}),
invalidatesTags: (result, error, arg) => [{ type: 'Item', id: arg.id }],
}),
}),
});
export const {
useGetItemsQuery,
useUpdateItemMutation,
} = api;
3. Store Configuration
import { configureStore } from '@reduxjs/toolkit';
import { uiSlice } from './uiSlice';
import { api } from './api';
export const store = configureStore({
reducer: {
ui: uiSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
4. Typed Hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
5. Component Usage
import React from 'react';
import { useAppDispatch, useAppSelector } from './hooks';
import { openModal, closeModal } from './uiSlice';
import { useGetItemsQuery } from './api';
const ItemList: React.FC = () => {
const dispatch = useAppDispatch();
const { data: items, isLoading, error } = useGetItemsQuery();
const isModalOpen = useAppSelector((s) => s.ui.isModalOpen);
if (isLoading) return <div>Loading…</div>;
if (error) return <div>Error loading items</div>;
return (
<div>
<ul>
{items?.map((item) => (
<li key={item.id}>
{item.name}
<button onClick={() => dispatch(openModal())}>Open</button>
</li>
))}</ul>
<Modal open={isModalOpen} onClose={() => dispatch(closeModal())}>
<div>Modal content here</div>
</Modal>
</div>
);
};
Validation Checklist
- Redux DevTools: Run the app and confirm that
GET /itemsappears as a single action. Re‑trigger the query and verify that no duplicate network request occurs (deduplication). - Cache invalidation: After calling
useUpdateItemMutation, check that theGET /itemsquery is automatically refetched due to tag invalidation. - Client‑only state: Dispatch
openModaland observe that the modal state changes without affecting the RTK Query cache. - Type safety: In the component, try to dispatch
openModalwith a wrong payload; TypeScript should raise an error. - Bundle size: Run
npm run buildand inspect the generated bundle withsource-map-explorerto confirm that the RTK Query portion is present and within acceptable limits.
Limitations & Caveats
- RTK Query’s cache is keyed per endpoint and serialized arguments. Highly dynamic keys can grow the cache; consider
keepUnusedDataForor manual invalidation. - Duplicating server data in both RTK Query and a slice leads to stale data; keep a single source of truth.
- RTK Query adds runtime overhead; for very small apps or bundles, a minimal thunk setup may be preferable.
- Version differences: the examples assume RTK 2.x. Check
npm ls @reduxjs/toolkitfor your installed version and adjust API calls accordingly.
Practical Decision Rule
Adopt the following rule of thumb:
- If your server data can be expressed as RESTful queries/mutations with clear entity tags, use RTK Query.
- Keep all UI‑only flags and transient state in
createSlicereducers. - Use async thunks only for flows that require imperative control (e.g., wizard navigation that depends on multiple sequential API calls).
Following this pattern yields a clean store shape, reduces boilerplate, and leverages RTK’s built‑in best practices.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.