React with Redux: A Comprehensive Guide

Understanding the Redux data flow and state management in React applications

Introduction to Redux

Redux is a predictable state container for JavaScript applications, particularly useful with frameworks like React. Think of Redux as a central bank for your application's data - it maintains all the information your app needs in one secure, reliable location.

Imagine building a large apartment complex. Without a proper blueprint or management system, chaos would ensue with contractors working independently. Redux provides that blueprint, ensuring all changes to your application's state happen in a predictable, traceable manner.

When to Use Redux?

Redux shines when your application:

  • Has complex state logic across multiple components
  • Needs to share state between components that aren't closely related
  • Has a medium to large-sized codebase with many contributors
  • Requires predictable state updates and time-travel debugging

Not every React application needs Redux. For simpler apps, React's built-in Context API and useReducer hook might be sufficient.

The Redux Data Cycle

The Redux data cycle follows a unidirectional flow, similar to water flowing through a hydroelectric dam system:

Redux Data Flow Diagram
  1. Action: An event occurs (user clicks a button, data arrives from an API)
  2. Dispatch: The action is sent to the Redux store
  3. Reducer: The store sends the current state and action to the reducer
  4. State Update: The reducer returns a new state based on the action
  5. Subscription: Components subscribed to the store are notified of the change
  6. Re-render: Components update based on the new state

This cycle ensures that state changes are predictable and traceable - just like a banking transaction that leaves a clear audit trail.

Setting Up Redux in a React Application

Project Structure

my_react_redux_app/
├── node_modules/
├── public/
├── src/
│   ├── actions/
│   │   └── index.js
│   ├── components/
│   │   ├── App.js
│   │   └── Counter.js
│   ├── reducers/
│   │   ├── counterReducer.js
│   │   ├── index.js
│   │   └── userReducer.js
│   ├── store/
│   │   └── index.js
│   ├── constants/
│   │   └── actionTypes.js
│   ├── index.js
│   └── App.css
├── package.json
└── README.md

Installation

First, create a new React application and install Redux dependencies:

npx create-react-app my_react_redux_app
cd my_react_redux_app
npm install redux react-redux redux-devtools-extension

Creating the Store

In the src/store/index.js file:

import { createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from '../reducers';

const store = createStore(
  rootReducer,
  composeWithDevTools()
);

export default store;

Setting Up Redux DevTools

The code above already includes Redux DevTools configuration. You'll also need to install the Redux DevTools browser extension.

Providing the Store to Your React App

In the src/index.js file:

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

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

Creating Reducers

Reducers are pure functions that determine how the application's state changes in response to actions. Think of reducers as the accountants of your application - they take the current balance (state) and a transaction receipt (action), then calculate the new balance.

Individual Reducers

Let's create a simple counter reducer in src/reducers/counterReducer.js:

import { INCREMENT, DECREMENT, RESET } from '../constants/actionTypes';

const initialState = {
  count: 0
};

const counterReducer = (state = initialState, action) => {
  switch (action.type) {
    case INCREMENT:
      return {
        ...state,
        count: state.count + 1
      };
    case DECREMENT:
      return {
        ...state,
        count: state.count - 1
      };
    case RESET:
      return {
        ...state,
        count: 0
      };
    default:
      return state;
  }
};

export default counterReducer;

And a user reducer in src/reducers/userReducer.js:

import { LOGIN, LOGOUT } from '../constants/actionTypes';

const initialState = {
  isLoggedIn: false,
  username: null
};

const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case LOGIN:
      return {
        ...state,
        isLoggedIn: true,
        username: action.payload.username
      };
    case LOGOUT:
      return {
        ...state,
        isLoggedIn: false,
        username: null
      };
    default:
      return state;
  }
};

export default userReducer;

Combining Reducers

In src/reducers/index.js, use combineReducers to organize your state management:

import { combineReducers } from 'redux';
import counterReducer from './counterReducer';
import userReducer from './userReducer';

const rootReducer = combineReducers({
  counter: counterReducer,
  user: userReducer
});

export default rootReducer;

This is like organizing a filing cabinet where each reducer manages its own drawer - counter information in one drawer, user information in another, making your state organization clean and modular.

Why Create a New Object for State Updates?

Redux works with immutability principles, meaning we never directly modify the existing state. Instead, we create a new object that represents the updated state.

This approach:

  • Allows for powerful features like time-travel debugging
  • Makes it easier to track changes (like comparing two photo snapshots vs. watching a video)
  • Helps prevent bugs related to direct object mutation
  • Enables performance optimizations in React rendering

The spread operator (...state) creates a shallow copy of the state object before making changes.

Defining Action Types

To prevent simple typos, we define action types as constants in src/constants/actionTypes.js:

// Counter action types
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

// User action types
export const LOGIN = 'LOGIN';
export const LOGOUT = 'LOGOUT';

Using constants for action types is like having standardized order forms for your business - it ensures everyone uses the same format and prevents miscommunication.

Creating Actions and Action Creators

In Redux, an action is a plain JavaScript object that contains information about an event that occurred in your app. An action creator is a function that creates and returns an action object.

Actions vs. Action Creators

Action

A plain object describing what happened

{ 
  type: 'INCREMENT', 
  payload: { value: 1 } 
}

Action Creator

A function that returns an action

const increment = (value = 1) => ({
  type: 'INCREMENT',
  payload: { value }
});

Let's define our action creators in src/actions/index.js:

import {
  INCREMENT,
  DECREMENT,
  RESET,
  LOGIN,
  LOGOUT
} from '../constants/actionTypes';

// Counter action creators
export const increment = () => ({
  type: INCREMENT
});

export const decrement = () => ({
  type: DECREMENT
});

export const reset = () => ({
  type: RESET
});

// User action creators
export const login = (username) => ({
  type: LOGIN,
  payload: { username }
});

export const logout = () => ({
  type: LOGOUT
});

Think of action creators as pre-filled forms. Rather than filling out the entire form (action object) each time, you just provide the specific details (like a username) and the rest of the form is automatically completed.

Connecting Components to Redux

With the introduction of React Hooks, connecting components to Redux has become much simpler. We'll use the useSelector and useDispatch hooks.

Creating a Counter Component

In src/components/Counter.js:

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, reset } from '../actions';

const Counter = () => {
  // Extract just the piece of state we need (a slice of state)
  const count = useSelector(state => state.counter.count);
  const dispatch = useDispatch();

  return (
    <div className="counter-container">
      <h2>Counter: {count}</h2>
      <div className="button-group">
        <button onClick={() => dispatch(increment())}>Increment</button>
        <button onClick={() => dispatch(decrement())}>Decrement</button>
        <button onClick={() => dispatch(reset())}>Reset</button>
      </div>
    </div>
  );
};

export default Counter;

Creating a User Component

In src/components/User.js:

import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { login, logout } from '../actions';

const User = () => {
  const [inputName, setInputName] = useState('');
  const { isLoggedIn, username } = useSelector(state => state.user);
  const dispatch = useDispatch();

  const handleLogin = () => {
    if (inputName.trim()) {
      dispatch(login(inputName));
      setInputName('');
    }
  };

  return (
    <div className="user-container">
      {isLoggedIn ? (
        <div>
          <h3>Welcome, {username}!</h3>
          <button onClick={() => dispatch(logout())}>Logout</button>
        </div>
      ) : (
        <div>
          <h3>Please login</h3>
          <input
            type="text"
            value={inputName}
            onChange={(e) => setInputName(e.target.value)}
            placeholder="Enter username"
          />
          <button onClick={handleLogin}>Login</button>
        </div>
      )}
    </div>
  );
};

export default User;

Assembling the App Component

In src/components/App.js:

import React from 'react';
import Counter from './Counter';
import User from './User';

const App = () => {
  return (
    <div className="app-container">
      <h1>Redux Demo App</h1>
      <Counter />
      <User />
    </div>
  );
};

export default App;

The useSelector hook is like having a personal assistant who monitors a specific part of your store and alerts you when it changes. Meanwhile, useDispatch gives you a direct line to send messages (actions) to the store.

Understanding Slices of State

A "slice of state" refers to a specific portion of your Redux store that a particular reducer manages. When we created our root reducer using combineReducers, we effectively divided our state into slices:

// Our state shape
{
  counter: {
    count: 0
  },
  user: {
    isLoggedIn: false,
    username: null
  }
}

In this example:

This is similar to how a restaurant divides responsibilities - the chef handles food preparation, servers handle customer interaction, and managers handle overall operations. Each "slice" has its own manager (reducer) but works within the same restaurant (store).

When selecting data in components, we typically extract just the specific slice or piece of data we need:

// Getting the entire counter slice
const counterSlice = useSelector(state => state.counter);

// Getting just the count value from the counter slice
const count = useSelector(state => state.counter.count);

// Getting multiple properties from the user slice
const { isLoggedIn, username } = useSelector(state => state.user);

Creating a Store with Preloaded State

Sometimes you might want to initialize your Redux store with pre-existing data. This is useful for:

To preload state, we pass it as the second argument to createStore:

import { createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from '../reducers';

// Preloaded state (could come from localStorage, server, etc.)
const preloadedState = {
  counter: {
    count: 10
  },
  user: {
    isLoggedIn: true,
    username: 'PreloadedUser'
  }
};

// Optional: Load state from localStorage
const loadState = () => {
  try {
    const serializedState = localStorage.getItem('appState');
    if (serializedState === null) {
      return undefined; // Let reducers initialize state
    }
    return JSON.parse(serializedState);
  } catch (err) {
    console.error('Failed to load state:', err);
    return undefined;
  }
};

// Create the store with preloaded state
const store = createStore(
  rootReducer,
  loadState() || preloadedState, // Use localStorage state if available, else use preloaded
  composeWithDevTools()
);

// Optional: Save state to localStorage when it changes
store.subscribe(() => {
  try {
    const serializedState = JSON.stringify(store.getState());
    localStorage.setItem('appState', serializedState);
  } catch (err) {
    console.error('Failed to save state:', err);
  }
});

export default store;

This is like opening a new bank account with an initial deposit, rather than starting at zero.

Debugging Redux

Debugging Redux applications can be done through several approaches:

Using Redux DevTools

With Redux DevTools Extension configured, you can:

Redux DevTools Screenshot

Using the Browser Console

You can expose the store to the browser's window object for debugging:

// In src/index.js
import store from './store';

// Only do this in development
if (process.env.NODE_ENV === 'development') {
  window.store = store;
}

// Now in browser console you can:
// See current state: window.store.getState()
// Dispatch an action: window.store.dispatch({ type: 'INCREMENT' })

Console Logging in Reducers

Add logging to see when reducers are processing actions:

const counterReducer = (state = initialState, action) => {
  console.log('counterReducer received action:', action);
  
  switch (action.type) {
    // reducer cases
  }
};

This will help you understand which reducers are responding to which actions.

Why Do All Actions Hit All Reducers?

In Redux, when an action is dispatched, it flows through every reducer. This is by design, allowing each slice of state to respond (or not) to any action.

Think of it like a company-wide announcement system. When an announcement is made, everyone hears it, but only the relevant departments take action.

Benefits of this approach:

  • One action can update multiple slices of state
  • Cross-cutting concerns can be addressed
  • It enables more complex state interactions when needed

This is why the default case in each reducer (returning the unchanged state) is crucial - it ensures that irrelevant actions don't cause unnecessary state changes.

Practical Example: Todo Application

Let's build a more complete example: a todo list application with filtering capabilities.

Setting Up Action Types

In src/constants/actionTypes.js:

// Todo action types
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const DELETE_TODO = 'DELETE_TODO';

// Filter action types
export const SET_FILTER = 'SET_FILTER';

// Filter values
export const FILTERS = {
  SHOW_ALL: 'SHOW_ALL',
  SHOW_COMPLETED: 'SHOW_COMPLETED',
  SHOW_ACTIVE: 'SHOW_ACTIVE'
};

Creating Reducers

In src/reducers/todosReducer.js:

import { ADD_TODO, TOGGLE_TODO, DELETE_TODO } from '../constants/actionTypes';

const initialState = [];

const todosReducer = (state = initialState, action) => {
  switch (action.type) {
    case ADD_TODO:
      return [
        ...state,
        {
          id: action.payload.id,
          text: action.payload.text,
          completed: false
        }
      ];
      
    case TOGGLE_TODO:
      return state.map(todo =>
        todo.id === action.payload.id
          ? { ...todo, completed: !todo.completed }
          : todo
      );
      
    case DELETE_TODO:
      return state.filter(todo => todo.id !== action.payload.id);
      
    default:
      return state;
  }
};

export default todosReducer;

In src/reducers/filterReducer.js:

import { SET_FILTER, FILTERS } from '../constants/actionTypes';

const initialState = FILTERS.SHOW_ALL;

const filterReducer = (state = initialState, action) => {
  switch (action.type) {
    case SET_FILTER:
      return action.payload.filter;
      
    default:
      return state;
  }
};

export default filterReducer;

Combining the reducers in src/reducers/index.js:

import { combineReducers } from 'redux';
import todosReducer from './todosReducer';
import filterReducer from './filterReducer';

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

export default rootReducer;

Creating Action Creators

In src/actions/index.js:

import {
  ADD_TODO,
  TOGGLE_TODO,
  DELETE_TODO,
  SET_FILTER
} from '../constants/actionTypes';

// Generate unique IDs for todos
let nextTodoId = 0;

// Todo action creators
export const addTodo = (text) => ({
  type: ADD_TODO,
  payload: {
    id: nextTodoId++,
    text
  }
});

export const toggleTodo = (id) => ({
  type: TOGGLE_TODO,
  payload: { id }
});

export const deleteTodo = (id) => ({
  type: DELETE_TODO,
  payload: { id }
});

// Filter action creator
export const setFilter = (filter) => ({
  type: SET_FILTER,
  payload: { filter }
});

Creating Components

Let's create a few components for our todo app:

src/components/TodoItem.js:

import React from 'react';
import { useDispatch } from 'react-redux';
import { toggleTodo, deleteTodo } from '../actions';

const TodoItem = ({ todo }) => {
  const dispatch = useDispatch();
  
  return (
    <li className="todo-item">
      <span
        className={todo.completed ? 'completed' : ''}
        onClick={() => dispatch(toggleTodo(todo.id))}
      >
        {todo.text}
      </span>
      <button onClick={() => dispatch(deleteTodo(todo.id))}>
        Delete
      </button>
    </li>
  );
};

export default TodoItem;

src/components/TodoList.js:

import React from 'react';
import { useSelector } from 'react-redux';
import TodoItem from './TodoItem';
import { FILTERS } from '../constants/actionTypes';

const TodoList = () => {
  const todos = useSelector(state => state.todos);
  const filter = useSelector(state => state.filter);
  
  // Filter todos based on current filter
  const getVisibleTodos = (todos, filter) => {
    switch (filter) {
      case FILTERS.SHOW_COMPLETED:
        return todos.filter(todo => todo.completed);
      case FILTERS.SHOW_ACTIVE:
        return todos.filter(todo => !todo.completed);
      case FILTERS.SHOW_ALL:
      default:
        return todos;
    }
  };
  
  const visibleTodos = getVisibleTodos(todos, filter);
  
  return (
    <ul className="todo-list">
      {visibleTodos.length === 0 ? (
        <li>No todos to display</li>
      ) : (
        visibleTodos.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))
      )}
    </ul>
  );
};

export default TodoList;

src/components/FilterButtons.js:

import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setFilter } from '../actions';
import { FILTERS } from '../constants/actionTypes';

const FilterButtons = () => {
  const dispatch = useDispatch();
  const currentFilter = useSelector(state => state.filter);
  
  return (
    <div className="filter-buttons">
      {Object.values(FILTERS).map(filter => (
        <button
          key={filter}
          onClick={() => dispatch(setFilter(filter))}
          className={currentFilter === filter ? 'active' : ''}
        >
          {filter.replace('SHOW_', '')}
        </button>
      ))}
    </div>
  );
};

export default FilterButtons;

src/components/AddTodo.js:

import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { addTodo } from '../actions';

const AddTodo = () => {
  const [text, setText] = useState('');
  const dispatch = useDispatch();
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    
    dispatch(addTodo(text));
    setText('');
  };
  
  return (
    <form onSubmit={handleSubmit} className="add-todo-form">
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Add a new todo"
      />
      <button type="submit">Add</button>
    </form>
  );
};

export default AddTodo;

src/components/TodoApp.js:

import React from 'react';
import AddTodo from './AddTodo';
import TodoList from './TodoList';
import FilterButtons from './FilterButtons';

const TodoApp = () => {
  return (
    <div className="todo-app">
      <h1>Redux Todo App</h1>
      <AddTodo />
      <FilterButtons />
      <TodoList />
    </div>
  );
};

export default TodoApp;

Update src/components/App.js to use our TodoApp:

import React from 'react';
import TodoApp from './TodoApp';

const App = () => {
  return (
    <div className="app-container">
      <TodoApp />
    </div>
  );
};

export default App;

Adding Some Basic CSS

Finally, let's add some basic styling in src/App.css:

/* Note: This would normally be in the main.css file linked in the HTML */
.app-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: Arial, sans-serif;
}

.counter-container, .user-container, .todo-app {
  margin-bottom: 30px;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 5px;
}

.button-group {
  display: flex;
  gap: 10px;
  margin-top: 10px;
}

button {
  padding: 8px 16px;
  background-color: #4CAF50;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #45a049;
}

button.active {
  background-color: #3e8e41;
}

input {
  padding: 8px;
  margin-right: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.todo-list {
  list-style-type: none;
  padding: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px;
  margin: 5px 0;
  background-color: #f9f9f9;
  border-radius: 4px;
}

.todo-item span {
  cursor: pointer;
}

.todo-item span.completed {
  text-decoration: line-through;
  color: #888;
}

.filter-buttons {
  margin: 15px 0;
  display: flex;
  gap: 10px;
}

.add-todo-form {
  display: flex;
  margin-bottom: 15px;
}

.add-todo-form input {
  flex-grow: 1;
}

/* Add extra styling for info boxes and diagrams */
.info-box {
  background-color: #f0f8ff;
  border-left: 4px solid #1e90ff;
  padding: 15px;
  margin: 20px 0;
  border-radius: 0 4px 4px 0;
}

.diagram, .image-container {
  text-align: center;
  margin: 20px 0;
}

.comparison {
  display: flex;
  gap: 20px;
  margin: 20px 0;
}

.comparison-item {
  flex: 1;
  padding: 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

Redux Middleware

Middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer. It's perfect for logging, crash reporting, talking to an asynchronous API, routing, and more.

Understanding Middleware

Middleware forms a pipeline around the store's dispatch method. Each middleware receives the store's dispatch and getState functions and returns a function that takes the next middleware's dispatch method. This chain eventually ends with the original dispatch function.

Redux Middleware Pipeline

Think of middleware as checkpoints that an action passes through before reaching its final destination. Each checkpoint can:

  • Inspect the action
  • Modify the action
  • Dispatch different actions
  • Stop the action from proceeding
  • Perform side effects

Common Middleware Libraries

Some popular middleware packages include:

  • redux-thunk: Allows action creators to return functions instead of objects, useful for async operations
  • redux-saga: Uses ES6 generators to manage side effects
  • redux-observable: Uses RxJS observables for complex async operations
  • redux-logger: Logs actions and state changes to the console

Implementing Redux Thunk

Redux Thunk is the most commonly used middleware for handling asynchronous operations in Redux. Let's set it up:

First, install the package:

npm install redux-thunk

Then, apply the middleware in our store:

import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import rootReducer from '../reducers';

const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);

export default store;

Creating Async Action Creators

With Redux Thunk, we can create action creators that perform asynchronous operations:

// In src/actions/index.js
import {
FETCH_TODOS_REQUEST,
FETCH_TODOS_SUCCESS,
FETCH_TODOS_FAILURE
} from '../constants/actionTypes';

// Synchronous action creators
export const fetchTodosRequest = () => ({
type: FETCH_TODOS_REQUEST
});

export const fetchTodosSuccess = (todos) => ({
type: FETCH_TODOS_SUCCESS,
payload: { todos }
});

export const fetchTodosFailure = (error) => ({
type: FETCH_TODOS_FAILURE,
payload: { error }
});

// Async action creator using thunk
export const fetchTodos = () => {
return async (dispatch) => {
dispatch(fetchTodosRequest());

try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
const data = await response.json();

dispatch(fetchTodosSuccess(data));
} catch (error) {
dispatch(fetchTodosFailure(error.message));
}
};
};

And update the reducer to handle these actions:

// In src/reducers/todosReducer.js
import {
FETCH_TODOS_REQUEST,
FETCH_TODOS_SUCCESS,
FETCH_TODOS_FAILURE,
// ... existing action types
} from '../constants/actionTypes';

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

const todosReducer = (state = initialState, action) => {
switch (action.type) {
case FETCH_TODOS_REQUEST:
return {
...state,
loading: true,
error: null
};

case FETCH_TODOS_SUCCESS:
return {
...state,
loading: false,
items: action.payload.todos
};

case FETCH_TODOS_FAILURE:
return {
...state,
loading: false,
error: action.payload.error
};

// ... existing cases

default:
return state;
}
};

This pattern allows for clean separation of concerns while handling asynchronous operations like API calls.

Redux Toolkit: The Modern Way

Redux Toolkit is the official, opinionated, batteries-included toolset for efficient Redux development. It addresses the three most common concerns about Redux:

  • "Configuring a Redux store is too complicated"
  • "I have to add a lot of packages to get Redux to do anything useful"
  • "Redux requires too much boilerplate code"

Getting Started with Redux Toolkit

Install Redux Toolkit:

npm install @reduxjs/toolkit react-redux

Creating a Store with ConfigureStore

Redux Toolkit's configureStore function simplifies store setup:

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from '../reducers';

const store = configureStore({
reducer: rootReducer
});

export default store;

This single function:

  • Automatically sets up the Redux DevTools Extension
  • Adds thunk middleware by default
  • Enables development checks that catch mistakes

Creating Slices with CreateSlice

The createSlice function generates action creators and action types automatically:

// src/slices/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: (state) => {
// With Immer, we can write "mutating" logic
state.count += 1;
},
decrement: (state) => {
state.count -= 1;
},
incrementByAmount: (state, action) => {
state.count += action.payload;
}
}
});

// Export action creators
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// Export reducer
export default counterSlice.reducer;

This approach:

  • Defines the slice name, initial state, and reducer functions in one place
  • Automatically generates action creators and types
  • Uses Immer internally to allow "mutating" code while maintaining immutability behind the scenes

Updating the Todo App with Redux Toolkit

Let's refactor our todo application to use Redux Toolkit:

// src/slices/todosSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// Create async thunk for fetching todos
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
return response.json();
}
);

const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
loading: false,
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(todo => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
deleteTodo: (state, action) => {
state.items = state.items.filter(todo => todo.id !== action.payload);
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload.slice(0, 10).map(item => ({
id: item.id,
text: item.title,
completed: item.completed
}));
})
.addCase(fetchTodos.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});

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

And similarly for the filter:

// src/slices/filterSlice.js
import { createSlice } from '@reduxjs/toolkit';

export const FILTERS = {
SHOW_ALL: 'SHOW_ALL',
SHOW_COMPLETED: 'SHOW_COMPLETED',
SHOW_ACTIVE: 'SHOW_ACTIVE'
};

const filterSlice = createSlice({
name: 'filter',
initialState: FILTERS.SHOW_ALL,
reducers: {
setFilter: (state, action) => {
return action.payload;
}
}
});

export const { setFilter } = filterSlice.actions;
export default filterSlice.reducer;

Update the store configuration:

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../slices/todosSlice';
import filterReducer from '../slices/filterSlice';

const store = configureStore({
reducer: {
todos: todosReducer,
filter: filterReducer
}
});

export default store;

Components would be updated similarly to use these new action creators. Notice how much cleaner and more concise the code is with Redux Toolkit!

Performance Optimizations

As your Redux application grows, you might need to optimize performance. Here are some key techniques:

Memoizing Selectors with Reselect

The Reselect library (included in Redux Toolkit) helps avoid unnecessary recalculations of derived data:

// src/selectors/todoSelectors.js
import { createSelector } from '@reduxjs/toolkit';
import { FILTERS } from '../slices/filterSlice';

// Select the entire state slices (simple selectors)
export const selectTodos = state => state.todos.items;
export const selectFilter = state => state.filter;

// Create a memoized selector
export const selectVisibleTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case FILTERS.SHOW_COMPLETED:
return todos.filter(todo => todo.completed);
case FILTERS.SHOW_ACTIVE:
return todos.filter(todo => !todo.completed);
case FILTERS.SHOW_ALL:
default:
return todos;
}
}
);

// Another memoized selector
export const selectTodoStats = createSelector(
[selectTodos],
(todos) => {
const total = todos.length;
const completed = todos.filter(todo => todo.completed).length;
const active = total - completed;
const percentComplete = total === 0 ? 0 : Math.round((completed / total) * 100);

return { total, completed, active, percentComplete };
}
);

Using these selectors in components:

import React from 'react';
import { useSelector } from 'react-redux';
import { selectVisibleTodos, selectTodoStats } from '../selectors/todoSelectors';

const TodoStats = () => {
const stats = useSelector(selectTodoStats);

return (
<div className="todo-stats">
<p>Total: {stats.total} | Completed: {stats.completed} | Active: {stats.active}</p>
<div className="progress-bar">
<div
className="progress"
style={{ width: `${stats.percentComplete}%` }}
>
{stats.percentComplete}%
</div>
</div>
</div>
);
};

The benefit of memoized selectors is that they only recalculate when their inputs change, preventing unnecessary renders and calculations.

Normalizing State Shape

For collections of items, a normalized state structure can improve performance and make updates easier:

// Instead of:
{
todos: [
{ id: 1, text: "Buy milk", completed: false },
{ id: 2, text: "Walk dog", completed: true }
]
}

// Use:
{
todos: {
ids: [1, 2],
entities: {
"1": { id: 1, text: "Buy milk", completed: false },
"2": { id: 2, text: "Walk dog", completed: true }
}
}
}

Redux Toolkit's createEntityAdapter makes this pattern easy to implement:

// src/slices/todosSlice.js
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';

// Create entity adapter
const todosAdapter = createEntityAdapter();

// Initial state uses adapter's getInitialState method
const initialState = todosAdapter.getInitialState({
loading: false,
error: null
});

// Rest of the code as before, but using adapter methods
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: {
reducer: (state, action) => {
todosAdapter.addOne(state, action.payload);
},
prepare: (text) => ({
payload: {
id: Date.now(),
text,
completed: false
}
})
},
toggleTodo: (state, action) => {
const id = action.payload;
const existingTodo = state.entities[id];
if (existingTodo) {
existingTodo.completed = !existingTodo.completed;
}
},
deleteTodo: (state, action) => {
todosAdapter.removeOne(state, action.payload);
}
},
// Extra reducers for async operations...
});

// Export adapter selectors
export const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectIds: selectTodoIds
} = todosAdapter.getSelectors(state => state.todos);

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

Testing Redux Applications

Testing is a crucial part of maintaining a reliable Redux application. Let's explore how to test different parts of the Redux ecosystem.

Testing Reducers

Reducers are pure functions, making them straightforward to test:

// src/reducers/__tests__/todosReducer.test.js
import todosReducer from '../todosReducer';
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO } from '../../constants/actionTypes';

describe('Todos Reducer', () => {
it('should return the initial state', () => {
expect(todosReducer(undefined, {})).toEqual([]);
});

it('should handle ADD_TODO', () => {
const action = {
type: ADD_TODO,
payload: { id: 1, text: 'Test Todo' }
};

expect(todosReducer([], action)).toEqual([
{ id: 1, text: 'Test Todo', completed: false }
]);
});

it('should handle TOGGLE_TODO', () => {
const initialState = [
{ id: 1, text: 'Test Todo', completed: false }
];

const action = {
type: TOGGLE_TODO,
payload: { id: 1 }
};

expect(todosReducer(initialState, action)).toEqual([
{ id: 1, text: 'Test Todo', completed: true }
]);
});

it('should handle DELETE_TODO', () => {
const initialState = [
{ id: 1, text: 'Test Todo', completed: false }
];

const action = {
type: DELETE_TODO,
payload: { id: 1 }
};

expect(todosReducer(initialState, action)).toEqual([]);
});
});

Testing Action Creators

Action creators are also simple functions:

// src/actions/__tests__/index.test.js
import * as actions from '../index';
import * as types from '../../constants/actionTypes';

describe('Todo Action Creators', () => {
it('should create an action to add a todo', () => {
const text = 'Test Todo';
const expectedAction = {
type: types.ADD_TODO,
payload: {
id: expect.any(Number),
text
}
};

expect(actions.addTodo(text)).toMatchObject(expectedAction);
});

it('should create an action to toggle a todo', () => {
const id = 1;
const expectedAction = {
type: types.TOGGLE_TODO,
payload: { id }
};

expect(actions.toggleTodo(id)).toEqual(expectedAction);
});
});

Testing Async Action Creators

For async action creators using thunk, we need to mock the store:

// src/actions/__tests__/async.test.js
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'jest-fetch-mock';
import { fetchTodos } from '../index';
import * as types from '../../constants/actionTypes';

const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

describe('Async Action Creators', () => {
beforeEach(() => {
fetchMock.resetMocks();
});

it('creates FETCH_TODOS_SUCCESS when fetching todos has been completed', async () => {
const mockTodos = [
{ id: 1, title: 'Test Todo', completed: false }
];

fetchMock.mockResponseOnce(JSON.stringify(mockTodos));

const expectedActions = [
{ type: types.FETCH_TODOS_REQUEST },
{ 
type: types.FETCH_TODOS_SUCCESS, 
payload: { todos: mockTodos }
}
];

const store = mockStore({ todos: { items: [] } });
await store.dispatch(fetchTodos());

expect(store.getActions()).toEqual(expectedActions);
});
});

Testing Connected Components

For testing connected components, you can use React Testing Library with a real or mock store:

// src/components/__tests__/TodoList.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import TodoList from '../TodoList';
import { FILTERS } from '../../constants/actionTypes';

const mockStore = configureStore([]);

describe('TodoList Component', () => {
let store;

beforeEach(() => {
store = mockStore({
todos: [
{ id: 1, text: 'Test Todo 1', completed: false },
{ id: 2, text: 'Test Todo 2', completed: true }
],
filter: FILTERS.SHOW_ALL
});
});

it('renders all todos when filter is SHOW_ALL', () => {
render(
<Provider store={store}>
<TodoList />
</Provider>
);

expect(screen.getByText('Test Todo 1')).toBeInTheDocument();
expect(screen.getByText('Test Todo 2')).toBeInTheDocument();
});

it('renders only completed todos when filter is SHOW_COMPLETED', () => {
store = mockStore({
todos: [
{ id: 1, text: 'Test Todo 1', completed: false },
{ id: 2, text: 'Test Todo 2', completed: true }
],
filter: FILTERS.SHOW_COMPLETED
});

render(
<Provider store={store}>
<TodoList />
</Provider>
);

expect(screen.queryByText('Test Todo 1')).not.toBeInTheDocument();
expect(screen.getByText('Test Todo 2')).toBeInTheDocument();
});
});

Redux Ecosystem and Extensions

The Redux ecosystem includes a wide variety of libraries and tools that enhance its functionality:

Redux DevTools

The DevTools extension offers time-travel debugging, action inspection, and state diffing.

Persistence

Libraries like redux-persist make it easy to save the Redux store to localStorage or other storage systems:

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // localStorage
import rootReducer from '../reducers';

const persistConfig = {
key: 'root',
storage,
whitelist: ['todos', 'user'] // Only persist these reducers
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST']
}
})
});

export const persistor = persistStore(store);

Then in your app's entry point:

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from './store';
import App from './components/App';

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

Form Handling

Redux Form and Formik+Redux make complex form state management easier.

Routing

Redux can be integrated with React Router using connected-react-router.

Immutability Helpers

Libraries like Immer (built into Redux Toolkit) make working with immutable state easier.

Redux Best Practices

Following these best practices will help you maintain a clean, efficient Redux application:

Structure and Organization

  • Organize by feature instead of by type (e.g., "users/" folder containing actions, reducers, and selectors related to users)
  • Keep related code together in "ducks" or slices with Redux Toolkit
  • Use meaningful action names that describe events (e.g., "users/userLoggedIn" instead of "LOGIN_SUCCESS")

State Management

  • Keep your state normalized (avoid nested objects and duplicated data)
  • Store the minimal required state, and compute derived data with selectors
  • Avoid putting non-serializable values in state or actions (like functions or Promises)
  • Only store domain data, UI state, and app state in Redux; don't store form state unless needed globally

Performance

  • Use memoized selectors for derived data
  • Connect components at a granular level to avoid unnecessary re-renders
  • Batch related actions when possible to reduce render cycles
  • Consider using Reselect for complex calculations

Redux Toolkit

  • Prefer Redux Toolkit over "vanilla" Redux for new projects
  • Use createSlice to generate action creators and reducers
  • Leverage createEntityAdapter for normalized state
  • Use the RTK Query module for data fetching and caching

Testing

  • Write tests for reducers as pure functions
  • Test selectors with various state inputs
  • Use mock stores to test connected components
  • Test async action creators by mocking API calls

Conclusion

Redux provides a powerful, predictable way to manage state in React applications. While it does introduce additional complexity, the benefits become apparent as your application grows:

  • Centralized state management makes debugging easier
  • Predictable state updates follow a strict pattern
  • Middleware provides powerful extensibility
  • The developer tools offer unparalleled debugging capabilities
  • The large ecosystem provides solutions for common problems

Modern Redux with Redux Toolkit significantly reduces boilerplate and makes development more efficient. Whether you're building a small app or a large-scale application, understanding these Redux patterns and practices will help you create maintainable, robust React applications.

Remember that not every application needs Redux - for smaller applications, React's built-in state management might be sufficient. However, as your application grows in complexity, having a dedicated state management solution like Redux becomes increasingly valuable.

Further Learning

To continue learning about Redux, check out these resources: