Redux Toolkit Implementation

Module 25: Frontend Frameworks & State Management

The Problem with Traditional Redux

While Redux provides a powerful state management solution, it has been criticized for:

Consider the example from our previous lecture, where setting up a simple counter required:

Analogy: Using traditional Redux is like making coffee from scratch - grinding beans, measuring, setting up a filter, boiling water, etc. Redux Toolkit is like a programmable coffee machine - push a button and get your coffee, with all the complexity handled for you.

Introducing Redux Toolkit

Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. Created by the Redux team to address common concerns, it:

graph TD A[Redux Toolkit] --> B[configureStore] A --> C[createSlice] A --> D[createReducer] A --> E[createAction] A --> F[createAsyncThunk] A --> G[createEntityAdapter] A --> H[createSelector] style A fill:#f96 style B fill:#9af style C fill:#9af style D fill:#9af style E fill:#9af style F fill:#9af style G fill:#9af style H fill:#9af classDef default stroke:#333,stroke-width:2px;

Installation

// With npm
npm install @reduxjs/toolkit react-redux

// With yarn
yarn add @reduxjs/toolkit react-redux

Core Redux Toolkit Features

configureStore

configureStore wraps the standard Redux createStore function with simplified configuration options and defaults. It automatically:

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import todosReducer from './todosSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer
  },
  // Optional additional configuration
  middleware: (getDefaultMiddleware) => 
    getDefaultMiddleware().concat(logger),
  devTools: process.env.NODE_ENV !== 'production',
});

export default store;

createReducer

createReducer simplifies reducer creation by:

import { createReducer } from '@reduxjs/toolkit';
import { increment, decrement, reset } from './actions';

const initialState = { count: 0 };

// Traditional reducer with switch-case
function 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;
  }
}

// Using createReducer with builder callback notation
const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state) => {
      // "mutate" state directly - Immer handles immutability
      state.count += 1;
    })
    .addCase(decrement, (state) => {
      state.count -= 1;
    })
    .addCase(reset, (state) => {
      state.count = 0;
    });
});

createAction

createAction generates action creator functions with less boilerplate:

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

// Traditional action creators
const increment = () => ({ type: 'INCREMENT' });
const addTodo = (text) => ({ 
  type: 'ADD_TODO', 
  payload: { id: Date.now(), text, completed: false } 
});

// With createAction
const increment = createAction('INCREMENT');
// Calling increment() returns { type: 'INCREMENT' }

const addTodo = createAction('ADD_TODO', (text) => ({
  payload: {
    id: Date.now(),
    text,
    completed: false
  }
}));
// Calling addTodo('Learn Redux') returns 
// { type: 'ADD_TODO', payload: { id: 1234567890, text: 'Learn Redux', completed: false } }

createSlice: The Heart of Redux Toolkit

createSlice is the most powerful feature of Redux Toolkit. It combines createReducer and createAction to generate action creators and reducers from a single configuration object:

graph TD A[createSlice] --> B[Slice Name] A --> C[Initial State] A --> D[Reducer Functions] A --> E[Extra Reducers] A -.-> F[Generated Output] F --> G[Slice Reducer] F --> H[Action Creators] F --> I[Action Types] style A fill:#f96 style F fill:#9af classDef default stroke:#333,stroke-width:2px;

Anatomy of createSlice

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

const counterSlice = createSlice({
  name: 'counter', // Namespace for action types
  initialState: { count: 0 }, // Initial state
  reducers: {
    // Object of reducer functions
    // Keys become action types (counter/increment)
    // Values become case reducers
    increment: (state) => {
      state.count += 1;
    },
    decrement: (state) => {
      state.count -= 1;
    },
    incrementByAmount: (state, action) => {
      state.count += action.payload;
    },
    reset: (state) => {
      state.count = 0;
    }
  }
});

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

// Export the reducer function
export default counterSlice.reducer;

With this single createSlice call, we've created:

The equivalent code in traditional Redux would be at least 3-4 times longer and spread across multiple files.

Handling Actions with Prepare Callbacks

For more complex action payloads, you can use prepare callbacks:

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // Using an object with reducer and prepare functions
    addTodo: {
      reducer: (state, action) => {
        state.push(action.payload);
      },
      prepare: (text) => ({
        payload: {
          id: Date.now(),
          text,
          completed: false,
          createdAt: new Date().toISOString()
        }
      })
    },
    toggleTodo: (state, action) => {
      const todo = state.find(todo => todo.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    }
  }
});

Practical Example: Building a Complete Redux Store

Let's build a complete example with multiple slices, store configuration, and React integration:

1. Create Feature Slices

// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

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

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

// Selectors
export const selectCount = (state) => state.counter.value;

export default counterSlice.reducer;

// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    status: 'idle',
    error: null
  },
  reducers: {
    addTodo: {
      reducer: (state, action) => {
        state.items.push(action.payload);
      },
      prepare: (text) => ({
        payload: {
          id: Date.now().toString(),
          text,
          completed: false,
          createdAt: new Date().toISOString()
        }
      })
    },
    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);
    }
  }
});

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

// Selectors
export const selectAllTodos = (state) => state.todos.items;
export const selectActiveTodos = (state) => 
  state.todos.items.filter(todo => !todo.completed);
export const selectCompletedTodos = (state) => 
  state.todos.items.filter(todo => todo.completed);

export default todosSlice.reducer;

2. Configure the Store

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer
  }
});

export default store;

3. Connect to React

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

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

// Counter Component
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
  increment,
  decrement,
  incrementByAmount,
  selectCount
} from './counterSlice';

export function Counter() {
  const count = useSelector(selectCount);
  const dispatch = useDispatch();
  const [incrementAmount, setIncrementAmount] = React.useState('2');

  return (
    <div>
      <div>
        <button onClick={() => dispatch(decrement())}>-</button>
        <span>{count}</span>
        <button onClick={() => dispatch(increment())}>+</button>
      </div>
      <div>
        <input
          value={incrementAmount}
          onChange={e => setIncrementAmount(e.target.value)}
        />
        <button
          onClick={() => dispatch(incrementByAmount(Number(incrementAmount) || 0))}
        >
          Add Amount
        </button>
      </div>
    </div>
  );
}

Redux Toolkit's Other Features

createSelector and Reselect

Redux Toolkit re-exports the createSelector function from the Reselect library, which helps create memoized selector functions:

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

// Basic selectors
const selectTodos = state => state.todos.items;
const selectFilter = state => state.todos.filter;

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

Selectors created with createSelector only recalculate when their inputs change, making them efficient for derived data.

createEntityAdapter

createEntityAdapter provides a standardized way to store and manage "normalized" data in your Redux store:

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

// Create an adapter for a specific entity type
const usersAdapter = createEntityAdapter({
  // Optional: Specify how to get the ID from entities
  selectId: (user) => user.userId,
  // Optional: Specify how to sort entities
  sortComparer: (a, b) => a.name.localeCompare(b.name)
});

// The adapter provides initial state and reducer functions
const initialState = usersAdapter.getInitialState({
  // You can add additional state properties
  status: 'idle',
  error: null
});

const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    // Use adapter methods in reducers
    userAdded: usersAdapter.addOne,
    usersReceived: usersAdapter.setAll,
    userUpdated: usersAdapter.updateOne,
    userRemoved: usersAdapter.removeOne
  }
});

// The adapter also provides selectors
export const {
  selectAll: selectAllUsers,
  selectById: selectUserById,
  selectIds: selectUserIds
} = usersAdapter.getSelectors(state => state.users);

createEntityAdapter is perfect for collections of items like users, products, or posts, where you need efficient CRUD operations and lookups by ID.

Best Practices with Redux Toolkit

Organize by Feature

Structure your Redux code by feature, not by type. Each feature folder should contain its slice file and related components:

src/
  features/
    users/
      usersSlice.js
      UsersList.js
      UserDetails.js
    posts/
      postsSlice.js
      PostsList.js
      PostForm.js
    comments/
      commentsSlice.js
      CommentsList.js
  app/
    store.js
    rootReducer.js

Use RTK Query for Data Fetching

For API calls, consider using RTK Query (part of Redux Toolkit) which handles caching, loading states, and more:

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

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com/' }),
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => 'users'
    }),
    getUserById: builder.query({
      query: (id) => `users/${id}`
    }),
    createUser: builder.mutation({
      query: (newUser) => ({
        url: 'users',
        method: 'POST',
        body: newUser
      })
    })
  })
});

Avoid Storing Derived Data

Use selectors to derive data instead of storing it in the state. This keeps the state minimal and prevents synchronization issues.

Use Action Payload Creators

Take advantage of prepare callbacks for complex action payloads to keep your components cleaner.

Use TypeScript for Type Safety

Redux Toolkit works extremely well with TypeScript, providing excellent type inference:

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

Practice Activity

Convert Todo App to Redux Toolkit

Take the Todo List application from the previous lecture's practice activity and convert it to use Redux Toolkit.

  1. Create a todosSlice using createSlice
  2. Add reducers for adding, toggling, and removing todos
  3. Create a filtersSlice for filtering todos
  4. Configure the store with configureStore
  5. Create selectors for filtering todos

Start with this skeleton code:

// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';

// TODO: Create a todos slice with addTodo, toggleTodo, and removeTodo reducers

// features/filters/filtersSlice.js
import { createSlice } from '@reduxjs/toolkit';

// TODO: Create a filters slice with setFilter reducer

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
// TODO: Import reducers and configure store

// features/todos/todosSelectors.js
import { createSelector } from '@reduxjs/toolkit';

// TODO: Create selectors for filtered todos

Bonus Challenge: Add a "clearCompleted" action and implement optimistic updates for toggle actions.

Key Takeaways

Additional Resources