Smarter Redux: A Complete Guide to Redux Toolkit in 2025

Master modern React state management with Redux Toolkit--eliminate boilerplate, write cleaner code, and build scalable applications with RTK Query and Immer.

Why Redux Toolkit Matters

If you remember writing Redux around 2017 or 2018, you likely recall the frustration of managing multiple files just to add a simple feature. Action types, action creators, reducers with massive switch statements, and thunks scattered across your codebase. Redux Toolkit (RTK) changed everything--it is now the official recommended way to write Redux.

Redux Toolkit provides simplified store setup, automatic action creation, Immer integration for intuitive updates, built-in async handling with thunks, and RTK Query for data fetching. The result is less code, fewer mistakes, and faster development.

For teams building complex React applications, adopting Redux Toolkit is essential for maintainable codebases. When combined with proper TypeScript practices and modern React patterns, it creates a powerful foundation for scalable state management.

What Redux Toolkit Provides

Simplified Store Setup

configureStore automatically configures middleware, dev tools, and immutability checks.

createSlice Function

Eliminates action types and creators by generating them automatically from a single configuration.

Immer Integration

Write 'mutative' syntax that is converted to immutable updates under the hood.

Built-in Redux Thunk

Async logic handling without additional packages or configuration.

RTK Query

Purpose-built data fetching and caching solution with automatic cache invalidation.

TypeScript Support

Full type inference out of the box with excellent developer experience.

Setting Up Redux Toolkit

Installing the Required Packages

Getting started with Redux Toolkit requires just two packages:

npm install @reduxjs/toolkit react-redux

Store Configuration with configureStore

The old Redux approach required configuring middleware, dev tools, and enhancers manually. Redux Toolkit's configureStore does all of this automatically with sensible defaults, as recommended by the official Redux style guide.

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,
 },
});

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

This configuration includes Redux DevTools automatically enabled during development, thunk middleware for async operations, and Immer for simplified immutable updates. For debugging tips, see our guide on Redux DevTools tips and tricks.

Provider Setup in React

After creating the store, wrap your application with the Redux Provider component to make the store available throughout your component tree.

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(
 <Provider store={store}>
 <App />
 </Provider>
);

Following the single store pattern ensures predictable state management and enables powerful debugging features.

Core Concepts: Essential Rules

Do Not Mutate State

Mutating state is the most common cause of bugs in Redux applications, leading to components failing to re-render properly and breaking time-travel debugging in the Redux DevTools. Always avoid actual mutation of state values.

Use tools such as redux-immutable-state-invariant to catch mutations during development, and Immer to avoid accidental mutations in state updates. When using Immer, writing "mutating" logic is acceptable because the real data is not being mutated--Immer safely tracks changes and generates immutably-updated values internally.

Reducers Must Not Have Side Effects

Reducer functions should only depend on their state and action arguments, and should only calculate and return a new state value based on those arguments. They must not execute asynchronous logic (AJAX calls, timeouts), generate random values (Date.now(), Math.random()), or modify variables outside the reducer function.

Only One Redux Store Per Application

A standard Redux application should have a single Redux store instance used by the whole application, typically defined in a separate file. As emphasized in the Redux style guide, no application logic should import the store directly--it should be passed to a React component tree via Provider or referenced indirectly via middleware such as thunks.

Do Not Put Non-Serializable Values in State or Actions

Avoid putting non-serializable values such as Promises, Symbols, Maps/Sets, functions, or class instances into the Redux store state or dispatched actions. This ensures debugging capabilities work as expected and the UI updates properly. This principle aligns with best practices for intercepting Fetch API requests where serializable data is essential.

Creating Slices: The Modern Approach

The createSlice function is the heart of modern Redux development. It eliminates the need for separate action types, action creators, and reducers by generating them automatically from a single configuration object.

Anatomy of a Slice

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
 value: number;
 status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
 value: 0,
 status: 'idle',
};

const counterSlice = createSlice({
 name: 'counter',
 initialState,
 reducers: {
 increment: (state) => {
 state.value += 1;
 },
 decrement: (state) => {
 state.value -= 1;
 },
 incrementByAmount: (state, action: PayloadAction<number>) => {
 state.value += action.payload;
 },
 },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

Notice how the reducer functions can directly "mutate" the state using Immer. The line state.value += 1 looks like mutation but is converted to immutable updates internally. For creating reusable components with TypeScript, see our guide on TypeScript generics.

Async Logic with createAsyncThunk

For async operations like API calls, Redux Toolkit provides createAsyncThunk, which generates action types for pending, fulfilled, and rejected states automatically.

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

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

export const fetchUser = createAsyncThunk(
 'user/fetchUser',
 async (userId: number) => {
 const response = await fetch(`https://api.example.com/users/${userId}`);
 if (!response.ok) {
 throw new Error('Failed to fetch user');
 }
 return await response.json() as User;
 }
);

const userSlice = createSlice({
 name: 'user',
 initialState: { data: null, status: 'idle', error: null },
 reducers: {
 clearUser: (state) => {
 state.data = null;
 state.status = 'idle';
 state.error = null;
 },
 },
 extraReducers: (builder) => {
 builder
 .addCase(fetchUser.pending, (state) => {
 state.status = 'loading';
 state.error = null;
 })
 .addCase(fetchUser.fulfilled, (state, action) => {
 state.status = 'succeeded';
 state.data = action.payload;
 })
 .addCase(fetchUser.rejected, (state, action) => {
 state.status = 'failed';
 state.error = action.error.message || 'Unknown error';
 });
 },
});

This pattern provides clear state transitions for async operations, making it easy to handle loading states and errors in your UI. For more advanced data fetching patterns, see our guide on creating scalable GraphQL APIs.

RTK Query: Data Fetching Powerhouse

While createAsyncThunk handles general async logic, RTK Query is purpose-built for data fetching and caching. It eliminates the need for manual loading and caching logic while providing features like polling, cache invalidation, and optimistic updates.

Setting Up an API Slice

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const postsApi = createApi({
 reducerPath: 'postsApi',
 baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
 endpoints: (builder) => ({
 getPosts: builder.query<Post[], void>({
 query: () => '/posts',
 }),
 getPostById: builder.query<Post, number>({
 query: (id) => `/posts/${id}`,
 }),
 createPost: builder.mutation<Post, Partial<Post>>({
 query: (newPost) => ({
 url: '/posts',
 method: 'POST',
 body: newPost,
 }),
 }),
 }),
});

export const { useGetPostsQuery, useGetPostByIdQuery, useCreatePostMutation } = postsApi;

The generated hooks provide data, loading state, and error information automatically. Add the API to your store middleware and use the hooks in components. RTK Query handles caching automatically based on the endpoints you define, significantly reducing boilerplate compared to manual data fetching approaches.

Feature-Based Folder Structure

Redux applications should organize files using a feature folder approach, where all files for a specific feature reside in the same directory. Within each feature folder, the Redux logic should be written as a single slice file using createSlice. This is also known as the "ducks" pattern, as recommended by the official Redux style guide.

src/
├── app/
│ ├── store.ts # Store setup
│ └── App.tsx # Root component
├── features/
│ ├── counter/
│ │ ├── counterSlice.ts
│ │ └── Counter.tsx
│ ├── user/
│ │ ├── userSlice.ts
│ │ └── UserProfile.tsx
│ └── posts/
│ ├── postsSlice.ts
│ └── PostsList.tsx
├── services/
│ └── postsApi.ts # RTK Query API definition
└── common/
 ├── hooks/
 └── components/

This structure keeps related logic together, making it easier to find and maintain code. Each feature folder contains everything needed for that feature, from Redux logic to React components.

Performance Optimization

Selectors and Memoization

Selectors extract specific pieces of state from the Redux store. For better performance, use memoized selectors that cache results and only recalculate when inputs change.

import { createSelector } from '@reduxjs/toolkit';

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

const selectFilteredTodos = createSelector(
 [selectTodos, selectFilter],
 (todos, filter) => {
 switch (filter) {
 case 'active':
 return todos.filter((todo) => !todo.completed);
 case 'completed':
 return todos.filter((todo) => todo.completed);
 default:
 return todos;
 }
 }
);

Entity Adapters for Normalized State

When dealing with collections of items, use entity adapters to manage normalized state with efficient CRUD operations.

const postsAdapter = createEntityAdapter<Post>({
 selectId: (post) => post.id,
 sortComparer: (a, b) => a.title.localeCompare(b.title),
});

Entity adapters provide standardized methods for adding, updating, and removing entities while maintaining a normalized structure, which is essential for performance in applications with large datasets. For advanced debugging capabilities, check out our guide on Redux DevTools tips and tricks.

Common Patterns and Best Practices

Model Actions as Events, Not Setters

Redux actions should describe events that occurred rather than acting as setters. This leads to more meaningful action names, fewer total actions being dispatched, and a more meaningful action log history, as recommended in the Redux style guide.

For example, dispatch { type: 'posts/postAdded', payload: post } instead of { type: 'posts/setPostTitle', payload: { id, title } }. The former describes an event (a post was added) while the latter describes a setter operation.

Treat Reducers as State Machines

Many Redux reducers are written unconditionally, only looking at the dispatched action without considering the current state. This can cause bugs when actions are dispatched in invalid states. Treat reducers as state machines where the combination of current state and dispatched action determines the new state.

Keep State Minimal and Derive Additional Values

Keep the actual data in the Redux store as minimal as possible and derive additional values from that state as needed. This includes calculating filtered lists, summing values, or computing derived state. As the official style guide recommends, deriving data rather than storing it avoids synchronization issues and keeps the store simpler.

Common Mistakes to Avoid

Mutating State Directly Without Immer

One of the most common mistakes is attempting to mutate state directly without using Immer in reducers. While Immer allows "mutative" syntax, this only works inside Redux Toolkit reducers.

Spreading State Incorrectly

Another common error is using spread operators incorrectly when updating nested state:

// Wrong - this doesn't actually update nested state
return { ...state, items[0].completed: true };

// Correct - need to spread each level
return {
 ...state,
 items: state.items.map((item, index) =>
 index === 0 ? { ...item, completed: true } : item
 ),
};

// With Immer (correct and simpler)
state.items[0].completed = true;

Putting Everything in Redux

Not all state belongs in Redux. Local component state, refs, and transient UI state are better handled by React's built-in state management. Reserve Redux for state that needs to be accessed by multiple components or persisted across sessions. For a comprehensive approach to React architecture, explore our web development services.

Conclusion

Redux Toolkit represents a fundamental improvement in how we write Redux applications. By embracing these modern patterns--configureStore for store setup, createSlice for consolidating logic, Immer for intuitive state updates, RTK Query for data fetching, and feature-based architecture for organization--you will write cleaner, more maintainable code that scales effectively.

The investment in understanding these patterns pays dividends throughout your application's lifecycle, from initial development through long-term maintenance. Start implementing these practices in your next project and experience the difference that smarter Redux development makes.

For organizations building complex React applications, our web development services can help you implement modern state management patterns effectively. Contact our team to discuss how we can support your React projects.

Frequently Asked Questions

Is Redux Toolkit suitable for small applications?

Yes, but for very simple applications with minimal state sharing needs, React's built-in useState and useContext may be sufficient. Redux Toolkit shines in medium to large applications with complex state requirements.

Do I need to use RTK Query with Redux Toolkit?

No, RTK Query is optional but highly recommended for data fetching. You can still use createAsyncThunk or other approaches for async logic if you prefer.

Can I mix Redux Toolkit with classic Redux patterns?

Yes, but it's not recommended. Redux Toolkit is designed to be used as a complete solution. Mixing patterns can lead to confusion and missed optimizations.

How does Immer work under the hood?

Immer uses proxy objects to track mutations to a draft state. When a reducer returns, Immer produces a frozen, immutable copy of the final state based on those tracked changes.

What is the difference between createSlice and createReducer?

createSlice combines createReducer with automatic action creator generation. Use createSlice for most cases; createReducer is useful when you need separate action creators.

Ready to Modernize Your React State Management?

Our team specializes in building scalable React applications with modern state management patterns. Let us help you implement Redux Toolkit effectively in your project.

Sources

  1. Redux.js.org Style Guide - Official Redux style guide with priority A/B/C rules for best practices
  2. Redux Toolkit Official Documentation - Official getting started guide for Redux Toolkit
  3. RTK Query Overview - Official RTK Query documentation for data fetching
  4. Modern Redux with Redux Toolkit & RTK Query in 2025 - TypeScript examples and modern patterns