← Back to all blogs
Redux Toolkit Best Practices - Step-by-Step Tutorial
Sat Feb 28 202610 minIntermediate

Redux Toolkit Best Practices - Step-by-Step Tutorial

A detailed, SEO‑optimized tutorial that walks you through Redux Toolkit best practices, from initial setup to advanced architecture, with real code snippets and a FAQ section.

#redux#redux toolkit#state management#react#javascript#web development

Introduction

Why Redux Toolkit Matters

Redux Toolkit (RTK) has become the de‑facto standard for managing state in modern React applications. It eliminates boilerplate, provides a powerful createSlice API, and bundles best‑in‑class middleware such as redux-thunk out of the box. This tutorial - over 1,500 words - explores the most reliable practices for building scalable, maintainable stores.

Who Should Read This

If you already know basic Redux concepts and want to upgrade to RTK, or if you’re starting a new React project and aim for a clean architecture from day one, this guide is for you. The steps are illustrated with concise code examples and an architectural overview that clarifies where each piece fits.

What You’ll Gain

  • A reproducible project structure
  • Patterns for async logic, entity management, and testing
  • Guidance on avoiding common pitfalls like mutable state and over‑nesting
  • Answers to the most frequently asked questions about RTK

Let’s dive into the setup before we discuss best practices.

Getting Started with Redux Toolkit

Project Scaffold

bash npx create-react-app rtk-best‑practices --template typescript cd rtk-best‑practices npm install @reduxjs/toolkit react-redux

Folder Layout

src/ │ app/ │ │ store.ts # RTK store configuration │ │ rootReducer.ts # Combined reducers (optional) │ │ │ features/ │ │ todos/ │ │ │ todosSlice.ts │ │ │ TodosList.tsx │ │ │ AddTodoForm.tsx │ │ │ todosAPI.ts # Async thunks │ │ │ │ │ users/ │ │ usersSlice.ts │ │ UsersTable.tsx │ │ usersAPI.ts │ │ │ components/ │ │ ... │ │ index.tsx

The features folder groups related slices, UI components, and API logic together. This locality makes the codebase discoverable and limits the number of imports per file.

Store Configuration

ts // src/app/store.ts import { configureStore } from '@reduxjs/toolkit'; import todosReducer from '../features/todos/todosSlice'; import usersReducer from '../features/users/usersSlice';

export const store = configureStore({ reducer: { todos: todosReducer, users: usersReducer, }, // Enable Redux DevTools only in development devTools: process.env.NODE_ENV !== 'production', });

export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;

Notice the omission of manual applyMiddleware; RTK adds redux-thunk by default. If you need additional middleware, pass it through the middleware option.

Connecting React

tsx // src/index.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import { store } from './app/store'; import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( <React.StrictMode> <Provider store={store}> <App /> </Provider> </React.StrictMode> );

With the store wired, you can safely use useSelector and useDispatch throughout the component tree.

First Slice - Todos

ts // src/features/todos/todosSlice.ts import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { fetchTodos } from './todosAPI';

export interface Todo { id: string; title: string; completed: boolean; }

interface TodosState { items: Todo[]; loading: boolean; error?: string; }

const initialState: TodosState = { items: [], loading: false, };

export const loadTodos = createAsyncThunk('todos/fetch', async () => { const response = await fetchTodos(); return response as Todo[]; });

const todosSlice = createSlice({ name: 'todos', initialState, reducers: { addTodo: (state, action: PayloadAction<Todo>) => { state.items.push(action.payload); }, toggleTodo: (state, action: PayloadAction<string>) => { const todo = state.items.find(t => t.id === action.payload); if (todo) todo.completed = !todo.completed; }, deleteTodo: (state, action: PayloadAction<string>) => { state.items = state.items.filter(t => t.id !== action.payload); }, }, extraReducers: builder => { builder .addCase(loadTodos.pending, state => { state.loading = true; state.error = undefined; }) .addCase(loadTodos.fulfilled, (state, action) => { state.loading = false; state.items = action.payload; }) .addCase(loadTodos.rejected, (state, action) => { state.loading = false; state.error = action.error.message; }); }, });

export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions; export default todosSlice.reducer;

This slice demonstrates immutable updates using Immer (built‑in), async thunk handling, and TypeScript safety.

Hook‑Based Typing

ts // src/app/hooks.ts import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from './store';

export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Exporting these custom hooks eliminates repetitive type annotations in every component.

Core Best Practices

Keep Slices Small and Focused

A slice should represent a single domain concept-todos, users, settings, etc. Avoid mixing unrelated state. Small slices improve testability and make it trivial to replace a slice if requirements evolve.

Prefer createEntityAdapter for Collections

When dealing with large arrays (e.g., a list of users), createEntityAdapter normalizes data and provides selectors like selectAll and selectById. This pattern reduces selector complexity and improves performance.

ts // src/features/users/usersSlice.ts import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit'; import { fetchUsers } from './usersAPI';

export interface User { id: string; name: string; email: string; }

const usersAdapter = createEntityAdapter<User>();

export const loadUsers = createAsyncThunk('users/fetch', async () => { const data = await fetchUsers(); return data as User[]; });

const usersSlice = createSlice({ name: 'users', initialState: usersAdapter.getInitialState({ loading: false, error?: string }), reducers: { addUser: usersAdapter.addOne, updateUser: usersAdapter.updateOne, removeUser: usersAdapter.removeOne, }, extraReducers: builder => { builder .addCase(loadUsers.pending, state => { state.loading = true; }) .addCase(loadUsers.fulfilled, (state, action) => { state.loading = false; usersAdapter.setAll(state, action.payload); }) .addCase(loadUsers.rejected, (state, action) => { state.loading = false; state.error = action.error.message; }); }, });

export const usersSelectors = usersAdapter.getSelectors<RootState>(state => state.users); export const { addUser, updateUser, removeUser } = usersSlice.actions; export default usersSlice.reducer;

Centralize Async Logic in Thunks

Do not place API calls directly inside components. Keep them inside createAsyncThunk or custom middleware. This separation enhances reusability and simplifies unit tests.

Use Selectors for Derived Data

Components should rely on memoized selectors (via createSelector from Reselect) rather than compute derived values on each render.

ts // src/features/todos/todosSelectors.ts import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '../../app/store';

const selectTodos = (state: RootState) => state.todos.items;

export const selectCompletedTodos = createSelector([ selectTodos, ], todos => todos.filter(t => t.completed));

Avoid Over‑Using any

Leverage TypeScript’s inference throughout slices, thunks, and components. Explicit typing prevents runtime bugs and improves IDE autocompletion.

Keep Reducer Logic Pure

Even though RTK uses Immer, you should still avoid side‑effects in reducers (e.g., console.log or network calls). Side‑effects belong in thunks or middleware.

Export Only What Is Needed

Limit public API of a feature folder to the slice reducer, actions, and selectors. Hide internal utilities to preserve encapsulation.

Testing Recommendations

  • Slice Tests - Verify reducer behavior with known actions.
  • Thunk Tests - Mock API calls and assert dispatch sequences.
  • Component Tests - Use react-testing-library with Provider and mock store values.

Sample slice test:

ts // src/features/todos/todosSlice.test.ts import reducer, { addTodo, toggleTodo, deleteTodo, loadTodos } from './todosSlice'; import { Todo } from './todosSlice';

const initial = { items: [], loading: false } as any;

it('adds a todo', () => { const newTodo: Todo = { id: '1', title: 'Write test', completed: false }; const state = reducer(initial, addTodo(newTodo)); expect(state.items).toHaveLength(1); expect(state.items[0]).toEqual(newTodo); });

These practices keep the codebase predictable and future‑proof.

Advanced Architecture Patterns

Feature‑Based Module Boundaries

Instead of a monolithic store folder, adopt a feature‑driven boundary. Each feature owns its slice, API layer, UI components, and tests. This mirrors domain‑driven design and facilitates lazy‑loading.

Lazy‑Loaded Slices with injectReducer

For large apps, load slices only when the related route mounts.

ts // src/app/store.ts (modified) import { combineReducers, configureStore } from '@reduxjs/toolkit';

const staticReducers = { // static reducers go here };

type ReducerMap = typeof staticReducers;

export const store = configureStore({ reducer: staticReducers, });

export const injectReducer = (key: string, asyncReducer: any) => { if (!store.asyncReducers) store.asyncReducers = {} as ReducerMap; if (!store.asyncReducers[key]) { store.asyncReducers[key] = asyncReducer; store.replaceReducer(combineReducers({ ...staticReducers, ...store.asyncReducers })); } };

Use injectReducer inside a route component:

tsx // src/features/todos/TodosPage.tsx import React, { useEffect } from 'react'; import { useAppDispatch } from '../../app/hooks'; import { injectReducer } from '../../app/store'; import todosReducer from './todosSlice';

const TodosPage: React.FC = () => { const dispatch = useAppDispatch(); useEffect(() => { injectReducer('todos', todosReducer); dispatch(loadTodos()); }, [dispatch]); // render UI … return <div></div>; };

export default TodosPage;

Using RTK Query for Data Fetching

RTK Query, shipped with RTK, abstracts data fetching, caching, and invalidation.

ts // src/services/api.ts import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), tagTypes: ['Todo', 'User'], endpoints: builder => ({ getTodos: builder.query<Todo[], void>({ query: () => 'todos', providesTags: result => result ? result.map(({ id }) => ({ type: 'Todo' as const, id })) : [{ type: 'Todo', id: 'LIST' }], }), addTodo: builder.mutation<Todo, Partial<Todo>>({ query: body => ({ url: 'todos', method: 'POST', body }), invalidatesTags: [{ type: 'Todo', id: 'LIST' }], }), }), });

export const { useGetTodosQuery, useAddTodoMutation } = api;

Add api.reducer to the store configuration and api.middleware to the middleware array. RTK Query removes the need for manual thunks in many cases and ensures consistent cache handling.

State Versioning & Migration

When your application evolves, introduce a version field inside the persisted slice. Write a migration function that runs during store initialization.

ts // src/app/persistedReducer.ts import { combineReducers } from '@reduxjs/toolkit'; import storage from 'redux-persist/lib/storage'; import { persistReducer } from 'redux-persist'; import todosReducer from '../features/todos/todosSlice';

const rootReducer = combineReducers({ todos: todosReducer });

const persistConfig = { key: 'root', storage, version: 2, // increment when schema changes migrate: (state, version) => { if (version < 2) { // Example: rename field completed to isDone const updated = { ...state }; if (updated.todos?.items) { updated.todos.items = updated.todos.items.map((t: any) => ({ ...t, isDone: t.completed, completed: undefined, })); } return Promise.resolve(updated); } return Promise.resolve(state); }, };

export const persistedReducer = persistReducer(persistConfig, rootReducer);

Persisted state stays compatible across releases, preventing crashes for returning users.

Performance Tips

  • Use select from react-redux - it shallowly compares selected values, reducing unnecessary renders.
  • Batch Dispatches - when performing multiple state changes, wrap them in a single dispatch or leverage the batch API from react-redux.
  • Avoid Large Objects in State - keep only serializable primitives and IDs; store heavy data outside Redux (e.g., component local state or context).

FAQs

1. Do I still need redux-thunk with Redux Toolkit?

No. RTK includes redux-thunk by default, so you can write async logic with createAsyncThunk without extra setup. If you prefer another middleware (e.g., saga), replace the default via the middleware option.

2. When should I choose RTK Query over manual thunks?

Prefer RTK Query for CRUD‑type endpoints where caching, automatic refetching, and data synchronization are beneficial. Manual thunks shine when you need complex side‑effects, conditional flows, or integration with non‑HTTP APIs.

3. How can I debug Redux Toolkit state changes?

Enable the Redux DevTools extension (automatically active in development) and use the createLogger middleware for console‑based logging. Additionally, typing the store (RootState) provides compile‑time safety, reducing the need for runtime inspection.

4. Is it safe to store large arrays (e.g., thousands of records) in Redux?

Storing massive lists can degrade performance. Instead, keep normalized entities with createEntityAdapter, paginate on the server, and load only visible subsets. Use selectors to memoize derived calculations.

5. Can I use Redux Toolkit with React Native?

Absolutely. The same APIs work in React Native; just swap the web‑specific storage (e.g., redux-persist with AsyncStorage). All best‑practice patterns-slice organization, thunks, and RTK Query-remain applicable.

Conclusion

Wrapping Up

Redux Toolkit has transformed state management by delivering a concise API, built‑in Immer support, and powerful data‑fetching utilities. By adhering to the best practices outlined-modular slice design, entity adapters, selector‑driven UI, lazy‑loaded reducers, and RTK Query-you’ll construct React applications that are easier to reason about, faster to develop, and resilient to future change.

Remember to:

  1. Keep slices focused - one domain per file.
  2. Leverage createEntityAdapter for collections.
  3. Encapsulate async work in thunks or RTK Query.
  4. Use typed hooks to avoid repetitive annotations.
  5. Write unit tests for reducers, thunks, and components.

By integrating these habits early, you’ll reap the benefits of a maintainable codebase, smoother onboarding for teammates, and an excellent developer experience.

Happy coding, and may your Redux Toolkit journey be both productive and enjoyable!