Introduction
Why Redux Toolkit?
Redux has been the de‑facto standard for managing complex state in React applications for years. However, a vanilla Redux setup often involves a lot of boilerplate-action types, action creators, reducers, and store configuration. Redux Toolkit (RTK) eliminates that noise by providing a curated set of APIs that follow the "Redux Essentials" guidelines.
What This Guide Covers
- Core concepts of RTK and how they differ from classic Redux.
- Practical best‑practice patterns for slices, selectors, and async logic.
- Scalable architecture recommendations for large codebases.
- Code snippets that you can copy‑paste into production projects.
- Frequently asked questions to clarify common pitfalls.
By the end of this article, you’ll be able to build a maintainable, performant state layer with confidence.
Setting Up Redux Toolkit
Installing the Essentials
bash npm install @reduxjs/toolkit react-redux
Tip: If you use TypeScript, also install the type definitions: bash npm install -D @types/react-redux
Configuring the Store
RTK’s configureStore abstracts away the need for manual middleware concatenation and DevTools setup.
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './features/user/userSlice';
import postsReducer from './features/posts/postsSlice';
export const store = configureStore({ reducer: { user: userReducer, posts: postsReducer, }, // Middleware is automatically added (thunk, serializableCheck, etc.) });
Provider Integration
Wrap your root component with the Redux Provider so any descendant can connect to the store.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <Provider store={store}> <App /> </Provider> ); );
Using the New createSlice API
createSlice bundles action creators and reducers into a single, type‑safe module.
import { createSlice } from '@reduxjs/toolkit';
const initialState = { name: '', email: '', status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' };
export const userSlice = createSlice({ name: 'user', initialState, reducers: { setUser(state, action) { // Immer lets us write "mutating" code safely state.name = action.payload.name; state.email = action.payload.email; }, clearUser(state) { state.name = ''; state.email = ''; }, setStatus(state, action) { state.status = action.payload; }, }, });
export const { setUser, clearUser, setStatus } = userSlice.actions; export default userSlice.reducer;
The above pattern is the foundation for every feature slice you’ll create.
Core Best Practices
Keep Slices Small and Focused
A slice should encapsulate a single domain concept (e.g., auth, cart, notifications). Avoid mixing unrelated state; this simplifies testing and future refactoring.
Naming Conventions
- Slice names: singular noun (
user,post). - Action creators: verb‑first (
addPost,removeUser). - Selectors:
select+ PascalCase (selectUserName).
Use Typed Selectors (TS/JS)
Never read the store directly in components. Instead, create memoized selectors with createSelector from Reselect (included with RTK).
import { createSelector } from '@reduxjs/toolkit';
const selectUser = (state) => state.user; export const selectUserName = createSelector( [selectUser], (user) => user.name );
Components then consume the selector via useSelector:
import { useSelector } from 'react-redux';
import { selectUserName } from '../features/user/userSlice';
function Greeting() { const name = useSelector(selectUserName); return <h2>Hello, {name || 'Guest'}!</h2>; }
Prefer createAsyncThunk for Side Effects
Encapsulate API calls with createAsyncThunk. This keeps async logic out of components and centralizes error handling.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import api from '../../api';
export const fetchPosts = createAsyncThunk( 'posts/fetchPosts', async (_, { rejectWithValue }) => { try { const response = await api.get('/posts'); return response.data; } catch (err) { return rejectWithValue(err.response.data); } } );
const postsSlice = createSlice({ name: 'posts', initialState: { items: [], status: 'idle', error: null }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchPosts.pending, (state) => { state.status = 'loading'; }) .addCase(fetchPosts.fulfilled, (state, action) => { state.status = 'succeeded'; state.items = action.payload; }) .addCase(fetchPosts.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload; }); }, });
export default postsSlice.reducer;
Dispatching Thunks in Components
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPosts } from '../features/posts/postsSlice';
import { selectPosts, selectPostsStatus } from '../features/posts/selectors';
function PostsList() { const dispatch = useDispatch(); const posts = useSelector(selectPosts); const status = useSelector(selectPostsStatus);
useEffect(() => { if (status === 'idle') dispatch(fetchPosts()); }, [status, dispatch]);
if (status === 'loading') return <p>Loading…</p>; if (status === 'failed') return <p>Error loading posts.</p>; return ( <ul> {posts.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> ); }
Normalize Data When Appropriate
For collections larger than a handful of items, store data in a normalized shape (e.g., using createEntityAdapter). This improves lookups and simplifies updates.
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
const commentsAdapter = createEntityAdapter({ selectId: (comment) => comment.id, sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt), });
const initialState = commentsAdapter.getInitialState({ status: 'idle' });
const commentsSlice = createSlice({ name: 'comments', initialState, reducers: { addComment: commentsAdapter.addOne, updateComment: commentsAdapter.updateOne, removeComment: commentsAdapter.removeOne, }, });
export const { addComment, updateComment, removeComment } = commentsSlice.actions; export default commentsSlice.reducer;
The adapter automatically generates selectors (selectAll, selectById, etc.) that you can re‑export.
Scalable Architecture with Redux Toolkit
Feature‑Folder Structure
Organizing code by feature rather than by type (actions, reducers) keeps related files together and reduces import noise.
src/ │ app/ │ └─ store.
│
└─ features/
├─ auth/
│ ├─ authSlice.js
│ ├─ authAPI.js
│ └─ selectors.js
├─ cart/
│ ├─ cartSlice.js
│ └─ selectors.js
└─ posts/
├─ postsSlice.js
├─ postsAPI.js
└─ selectors.js
Each folder encapsulates its slice, async thunks, and selectors, making it straightforward to add, remove, or refactor a domain.
Layered Approach: UI ↔︎ Logic ↔︎ Store
- UI Layer - React components use only
useSelectoranduseDispatch. - Logic Layer - Custom hooks (e.g.,
usePosts) wrap dispatch calls and expose a clean API. - Store Layer - Slices, thunks, and adapters remain pure and testable.
Example Custom Hook
import { useDispatch, useSelector } from 'react-redux';
import { fetchPosts } from '../features/posts/postsSlice';
import { selectAllPosts, selectPostsStatus } from '../features/posts/selectors';
export function usePosts() { const dispatch = useDispatch(); const posts = useSelector(selectAllPosts); const status = useSelector(selectPostsStatus);
const load = () => dispatch(fetchPosts());
return { posts, status, load }; }
Components now stay declarative:
function PostsPage() {
const { posts, status, load } = usePosts();
useEffect(() => { if (status === 'idle') load(); }, [status, load]);
// render UI
}
Testing Strategy
- Unit tests for reducers using the slice’s exported reducer function.
- Integration tests for thunks with a mocked store (
@reduxjs/toolkit'sconfigureStore). - Component tests that rely on selectors only - no need to mock the entire slice.
import postsReducer, { setStatus } from './postsSlice';
test('should set loading status', () => { const initial = { items: [], status: 'idle' }; const next = postsReducer(initial, setStatus('loading')); expect(next.status).toBe('loading'); });
Performance Tips
- Use memoized selectors to prevent unnecessary re‑renders.
- Keep slice state flat; deep nesting defeats Immer's optimization.
- Leverage
createEntityAdapterfor large collections. - Avoid storing derived data (e.g., filtered lists) in the store; compute them in selectors.
FAQs
Frequently Asked Questions
1️⃣ Do I still need redux-thunk when using Redux Toolkit?
Yes and no. RTK ships with redux-thunk pre‑configured, so you get the same capabilities without extra installation. For most apps, the built‑in createAsyncThunk is sufficient.
2️⃣ When should I avoid using createSlice?
If you need to share a reducer across multiple slices or implement highly custom middleware logic, you might opt for a plain reducer. However, such scenarios are rare; createSlice covers 95% of typical use‑cases.
3️⃣ Is it safe to mutate state inside reducers?
Absolutely. RTK uses Immer under the hood, which records mutations and produces an immutable copy. This lets you write concise, readable code while preserving Redux’s immutability guarantees.
4️⃣ How does Redux Toolkit differ from react-query?
Redux Toolkit focuses on global client‑side state (ui flags, auth, forms), whereas react-query excels at server‑state caching, pagination, and background refetching. They can coexist: store UI‑centric state in RTK and delegate data fetching to react-query when appropriate.
5️⃣ Can I use Redux Toolkit with Next.js?
Yes. Initialise the store in a separate module and wrap the custom _app.js with Provider. For server‑side rendering, use next-redux-wrapper to hydrate the store per request.
Conclusion
Bringing It All Together
Redux Toolkit transforms the traditionally verbose Redux workflow into a streamlined, opinionated experience. By adhering to the best practices outlined-small feature‑focused slices, typed selectors, createAsyncThunk for side effects, and a clean feature‑folder architecture-you’ll gain:
- Reduced boilerplate that accelerates development.
- Predictable state that scales with your application’s complexity.
- Improved performance via memoized selectors and normalized data structures.
- Easier testing thanks to pure reducers and encapsulated thunks.
Remember that tools are only as good as the patterns they support. Treat RTK as a foundation, not a silver bullet, and complement it with thoughtful UI layer design, proper error handling, and a robust testing suite.
Ready to level up your React apps? Start by refactoring an existing Redux module into a slice, introduce createAsyncThunk for API calls, and watch the codebase become cleaner and more maintainable.
Happy coding!
