← Back to all blogs
Redux Toolkit Best Practices – Complete Guide
Sat Feb 28 20268 minIntermediate

Redux Toolkit Best Practices – Complete Guide

Learn how to use Redux Toolkit efficiently with best‑practice guidelines, architecture insights, and real‑world code samples.

#redux toolkit#react#state management#best practices#frontend architecture

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

  1. UI Layer - React components use only useSelector and useDispatch.
  2. Logic Layer - Custom hooks (e.g., usePosts) wrap dispatch calls and expose a clean API.
  3. 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's configureStore).
  • 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 createEntityAdapter for 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!