State Management in React with Redux Toolkit

Table of Contents
Big thanks to our contributors those make our blogs possible.

Our growing community of contributors bring their unique insights from around the world to power our blog. 

Introduction

Managing state in complex React applications can quickly become challenging. Prop drilling, inconsistent updates, and scattered logic often lead to bugs and maintenance headaches. Redux Toolkit—the official, opinionated, batteries-included toolset for Redux—simplifies state management by providing intuitive APIs, sensible defaults, and built-in best practices. In this post, we’ll introduce you to Redux Toolkit’s core concepts, walk through setting up a store and slices, demonstrate how to connect React components with hooks, and share advanced patterns for scaling your application. Whether you’re building a small widget or a large enterprise app, you’ll learn how Redux Toolkit streamlines state logic, reduces boilerplate, and keeps your code predictable and maintainable.

Why Redux Toolkit?

The Pain Points of Traditional Redux

  • Boilerplate Overload: Action types, action creators, reducers, and switch statements—manually writing these for every feature can feel tedious.
  • Immutable Updates: Manually copying nested objects to uphold immutability is error-prone.
  • Configuration Complexity: Setting up the store, middleware (Thunk, Saga), and DevTools requires many lines of code.

How Redux Toolkit Helps

  • createSlice API: Automatically generates action types and creators based on reducer functions.
  • Immer Integration: Write “mutating” logic in reducers; Immer produces immutably updated state under the hood.
  • configureStore: Pre-configured store with Redux DevTools, Thunk middleware, and sensible defaults.
  • RTK Query (Optional): Built-in data fetching and caching layer to reduce boilerplate in async logic.

Getting Started

Installation

Install Redux Toolkit and React-Redux with:

bashCopyEditnpm install @reduxjs/toolkit react-redux

Folder Structure

A scalable pattern might look like:

bashCopyEditsrc/
├── app/
│   └── store.js
├── features/
│   ├── todos/
│   │   ├── todosSlice.js
│   │   └── TodosComponent.jsx
│   └── users/
│       ├── usersSlice.js
│       └── UsersComponent.jsx
└── index.js

Creating a Slice

Defining State Logic with createSlice

In todosSlice.js, you can define your slice like this:

jsCopyEditimport { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    status: 'idle',
    error: null
  },
  reducers: {
    addTodo: (state, action) => {
      state.items.push({ id: Date.now(), text: action.payload, completed: false });
    },
    toggleTodo: (state, action) => {
      const todo = state.items.find(t => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    }
  }
});

export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
  • name defines the slice’s namespace for action types (e.g., "todos/addTodo").
  • initialState holds your slice’s default data.
  • reducers receive “mutating” logic; Immer ensures immutability.

Configuring the Store

In store.js, you wire up your slices:

jsCopyEditimport { 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
  }
});
  • configureStore automatically sets up Redux Thunk and DevTools.

Providing the Store

Wrap your root component with the Redux Provider in index.js:

jsxCopyEditimport React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Accessing State and Dispatching Actions

useSelector and useDispatch Hooks

Replace connect with hooks in functional components. For example, in TodosComponent.jsx:

jsxCopyEditimport React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo } from './todosSlice';

export default function TodosComponent() {
  const [text, setText] = useState('');
  const todos = useSelector(state => state.todos.items);
  const dispatch = useDispatch();

  const handleAdd = () => {
    if (text.trim()) {
      dispatch(addTodo(text));
      setText('');
    }
  };

  return (
    <div>
      <h2>My Todos</h2>
      <input value={text} onChange={e => setText(e.target.value)} placeholder="Add new todo" />
      <button onClick={handleAdd}>Add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Handling Asynchronous Logic

Creating Thunks with createAsyncThunk

In usersSlice.js, define an async thunk:

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

export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await fetch('/api/users');
  return response.json();
});

const usersSlice = createSlice({
  name: 'users',
  initialState: { list: [], status: 'idle', error: null },
  reducers: {},
  extraReducers: builder => {
    builder
      .addCase(fetchUsers.pending, state => { state.status = 'loading'; })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.list = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  }
});

export default usersSlice.reducer;

Then in UsersComponent.jsx:

jsxCopyEditimport React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from './usersSlice';

export default function UsersComponent() {
  const { list, status, error } = useSelector(state => state.users);
  const dispatch = useDispatch();

  useEffect(() => {
    if (status === 'idle') dispatch(fetchUsers());
  }, [status, dispatch]);

  if (status === 'loading') return <p>Loading...</p>;
  if (status === 'failed')  return <p>Error: {error}</p>;

  return (
    <div>
      <h2>Users</h2>
      <ul>
        {list.map(u => (
          <li key={u.id}>{u.name} ({u.email})</li>
        ))}
      </ul>
    </div>
  );
}

Advanced Patterns & Best Practices

RTK Query for Data Fetching

  • Define endpoints to fetch, cache, and invalidate data with minimal boilerplate.
  • Use auto-generated hooks like useGetPostsQuery().

Slice Organization

  • Feature Folders: Co-locate slice, component, and tests.
  • Selectors: Extract reusable selectors to avoid duplication.

Performance Considerations

  • Memoized Selectors: Use createSelector from Reselect.
  • Batching Updates: React-Redux batches dispatches in event handlers.

Conclusion

Redux Toolkit transforms complex state management into a streamlined, maintainable experience. By leveraging createSlice, configureStore, and createAsyncThunk, you eliminate boilerplate, write safe “mutating” reducers, and integrate async logic cleanly. Advanced features like RTK Query, custom middleware, and memoized selectors help you scale confidently. Whether you’re building a simple counter or a data-rich dashboard, Redux Toolkit provides the structure and best practices you need to keep your React application predictable, performant, and easy to maintain.

Let's connect on TikTok

Join our newsletter to stay updated

Sydney Based Software Solutions Professional who is crafting exceptional systems and applications to solve a diverse range of problems for the past 10 years.

Share the Post

Related Posts