Modern Redux with Redux Toolkit & RTK Query in 2025
Build Scalable React Apps with Modern State Management

If you were writing React around 2017 or 2018, you probably have some Redux-induced trauma. I certainly do. I remember staring at five different files just to add a simple counter increment: action types, action creators, a reducer with a massive switch statement, and maybe a thunk if I was feeling adventurous. It was… a lot.
Redux Toolkit (RTK) changed all of that. It’s now the official recommended way to write Redux, and honestly, it’s night and day compared to the old approach.
In this guide, I’ll show you how to build modern, scalable React applications using Redux Toolkit and RTK Query. We’ll cover everything from basic setup to advanced patterns, all with TypeScript examples.
noteHistorical Context: If you’re curious about the “old way” of doing Redux (or maintaining a legacy codebase), check out my article on classic Redux patterns. However, that article was more about managing the code than actually writing it. This article focuses exclusively on working with the modern approach.
Why Redux Toolkit?
The Redux team didn’t just ignore the “Redux is dead” tweets. They actually fixed the problems. Redux Toolkit (RTK) isn’t just a wrapper; it’s a complete reimagining of how we write Redux. It bundles the best practices I used to have to manually configure for every single client project:
- Simplified store setup with good defaults
- createSlice eliminates action types and creators
- Immer integration for “mutative” updates (actually immutable under the hood)
- Redux Thunk built-in for async logic
- RTK Query for data fetching and caching
- TypeScript support out of the box
The result? You write less code, make fewer mistakes, and ship faster.
Setting Up Redux Toolkit
Let’s get this running. It’s way simpler than it used to be.
First, install the packages:
npm install @reduxjs/toolkit react-reduxStore Configuration
The old way required configuring middleware, dev tools, and enhancers manually. RTK’s configureStore does it all:
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
import userReducer from './features/user/userSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
},
// Redux DevTools automatically enabled in development
// Thunk middleware included by default
});
// Infer types for TypeScript
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;That’s it. Redux DevTools, thunk middleware, and immutability checks are all configured automatically.
Provider Setup
Wire it up to React the same way as always:
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);Creating Slices: The Modern Way
A “slice” is a collection of Redux logic for a single feature. One file contains your initial state, reducers, and actions—no more spreading them across multiple files.
// src/store/features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
step: number;
}
const initialState: CounterState = {
value: 0,
step: 1,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
// Looks like mutation, but Immer makes it immutable
state.value += state.step;
},
decrement: (state) => {
state.value -= state.step;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
setStep: (state, action: PayloadAction<number>) => {
state.step = action.payload;
},
},
});
// Auto-generated action creators
export const { increment, decrement, incrementByAmount, setStep } = counterSlice.actions;
// Export reducer for store
export default counterSlice.reducer;Notice what’s missing? The boilerplate.
- No
const INCREMENT = 'INCREMENT'screaming at you. - No manual action creators.
- No switch statements (good riddance).
- No nervous
...statespreading to avoid accidental mutations.
RTK handles all of that. The “mutations” you see (state.value += state.step) are actually immutable updates thanks to Immer. It’s like having your cake and eating it too.
Using Slices in Components
TypeScript hooks for type safety:
// src/store/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
// Typed versions of hooks
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();Then use them in components:
// src/components/Counter.tsx
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { increment, decrement, incrementByAmount } from '../store/features/counter/counterSlice';
export default function Counter() {
const count = useAppSelector((state) => state.counter.value);
const step = useAppSelector((state) => state.counter.step);
const dispatch = useAppDispatch();
return (
<div>
<h2>Count: {count}</h2>
<button onClick={() => dispatch(increment())}>+{step}</button>
<button onClick={() => dispatch(decrement())}>-{step}</button>
<button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
</div>
);
}Async Logic with createAsyncThunk
For async operations (API calls, timers, etc.), use createAsyncThunk. It automatically dispatches pending/fulfilled/rejected actions:
// src/store/features/user/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
currentUser: User | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
currentUser: null,
loading: false,
error: null,
};
// Async thunk for fetching user
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return (await response.json()) as User;
}
);
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
logout: (state) => {
state.currentUser = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.currentUser = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Unknown error';
});
},
});
export const { logout } = userSlice.actions;
export default userSlice.reducer;Use it in components:
// src/components/UserProfile.tsx
import { useEffect } from 'react';
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { fetchUser } from '../store/features/user/userSlice';
export default function UserProfile({ userId }: { userId: string }) {
const { currentUser, loading, error } = useAppSelector((state) => state.user);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchUser(userId));
}, [userId, dispatch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!currentUser) return null;
return (
<div>
<h2>{currentUser.name}</h2>
<p>{currentUser.email}</p>
</div>
);
}tip
createAsyncThunkhandles the lifecycle for you: it dispatchespendingwhen the async function starts,fulfilledwhen it succeeds with the returned value, andrejectedif it throws.
RTK Query: Data Fetching Powerhouse
Here’s the thing: most of what we used Redux for was just caching server state. We’d fetch data, stick it in the store, and write a bunch of logic to track isLoading, isError, and data.
RTK Query retires all of that manual work. It’s a data fetching and caching tool built right into the toolkit. If you’ve used React Query (TanStack Query), this will feel very familiar.
RTK Query provides:
- Automatic caching and cache invalidation
- Request deduplication
- Polling and refetching
- Optimistic updates
- TypeScript code generation (from OpenAPI specs)
Setting Up an API
// src/store/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
interface User {
id: number;
name: string;
email: string;
}
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
tagTypes: ['Post', 'User'],
endpoints: (builder) => ({
// Query endpoints (GET)
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),
getPost: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
providesTags: (_result, _error, id) => [{ type: 'Post', id }],
}),
getUser: builder.query<User, number>({
query: (id) => `/users/${id}`,
providesTags: (_result, _error, id) => [{ type: 'User', id }],
}),
// Mutation endpoints (POST, PUT, DELETE)
createPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({
url: '/posts',
method: 'POST',
body,
}),
invalidatesTags: ['Post'],
}),
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
invalidatesTags: (_result, _error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation<void, number>({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _error, id) => [{ type: 'Post', id }],
}),
}),
});
// Auto-generated hooks
export const {
useGetPostsQuery,
useGetPostQuery,
useGetUserQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = api;Add the API reducer and middleware to your store:
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { api } from './services/api';
import counterReducer from './features/counter/counterSlice';
import userReducer from './features/user/userSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
[api.reducerPath]: api.reducer, // Add API reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware), // Add API middleware
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;Using RTK Query in Components
The generated hooks handle everything—loading states, caching, refetching:
// src/components/PostList.tsx
import { useGetPostsQuery, useDeletePostMutation } from '../store/services/api';
export default function PostList() {
const { data: posts, isLoading, isError, error } = useGetPostsQuery();
const [deletePost, { isLoading: isDeleting }] = useDeletePostMutation();
if (isLoading) return <div>Loading posts...</div>;
if (isError) return <div>Error: {error.toString()}</div>;
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
<button
onClick={() => deletePost(post.id)}
disabled={isDeleting}
>
Delete
</button>
</li>
))}
</ul>
);
}RTK Query automatically:
- Caches the result (subsequent renders don’t refetch)
- Deduplicates requests (if two components request the same data simultaneously, only one request fires)
- Invalidates cache when you delete a post (triggers refetch)
- Handles loading and error states
importantRTK Query replaces redux-saga for 90% of data fetching use cases. You only need saga if you have complex workflow orchestration, background tasks, or special retry logic.
Feature-Based Folder Structure
One mistake I remember from legacy codebases is organizing files by type (a folder for actions, a folder for reducers). I believe it was one of the most common ways from that time. Please don’t do that anymore.
Modern Redux is all about features. Keep everything related to a feature in one place. It makes the codebase so much easier to navigate when you’re jumping back into it after six months.
src/
├── store/
│ ├── index.ts # Store configuration
│ ├── hooks.ts # Typed hooks
│ ├── features/ # Feature slices
│ │ ├── counter/
│ │ │ └── counterSlice.ts
│ │ ├── user/
│ │ │ └── userSlice.ts
│ │ └── posts/
│ │ └── postsSlice.ts
│ └── services/ # RTK Query APIs
│ └── api.ts
├── components/
├── pages/
└── main.tsxEach feature folder contains everything related to that feature: slice, selectors, types, and optionally any feature-specific components.
For larger features, you might expand further:
src/store/features/posts/
├── postsSlice.ts # Slice definition
├── selectors.ts # Memoized selectors
├── types.ts # TypeScript types
└── thunks.ts # Complex async thunks (if needed)Selectors and Memoization
Selectors derive data from the store. For expensive computations, use createSelector from Reselect (built into RTK):
// src/store/features/posts/selectors.ts
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../../index';
// Simple selector (assuming state.posts has an 'items' array)
export const selectAllPosts = (state: RootState) => state.posts.items;
// Memoized selector - only recomputes when posts change
export const selectPublishedPosts = createSelector(
[selectAllPosts],
(posts) => posts.filter((post) => post.published)
);
// Memoized with multiple inputs
export const selectPostsByUser = createSelector(
[selectAllPosts, (_state: RootState, userId: number) => userId],
(posts, userId) => posts.filter((post) => post.userId === userId)
);Use in components:
import { useAppSelector } from '../store/hooks';
import { selectPublishedPosts, selectPostsByUser } from '../store/features/posts/selectors';
export default function PublishedPosts({ userId }: { userId: number }) {
// Memoized - only recalculates when posts change
const publishedPosts = useAppSelector(selectPublishedPosts);
// Pass arguments using a callback
const userPosts = useAppSelector((state) => selectPostsByUser(state, userId));
return <div>{/* render posts */}</div>;
}tipSelectors are critical for performance. They prevent unnecessary re-renders and expensive recalculations.
Advanced: Entity Adapters
For normalized state (think relational database), use createEntityAdapter. It provides CRUD operations and selectors out of the box:
// src/store/features/posts/postsSlice.ts
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';
interface Post {
id: number;
title: string;
body: string;
published: boolean;
}
// Entity adapter
const postsAdapter = createEntityAdapter<Post>({
selectId: (post) => post.id,
sortComparer: (a, b) => b.id - a.id, // Sort by newest first
});
const initialState = postsAdapter.getInitialState({
loading: false,
error: null as string | null,
});
// Async thunk
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await fetch('/api/posts');
return (await response.json()) as Post[];
});
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsReceived: postsAdapter.setAll,
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false;
postsAdapter.setAll(state, action.payload);
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch posts';
});
},
});
// Export actions
export const { postAdded, postUpdated, postRemoved, postsReceived } = postsSlice.actions;
// Export selectors
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds,
} = postsAdapter.getSelectors((state: { posts: typeof initialState }) => state.posts);
export default postsSlice.reducer;Entity adapters handle normalization automatically—no more hand-writing lookup logic.
When to Use Redux Toolkit vs. Other Solutions
RTK is great when you need:
- Shared global state across many components
- Complex state logic (multiple reducers, middleware)
- Centralized data fetching and caching (RTK Query)
- Time-travel debugging (Redux DevTools)
Consider alternatives when:
- Simple local state:
useStateoruseReducer - Server state only: React Query or SWR
- Component tree state: React Context API
- Form state: React Hook Form or Formik
Don’t reach for Redux just because it exists. Use it when the complexity justifies the abstraction.
The Bottom Line
Redux Toolkit transformed Redux from verbose and error-prone to elegant and productive. You write significantly less code, get better defaults, and maintain type safety with TypeScript.
- Use
configureStorefor automatic setup with DevTools and middleware - Build slices with
createSliceinstead of manual actions/reducers - Handle async logic with
createAsyncThunk - Replace most data fetching with RTK Query
- Organize by feature, not file type
- Use
createSelectorfor derived state - Leverage
createEntityAdapterfor normalized data
If you’re starting a new React project that needs global state management, Redux Toolkit is the modern standard. And if you’re maintaining legacy Redux code, RTK offers a gradual migration path without a complete rewrite.
The Redux ecosystem has matured beautifully. It went from being the tool we loved to hate, to a tool While my nine-to-five does not have React anymore, I might actually enjoy using again. The patterns are clearer, the code is cleaner, and I would spend way less time debugging typos in action strings.
Now go forth and delete some boilerplate!



