AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Redux Toolkit: Modern State Management for React

Last updated:
Redux Toolkit: Modern State Management for React

Master Redux Toolkit for scalable state management. Learn createSlice, configureStore, async thunks, and production patterns with TypeScript.

# Redux Toolkit: Modern State Management for React

Redux has been the industry standard for managing complex application state, but traditional Redux requires extensive boilerplate: action creators, action types, reducer functions, and middleware configuration. Redux Toolkit eliminates this friction by providing opinionated APIs that handle the common cases, allowing you to write less code while maintaining Redux's powerful debugging and time-travel capabilities. This guide teaches you to build production-grade Redux applications with Redux Toolkit.

# Table of Contents

  1. Why Redux Toolkit?
  2. Installation and Setup
  3. Creating Slices with createSlice
  4. Configuring the Store
  5. Selecting State with useSelector
  6. Dispatching Actions with useDispatch
  7. Handling Async Operations
  8. Organizing Multiple Slices
  9. Performance Optimization
  10. Testing Redux Code
  11. Common Pitfalls
  12. FAQ

# Why Redux Toolkit?

# The Redux Problem

Traditional Redux requires writing significant boilerplate for every feature:

typescript
// ❌ Traditional Redux: too much code
// 1. Define action types
const INCREASE_COUNTER = 'INCREASE_COUNTER';
const DECREASE_COUNTER = 'DECREASE_COUNTER';

// 2. Define action creators
export const increaseCounter = () => ({ type: INCREASE_COUNTER });
export const decreaseCounter = () => ({ type: DECREASE_COUNTER });

// 3. Define reducer
export function counterReducer(state = 0, action) {
  switch (action.type) {
    case INCREASE_COUNTER:
      return state + 1;
    case DECREASE_COUNTER:
      return state - 1;
    default:
      return state;
  }
}

This verbosity becomes painful with large apps. Redux Toolkit solves this by providing utilities that generate boilerplate automatically.

# The Redux Toolkit Solution

typescript
// ✅ Redux Toolkit: minimal code, same power
import { createSlice } from '@reduxjs/toolkit';

export const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increaseCounter: (state) => state + 1,
    decreaseCounter: (state) => state - 1,
  },
});

export const { increaseCounter, decreaseCounter } = counterSlice.actions;
export default counterSlice.reducer;

Same functionality, 70% less code. Redux Toolkit handles action creation, action types, and reducer logic internally.

# Installation and Setup

# Install Dependencies

bash
npm install @reduxjs/toolkit react-redux
# or yarn
yarn add @reduxjs/toolkit react-redux

Redux Toolkit includes:

  • @reduxjs/toolkit: Core utilities (createSlice, configureStore, createAsyncThunk)
  • react-redux: React bindings (useSelector, useDispatch, Provider)

# Project Structure

typescript
src/
├── store/
│   ├── store.ts          # Configure store
│   ├── hooks.ts          # Custom typed hooks
│   └── slices/
│       ├── counterSlice.ts
│       ├── userSlice.ts
│       └── todoSlice.ts
├── App.tsx
└── index.tsx

# Creating Slices with createSlice

# Basic Slice Structure

A slice combines the reducer, actions, and initial state for a single feature:

TypeScript Version

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

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

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

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // Synchronous actions
    increment: (state) => {
      // Immer library lets us "mutate" state immutably
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    reset: (state) => {
      state.value = 0;
    },
    setStatus: (state, action: PayloadAction<'idle' | 'loading'>) => {
      state.status = action.payload;
    },
  },
});

// Export actions (auto-generated by createSlice)
export const { increment, decrement, incrementByAmount, reset, setStatus } =
  counterSlice.actions;

// Export reducer (default export convention)
export default counterSlice.reducer;

JavaScript Version

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

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

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

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

export default counterSlice.reducer;

# Key Insight: Immer Middleware

Redux Toolkit uses Immer under the hood. This means you can write "mutative" code in reducers, and Immer automatically converts it to immutable updates:

typescript
// This looks like mutation but is immutable
reducers: {
  increment: (state) => {
    state.value += 1;  // Safe! Immer handles the immutability
  },
}

// Equivalent to without Immer:
// return { ...state, value: state.value + 1 }

# Configuring the Store

# Creating the Store

Redux Toolkit's configureStore combines multiple slices and configures middleware automatically:

TypeScript Version

typescript
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
import todoReducer from './slices/todoSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
    todos: todoReducer,
  },
});

// Infer RootState and AppDispatch types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

JavaScript Version

javascript
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
import todoReducer from './slices/todoSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
    todos: todoReducer,
  },
});

# Providing the Store to React

Wrap your app with the Redux Provider:

typescript
import { Provider } from 'react-redux';
import { store } from './store/store';
import App from './App';

export default function Root() {
  return (
    <Provider store={store}>
      <App />
    </Provider>
  );
}

# Selecting State with useSelector

# Basic Selection

Select slices of state using useSelector:

TypeScript Version

typescript
import { useSelector } from 'react-redux';
import type { RootState } from './store/store';

export function Counter() {
  // Select the entire counter slice
  const counter = useSelector((state: RootState) => state.counter);

  return (
    <div>
      <p>Count: {counter.value}</p>
      <p>Status: {counter.status}</p>
    </div>
  );
}

JavaScript Version

javascript
import { useSelector } from 'react-redux';

export function Counter() {
  const counter = useSelector((state) => state.counter);

  return (
    <div>
      <p>Count: {counter.value}</p>
      <p>Status: {counter.status}</p>
    </div>
  );
}

# Creating Typed Hooks

To avoid repeatedly typing RootState, create pre-typed hooks:

TypeScript Version

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

// Pre-typed useSelector and useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();

Now use these hooks in components:

typescript
// ✅ Much cleaner
import { useAppSelector, useAppDispatch } from './store/hooks';

export function Counter() {
  const count = useAppSelector((state) => state.counter.value);
  const dispatch = useAppDispatch();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  );
}

# Memoized Selectors with Reselect

For expensive calculations, memoize selectors with reselect:

bash
npm install reselect
typescript
import { createSelector } from 'reselect';
import type { RootState } from './store';

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

// Memoized selector - only recalculates when todos change
export const selectCompletedTodos = createSelector(
  [selectTodos],
  (todos) => todos.filter((todo) => todo.completed)
);

export const selectCompletedCount = createSelector(
  [selectCompletedTodos],
  (completed) => completed.length
);

// In component
function TodoStats() {
  const completed = useAppSelector(selectCompletedCount);
  // Component only re-renders if completed count actually changes
  return <p>Completed: {completed}</p>;
}

# Dispatching Actions with useDispatch

# Basic Dispatch

typescript
import { useAppDispatch } from './store/hooks';
import { increment, incrementByAmount } from './store/slices/counterSlice';

export function Counter() {
  const dispatch = useAppDispatch();

  return (
    <div>
      <button onClick={() => dispatch(increment())}>
        Increment
      </button>
      <button onClick={() => dispatch(incrementByAmount(5))}>
        Add 5
      </button>
    </div>
  );
}

# Handling Action Payloads

Actions can carry data via payloads:

TypeScript Version

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

const userSlice = createSlice({
  name: 'user',
  initialState: null as User | null,
  reducers: {
    setUser: (state, action: PayloadAction<User>) => {
      return action.payload;
    },
    updateUserEmail: (state, action: PayloadAction<string>) => {
      if (state) {
        state.email = action.payload;
      }
    },
  },
});

// Dispatch with payload
const handleLogin = (user: User) => {
  dispatch(setUser(user));
};

JavaScript Version

javascript
const userSlice = createSlice({
  name: 'user',
  initialState: null,
  reducers: {
    setUser: (state, action) => {
      return action.payload;
    },
    updateUserEmail: (state, action) => {
      if (state) {
        state.email = action.payload;
      }
    },
  },
});

const handleLogin = (user) => {
  dispatch(setUser(user));
};

# Handling Async Operations

# createAsyncThunk

For async operations (API calls), use createAsyncThunk:

TypeScript Version

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

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

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
}

const initialState: UserState = {
  user: null,
  loading: false,
  error: null,
};

// Define the async thunk
export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: string, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) {
        throw new Error('Failed to fetch user');
      }
      return await response.json() as User;
    } catch (error) {
      return rejectWithValue(
        error instanceof Error ? error.message : 'Unknown error'
      );
    }
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    clearUser: (state) => {
      state.user = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.user = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload as string;
      });
  },
});

export const { clearUser } = userSlice.actions;
export default userSlice.reducer;

JavaScript Version

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

export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) {
        throw new Error('Failed to fetch user');
      }
      return await response.json();
    } catch (error) {
      return rejectWithValue(
        error instanceof Error ? error.message : 'Unknown error'
      );
    }
  }
);

const initialState = {
  user: null,
  loading: false,
  error: null,
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    clearUser: (state) => {
      state.user = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.user = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  },
});

export const { clearUser } = userSlice.actions;
export default userSlice.reducer;

# Using Async Thunks in Components

typescript
import { useAppDispatch, useAppSelector } from './store/hooks';
import { fetchUser } from './store/slices/userSlice';

export function UserProfile({ userId }: { userId: string }) {
  const dispatch = useAppDispatch();
  const { user, loading, error } = useAppSelector((state) => state.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId, dispatch]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>ID: {user.id}</p>
    </div>
  );
}

# Organizing Multiple Slices

# Slice Organization Pattern

typescript
// store/slices/index.ts
export { default as counterReducer } from './counterSlice';
export { default as userReducer } from './userSlice';
export { default as todoReducer } from './todoSlice';

export * from './counterSlice';
export * from './userSlice';
export * from './todoSlice';

# Clean Store Configuration

typescript
// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import {
  counterReducer,
  userReducer,
  todoReducer,
} from './slices';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
    todos: todoReducer,
  },
});

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

# Performance Optimization

# Selector Memoization

Prevent unnecessary re-renders by memoizing selectors:

typescript
// ❌ PROBLEM: Creates new object every render
const selectCounter = (state: RootState) => ({
  value: state.counter.value,
  status: state.counter.status,
});

// ✅ SOLUTION: Memoize with createSelector
import { createSelector } from 'reselect';

const selectCounterValue = (state: RootState) => state.counter.value;
const selectCounterStatus = (state: RootState) => state.counter.status;

export const selectCounter = createSelector(
  [selectCounterValue, selectCounterStatus],
  (value, status) => ({ value, status })
);

# Entity Adapters for Normalized State

For managing collections, use createEntityAdapter:

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

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

const todoAdapter = createEntityAdapter<Todo>();

const todoSlice = createSlice({
  name: 'todos',
  initialState: todoAdapter.getInitialState(),
  reducers: {
    addTodo: todoAdapter.addOne,
    updateTodo: todoAdapter.updateOne,
    removeTodo: todoAdapter.removeOne,
  },
});

// Entity adapter provides optimized selectors
export const {
  selectAll: selectAllTodos,
  selectById: selectTodoById,
  selectIds: selectTodoIds,
} = todoAdapter.getSelectors((state: RootState) => state.todos);

# Testing Redux Code

# Testing Reducers

typescript
import counterReducer, { increment, decrement } from './counterSlice';

describe('counterSlice', () => {
  it('should handle increment', () => {
    const initialState = { value: 0, status: 'idle' };
    const result = counterReducer(
      initialState,
      increment()
    );
    expect(result.value).toBe(1);
  });

  it('should handle decrement', () => {
    const initialState = { value: 5, status: 'idle' };
    const result = counterReducer(
      initialState,
      decrement()
    );
    expect(result.value).toBe(4);
  });
});

# Testing Async Thunks

typescript
import { fetchUser } from './userSlice';
import { configureStore } from '@reduxjs/toolkit';

describe('fetchUser', () => {
  it('should fetch user successfully', async () => {
    const store = configureStore({
      reducer: { user: userReducer },
    });

    await store.dispatch(fetchUser('123'));
    const state = store.getState();

    expect(state.user.loading).toBe(false);
    expect(state.user.user).toBeDefined();
  });
});

# Testing Selectors

typescript
import { selectCompletedTodos } from './todoSlice';

it('should select completed todos', () => {
  const state: RootState = {
    todos: {
      items: [
        { id: '1', title: 'Learn Redux', completed: true },
        { id: '2', title: 'Build app', completed: false },
      ],
    },
  };

  const completed = selectCompletedTodos(state);
  expect(completed).toHaveLength(1);
});

# Common Pitfalls

# Pitfall 1: Mutating State Outside of Reducers

typescript
// ❌ WRONG: Mutating outside reducer
const user = useAppSelector((state) => state.user);
user.name = 'New Name'; // Mutating returned state!

// ✅ CORRECT: Dispatch action to update
dispatch(updateUserName('New Name'));

# Pitfall 2: Forgetting Immer in Custom Objects

typescript
// ❌ PROBLEM: Returns undefined
reducers: {
  addItem: (state, action) => {
    state.items.push(action.payload);
    // Forgot to return!
  }
}

// ✅ CORRECT: Immer handles mutation internally
reducers: {
  addItem: (state, action) => {
    state.items.push(action.payload);
    // No return needed - state is updated
  }
}

# Pitfall 3: Creating New Objects in Selectors

typescript
// ❌ PROBLEM: Component re-renders on every state change
const selectUserInfo = (state: RootState) => ({
  name: state.user.name,
  email: state.user.email,
});

// ✅ SOLUTION: Use createSelector
import { createSelector } from 'reselect';

export const selectUserInfo = createSelector(
  [(state: RootState) => state.user.name, (state: RootState) => state.user.email],
  (name, email) => ({ name, email })
);

# FAQ

Q: When should I use Redux Toolkit vs Context API?

A: Use Redux Toolkit for large apps with complex state, many actions, and debugging needs. Use Context API for simpler state (theme, auth, notifications). Redux Toolkit excels with complex state and middleware.

Q: Can I use Redux Toolkit with TypeScript?

A: Yes, Redux Toolkit has excellent TypeScript support. Create pre-typed hooks using TypedUseSelectorHook and useDispatch<AppDispatch>().

Q: How do I handle errors in async thunks?

A: Use rejectWithValue to return custom error data:

typescript
export const fetchData = createAsyncThunk(
  'data/fetch',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/data');
      if (!response.ok) {
        return rejectWithValue({ status: response.status });
      }
      return response.json();
    } catch (error) {
      return rejectWithValue({ error: 'Network error' });
    }
  }
);

Q: How do I debug Redux state?

A: Redux DevTools extension is configured by default in Redux Toolkit. Install the browser extension and use the time-travel debugger to inspect every action and state change.

Q: Can slices reference other slices?

A: Yes, in extraReducers:

typescript
const featureSlice = createSlice({
  name: 'feature',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(someOtherAction, (state, action) => {
      // Handle actions from other slices
    });
  },
});

Q: What's the difference between reducers and extraReducers?

A: reducers define actions created by the slice. extraReducers handle actions from other slices or async thunks. This keeps the slice focused on its own domain.


Key Takeaway: Redux Toolkit eliminates Redux boilerplate while maintaining its power for complex state management. Combine createSlice for feature state, createAsyncThunk for API calls, and createSelector for performance. Use pre-typed hooks to maximize TypeScript safety.

Questions? How are you managing complex state? Share your Redux Toolkit patterns in the comments.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT