Skip to main content

⚡ Lesson 8.3: Redux Toolkit (RTK)

Welcome to modern Redux! Redux Toolkit has revolutionized how we use Redux by dramatically reducing boilerplate while maintaining all the power of Redux. If you've heard Redux is "too complex" or "too much code," RTK solves those problems. In this comprehensive lesson, you'll learn how to use Redux Toolkit to build scalable, maintainable state management for complex applications. We'll cover slices, async thunks, RTK Query, and TypeScript integration from the ground up.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand Redux core concepts and when to use Redux vs other solutions
  • Install and configure Redux Toolkit in a React TypeScript project
  • Create type-safe slices with reducers and actions
  • Handle async operations with createAsyncThunk
  • Use RTK Query for powerful data fetching and caching
  • Integrate Redux DevTools for debugging
  • Write properly typed Redux code with TypeScript
  • Organize Redux code in a scalable way
  • Apply Redux Toolkit best practices
  • Compare Redux Toolkit with Zustand and Context

Estimated Time: 75-90 minutes

Prerequisites: Lessons 8.1-8.2 (State Management Overview, Zustand Basics), Modules 1-5

📑 In This Lesson

🤔 Understanding Redux and Redux Toolkit

Before diving into Redux Toolkit, let's understand what problems it solves and when you should use it.

What is Redux?

Redux is a predictable state container for JavaScript applications. It provides a centralized store for all application state with strict rules about how state can be updated.

📖 Definition

Redux is a state management library based on the Flux architecture that uses a single, immutable state tree and pure reducer functions to handle state changes. Redux Toolkit (RTK) is the official, opinionated toolset for efficient Redux development that reduces boilerplate and enforces best practices.

Core Redux Concepts

graph LR A[Action Dispatched] --> B[Store] B --> C[Reducer] C --> D[New State] D --> B B --> E[UI Updates] E --> A style A fill:#e3f2fd style B fill:#c8e6c9 style C fill:#fff3cd style D fill:#ffcdd2 style E fill:#f0f0f0

Redux follows three fundamental principles:

  1. Single Source of Truth: The entire state of your application is stored in a single object tree within a single store
  2. State is Read-Only: The only way to change state is to dispatch an action, an object describing what happened
  3. Changes are Made with Pure Functions: Reducers are pure functions that take the previous state and an action, and return the next state

The Problem with Classic Redux

Traditional Redux requires a lot of boilerplate code:

// Classic Redux - Too much boilerplate! 😫

// Action types
const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';
const SET_COUNT = 'counter/SET_COUNT';

// Action creators
function increment() {
  return { type: INCREMENT };
}

function decrement() {
  return { type: DECREMENT };
}

function setCount(count: number) {
  return { type: SET_COUNT, payload: count };
}

// Reducer
interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0
};

function counterReducer(
  state = initialState, 
  action: { type: string; payload?: any }
): CounterState {
  switch (action.type) {
    case INCREMENT:
      return { ...state, value: state.value + 1 };
    case DECREMENT:
      return { ...state, value: state.value - 1 };
    case SET_COUNT:
      return { ...state, value: action.payload };
    default:
      return state;
  }
}

⚠️ Problems with Classic Redux

  • Too much boilerplate for simple operations
  • Manual action type constants
  • Repetitive action creators
  • Verbose switch statements
  • Manual immutable updates with spread operators
  • Complex async logic setup

How Redux Toolkit Solves These Problems

Redux Toolkit reduces the same counter to just this:

// Redux Toolkit - Clean and concise! 🎉

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

interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1; // Looks like mutation, but it's actually immutable!
    },
    decrement: (state) => {
      state.value -= 1;
    },
    setCount: (state, action) => {
      state.value = action.payload;
    }
  }
});

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

✅ Redux Toolkit Benefits

  • Less Boilerplate: Drastically reduced code
  • Immer Integration: Write "mutating" logic that's actually immutable
  • Auto-Generated Actions: No manual action creators needed
  • Built-in DevTools: Redux DevTools support out of the box
  • TypeScript Support: Excellent type inference
  • RTK Query: Powerful data fetching and caching

When to Use Redux Toolkit

Redux Toolkit is ideal when you need:

Use Redux Toolkit When... Consider Alternatives When...
Complex, shared state across many components Simple, local component state (use useState)
Predictable state updates with clear actions Minimal state management needs (use Context)
Time-travel debugging and action history Simpler API integration (use React Query)
Large team needing standardized patterns Quick prototypes or small apps (use Zustand)
Middleware for complex side effects Server-state only (use React Query/SWR)

💡 The Sweet Spot for Redux Toolkit

RTK shines in medium-to-large applications with:

  • Multiple features with interconnected state
  • Need for time-travel debugging
  • Complex state update logic
  • Team that values explicit, traceable state changes
  • Applications that will scale significantly

⚙️ Installation and Setup

Let's get Redux Toolkit up and running in a React TypeScript project.

Installing Redux Toolkit

# Install Redux Toolkit and React-Redux
npm install @reduxjs/toolkit react-redux

# For TypeScript projects, types are included!
# No need for @types packages

Project Structure

Organize your Redux code in a scalable way:

src/
  store/
    index.ts                 // Store configuration
    hooks.ts                 // Typed hooks (useAppDispatch, useAppSelector)
    
  features/
    auth/
      authSlice.ts          // Auth feature slice
      authAPI.ts            // Auth API calls (optional)
    
    cart/
      cartSlice.ts          // Shopping cart slice
      
    products/
      productsSlice.ts      // Products slice
      productsAPI.ts        // Products API with RTK Query
      
  components/
    auth/
      LoginForm.tsx         // Uses auth slice
    cart/
      CartSummary.tsx       // Uses cart slice

Creating the Store

Set up the Redux store with TypeScript support:

// src/store/index.ts

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import authReducer from '../features/auth/authSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    auth: authReducer,
    // Add more reducers here
  },
  // Redux Toolkit includes Redux DevTools by default in development!
});

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

Creating Typed Hooks

Create pre-typed versions of Redux hooks for better TypeScript support:

// src/store/hooks.ts

import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './index';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

✅ Why Custom Hooks?

Using useAppDispatch and useAppSelector instead of the plain Redux hooks gives you:

  • Full TypeScript autocomplete for your state shape
  • Type-safe dispatch of actions
  • Compile-time errors for invalid selectors
  • No need to manually type selectors

Providing the Store

Wrap your app with the Redux Provider:

// src/main.tsx (or App.tsx)

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

💡 Redux DevTools

Redux Toolkit includes Redux DevTools Extension support by default. Install the browser extension to see:

  • All dispatched actions
  • State before and after each action
  • Time-travel debugging (replay actions)
  • State diff visualization
  • Action stack traces

Install Redux DevTools →

🍰 Creating Your First Slice

A "slice" is a collection of Redux reducer logic and actions for a single feature. Let's build a complete counter feature to understand all the concepts.

Basic Slice Structure

// src/features/counter/counterSlice.ts

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

// Define the shape of the state
interface CounterState {
  value: number;
  step: number;
}

// Define the initial state
const initialState: CounterState = {
  value: 0,
  step: 1
};

// Create the slice
const counterSlice = createSlice({
  name: 'counter', // Used to generate action types
  initialState,
  reducers: {
    // Reducer with no payload
    increment: (state) => {
      // Immer makes this safe!
      state.value += state.step;
    },
    
    // Reducer with no payload
    decrement: (state) => {
      state.value -= state.step;
    },
    
    // Reducer with payload
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    
    // Reducer to update step
    setStep: (state, action: PayloadAction<number>) => {
      state.step = action.payload;
    },
    
    // Reducer to reset state
    reset: (state) => {
      state.value = 0;
      state.step = 1;
    }
  }
});

// Export actions (automatically generated)
export const { 
  increment, 
  decrement, 
  incrementByAmount, 
  setStep, 
  reset 
} = counterSlice.actions;

// Export reducer
export default counterSlice.reducer;

📖 Understanding PayloadAction

PayloadAction<T> is a TypeScript type from Redux Toolkit that represents an action with a typed payload. When you define action: PayloadAction<number>, you're saying "this action carries a number as its payload."

How Immer Works

Redux Toolkit uses Immer internally, which allows you to write code that looks like it's mutating state, but actually produces immutable updates:

// ❌ Without Immer (Classic Redux) - Manual immutability
function increment(state: CounterState): CounterState {
  return {
    ...state,
    value: state.value + state.step
  };
}

// ✅ With Immer (Redux Toolkit) - Simpler "mutable" style
function increment(state: CounterState) {
  state.value += state.step;
  // Immer tracks changes and produces a new immutable state
}

Adding the Slice to the Store

// src/store/index.ts

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer, // Key determines how you access state
  }
});

export type RootState = ReturnType<typeof store.getState>;
// RootState will be: { counter: CounterState }

export type AppDispatch = typeof store.dispatch;

Complex State Updates

Immer handles nested objects and arrays beautifully:

// src/features/todos/todosSlice.ts

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

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

interface TodosState {
  items: Todo[];
  filter: 'all' | 'active' | 'completed';
}

const initialState: TodosState = {
  items: [],
  filter: 'all'
};

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immer lets us push to arrays!
      state.items.push({
        id: Date.now().toString(),
        text: action.payload,
        completed: false
      });
    },
    
    toggleTodo: (state, action: PayloadAction<string>) => {
      // Find and "mutate" - Immer handles immutability
      const todo = state.items.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    
    removeTodo: (state, action: PayloadAction<string>) => {
      // Filter works too
      state.items = state.items.filter(t => t.id !== action.payload);
    },
    
    setFilter: (state, action: PayloadAction<'all' | 'active' | 'completed'>) => {
      state.filter = action.payload;
    }
  }
});

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

⚠️ Immer Limitations

Immer is powerful, but has a few rules:

  • You must either mutate the state directly OR return a new state, not both
  • Cannot reassign the entire state parameter (use return newState instead)
  • Doesn't work with non-plain objects (class instances)
// ❌ Bad: Both mutating AND returning
reducers: {
  bad: (state) => {
    state.value = 5;
    return { value: 10 }; // DON'T DO THIS!
  }
}

// ✅ Good: Either mutate...
reducers: {
  good1: (state) => {
    state.value = 5;
  },
  
  // ...OR return new state
  good2: (state) => {
    return { value: 10 };
  }
}

⚛️ Using Redux in Components

Now let's use our Redux slices in React components with full TypeScript support.

Reading State with useAppSelector

// src/components/Counter.tsx

import { useAppSelector } from '../../store/hooks';

function Counter() {
  // Select state from the store
  const count = useAppSelector((state) => state.counter.value);
  const step = useAppSelector((state) => state.counter.step);
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <p>Step: {step}</p>
    </div>
  );
}

Dispatching Actions with useAppDispatch

// src/components/CounterControls.tsx

import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { 
  increment, 
  decrement, 
  incrementByAmount, 
  reset 
} from '../../features/counter/counterSlice';

function CounterControls() {
  const dispatch = useAppDispatch();
  const count = useAppSelector((state) => state.counter.value);
  
  return (
    <div>
      <h2>Count: {count}</h2>
      
      <button onClick={() => dispatch(increment())}>
        +1
      </button>
      
      <button onClick={() => dispatch(decrement())}>
        -1
      </button>
      
      <button onClick={() => dispatch(incrementByAmount(5))}>
        +5
      </button>
      
      <button onClick={() => dispatch(reset())}>
        Reset
      </button>
    </div>
  );
}

Selecting Multiple Values

// Option 1: Multiple selectors (best for performance)
function TodoList() {
  const todos = useAppSelector((state) => state.todos.items);
  const filter = useAppSelector((state) => state.todos.filter);
  
  // Component only re-renders when todos or filter change
}

// Option 2: Single selector returning object (re-renders if ANY field changes)
function TodoList() {
  const { items, filter } = useAppSelector((state) => ({
    items: state.todos.items,
    filter: state.todos.filter
  }));
  
  // Re-renders even if object contents are the same (new object reference)
}

Creating Selector Functions

For reusable, computed selectors:

// src/features/todos/todosSlice.ts

// ... slice definition ...

// Selector functions
export const selectAllTodos = (state: RootState) => state.todos.items;

export const selectActiveTodos = (state: RootState) =>
  state.todos.items.filter(todo => !todo.completed);

export const selectCompletedTodos = (state: RootState) =>
  state.todos.items.filter(todo => todo.completed);

export const selectFilteredTodos = (state: RootState) => {
  const { items, filter } = state.todos;
  
  switch (filter) {
    case 'active':
      return items.filter(t => !t.completed);
    case 'completed':
      return items.filter(t => t.completed);
    default:
      return items;
  }
};

// Usage in components
import { useAppSelector } from '../../store/hooks';
import { selectFilteredTodos } from './todosSlice';

function TodoList() {
  const filteredTodos = useAppSelector(selectFilteredTodos);
  
  return (
    <ul>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

✅ Benefits of Selector Functions

  • Reusability: Use the same selector in multiple components
  • Testability: Easy to test selectors independently
  • Maintainability: Change selector logic in one place
  • Encapsulation: Hide state structure details
  • Performance: Can be memoized with Reselect

Complete Todo Example

// src/components/TodoApp.tsx

import React, { useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
  addTodo,
  toggleTodo,
  removeTodo,
  setFilter,
  selectFilteredTodos
} from '../../features/todos/todosSlice';

function TodoApp() {
  const dispatch = useAppDispatch();
  const filteredTodos = useAppSelector(selectFilteredTodos);
  const filter = useAppSelector((state) => state.todos.filter);
  const [inputText, setInputText] = useState('');
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (inputText.trim()) {
      dispatch(addTodo(inputText));
      setInputText('');
    }
  };
  
  return (
    <div>
      <h1>Todo List</h1>
      
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          placeholder="Add a todo..."
        />
        <button type="submit">Add</button>
      </form>
      
      <div>
        <button 
          onClick={() => dispatch(setFilter('all'))}
          disabled={filter === 'all'}
        >
          All
        </button>
        <button 
          onClick={() => dispatch(setFilter('active'))}
          disabled={filter === 'active'}
        >
          Active
        </button>
        <button 
          onClick={() => dispatch(setFilter('completed'))}
          disabled={filter === 'completed'}
        >
          Completed
        </button>
      </div>
      
      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch(toggleTodo(todo.id))}
            />
            <span 
              style={{ 
                textDecoration: todo.completed ? 'line-through' : 'none' 
              }}
            >
              {todo.text}
            </span>
            <button onClick={() => dispatch(removeTodo(todo.id))}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

💡 Component Best Practices

  • Use useAppSelector and useAppDispatch instead of plain Redux hooks
  • Select only the data you need (avoid selecting entire slices)
  • Extract selector logic into selector functions for reusability
  • Dispatch actions rather than calling action creators directly
  • Keep local UI state in useState, only put shared state in Redux

📘 TypeScript Integration

Redux Toolkit has excellent TypeScript support built-in. Let's explore advanced typing patterns to make your Redux code fully type-safe.

Typing the Store

We already created typed hooks, but let's understand the types in depth:

// src/store/index.ts

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';
import authReducer from '../features/auth/authSlice';

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

// Extract the root state type from the store
export type RootState = ReturnType<typeof store.getState>;
// RootState = {
//   counter: CounterState,
//   todos: TodosState,
//   auth: AuthState
// }

// Extract the dispatch type
export type AppDispatch = typeof store.dispatch;

// Optional: Extract store type itself
export type AppStore = typeof store;

Typing Slices with Prepare Callbacks

Sometimes you need to transform or add metadata to actions. Use prepare callbacks:

// src/features/posts/postsSlice.ts

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

interface Post {
  id: string;
  title: string;
  content: string;
  createdAt: string;
}

interface PostsState {
  posts: Post[];
}

const initialState: PostsState = {
  posts: []
};

const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    // Simple action - RTK generates the prepare function
    addPost: {
      // Reducer function
      reducer: (state, action: PayloadAction<Post>) => {
        state.posts.push(action.payload);
      },
      // Prepare function - transforms arguments into payload
      prepare: (title: string, content: string) => {
        return {
          payload: {
            id: nanoid(), // Generate unique ID
            title,
            content,
            createdAt: new Date().toISOString()
          }
        };
      }
    },
    
    // You can also add metadata
    postUpdated: {
      reducer: (state, action: PayloadAction<Post, string, { updatedAt: string }>) => {
        const index = state.posts.findIndex(p => p.id === action.payload.id);
        if (index !== -1) {
          state.posts[index] = action.payload;
        }
      },
      prepare: (post: Post) => {
        return {
          payload: post,
          meta: {
            updatedAt: new Date().toISOString()
          }
        };
      }
    }
  }
});

export const { addPost, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;

// Usage in component
function CreatePost() {
  const dispatch = useAppDispatch();
  
  const handleSubmit = (title: string, content: string) => {
    // ID and timestamp added automatically!
    dispatch(addPost(title, content));
  };
}

📖 Understanding Prepare Callbacks

Prepare callbacks let you customize how action payloads are created. They're useful for generating IDs, adding timestamps, or transforming multiple arguments into a single payload object. The prepare function returns an object with payload and optionally meta and error.

Typing Action Creators

Action creators generated by createSlice are fully typed:

import { addTodo, toggleTodo } from './todosSlice';

// Action creators have proper types
const action1 = addTodo('Learn Redux');
// type: { type: 'todos/addTodo', payload: string }

const action2 = toggleTodo('todo-id-123');
// type: { type: 'todos/toggleTodo', payload: string }

// TypeScript error if wrong payload type
const action3 = addTodo(123); // ❌ Error: number not assignable to string

Typing Selectors

Create strongly-typed selectors:

// src/features/todos/todosSelectors.ts

import { RootState } from '../../store';
import { createSelector } from '@reduxjs/toolkit';

// Basic selector with explicit typing
export const selectTodos = (state: RootState) => state.todos.items;

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

// Memoized selector with createSelector
export const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active':
        return todos.filter(t => !t.completed);
      case 'completed':
        return todos.filter(t => t.completed);
      default:
        return todos;
    }
  }
);

// Selector with parameter
export const selectTodoById = (state: RootState, todoId: string) =>
  state.todos.items.find(todo => todo.id === todoId);

// Memoized parametric selector
export const makeSelectTodoById = () =>
  createSelector(
    [selectTodos, (_state: RootState, todoId: string) => todoId],
    (todos, todoId) => todos.find(todo => todo.id === todoId)
  );

✅ Benefits of createSelector

Redux Toolkit re-exports createSelector from Reselect. It provides:

  • Memoization: Only recalculates when inputs change
  • Composition: Build complex selectors from simple ones
  • Performance: Prevents unnecessary recalculations
  • Type Safety: Input and output types are inferred

Typing Middleware

If you add custom middleware, type it properly:

// src/store/middleware/logger.ts

import { Middleware } from '@reduxjs/toolkit';
import { RootState } from '../index';

export const loggerMiddleware: Middleware<{}, RootState> = 
  (storeAPI) => (next) => (action) => {
    console.log('Dispatching:', action);
    console.log('Current state:', storeAPI.getState());
    
    const result = next(action);
    
    console.log('Next state:', storeAPI.getState());
    return result;
  };

// Add to store
export const store = configureStore({
  reducer: {
    // ... reducers
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(loggerMiddleware)
});

Typing Extra Reducers

When handling actions from other slices or async thunks, use extraReducers:

// src/features/notifications/notificationsSlice.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { addTodo, toggleTodo } from '../todos/todosSlice';

interface Notification {
  id: string;
  message: string;
  type: 'info' | 'success' | 'error';
}

interface NotificationsState {
  notifications: Notification[];
}

const initialState: NotificationsState = {
  notifications: []
};

const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {
    addNotification: (state, action: PayloadAction<Notification>) => {
      state.notifications.push(action.payload);
    },
    removeNotification: (state, action: PayloadAction<string>) => {
      state.notifications = state.notifications.filter(
        n => n.id !== action.payload
      );
    }
  },
  extraReducers: (builder) => {
    // Listen to actions from other slices
    builder
      .addCase(addTodo, (state) => {
        state.notifications.push({
          id: Date.now().toString(),
          message: 'Todo added successfully!',
          type: 'success'
        });
      })
      .addCase(toggleTodo, (state) => {
        state.notifications.push({
          id: Date.now().toString(),
          message: 'Todo status updated!',
          type: 'info'
        });
      });
  }
});

export const { addNotification, removeNotification } = notificationsSlice.actions;
export default notificationsSlice.reducer;

💡 When to Use extraReducers

Use extraReducers when you need to:

  • Respond to actions defined in other slices
  • Handle async thunk actions (pending, fulfilled, rejected)
  • Cross-slice communication
  • Side effects triggered by actions from elsewhere

Generic Slice Patterns

Create reusable slice factories with generics:

// src/store/genericSlice.ts

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

interface LoadingState<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

function createLoadingSlice<T>(name: string) {
  const initialState: LoadingState<T> = {
    data: null,
    isLoading: false,
    error: null
  };

  return createSlice({
    name,
    initialState,
    reducers: {
      startLoading: (state) => {
        state.isLoading = true;
        state.error = null;
      },
      loadSuccess: (state, action: PayloadAction<T>) => {
        state.data = action.payload;
        state.isLoading = false;
        state.error = null;
      },
      loadFailure: (state, action: PayloadAction<string>) => {
        state.isLoading = false;
        state.error = action.payload;
      },
      reset: (state) => {
        state.data = null;
        state.isLoading = false;
        state.error = null;
      }
    }
  });
}

// Usage
interface User {
  id: string;
  name: string;
  email: string;
}

interface Product {
  id: string;
  name: string;
  price: number;
}

const userSlice = createLoadingSlice<User>('user');
const productsSlice = createLoadingSlice<Product[]>('products');

export const userActions = userSlice.actions;
export const productsActions = productsSlice.actions;

Strict Typing for Action Matchers

Use type guards and matchers for complex scenarios:

import { 
  createSlice, 
  isAnyOf, 
  isPending, 
  isFulfilled, 
  isRejected 
} from '@reduxjs/toolkit';
import { fetchUser, updateUser, deleteUser } from './userThunks';

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    // Match multiple actions at once
    builder
      .addMatcher(
        isAnyOf(fetchUser.pending, updateUser.pending, deleteUser.pending),
        (state) => {
          state.isLoading = true;
        }
      )
      .addMatcher(
        isAnyOf(fetchUser.fulfilled, updateUser.fulfilled),
        (state, action) => {
          state.user = action.payload;
          state.isLoading = false;
        }
      )
      .addMatcher(
        isAnyOf(fetchUser.rejected, updateUser.rejected, deleteUser.rejected),
        (state, action) => {
          state.error = action.error.message || 'Something went wrong';
          state.isLoading = false;
        }
      );
  }
});

⚠️ TypeScript Best Practices

  • Always define state interfaces before creating slices
  • Use PayloadAction<T> for typed action payloads
  • Extract RootState and AppDispatch types
  • Create typed hooks (useAppSelector, useAppDispatch)
  • Use createSelector for memoized, typed selectors
  • Leverage TypeScript's inference - don't over-annotate

⏳ Async Operations with createAsyncThunk

Most real applications need to fetch data from APIs. Redux Toolkit provides createAsyncThunk to handle async logic with automatic action creators for pending, fulfilled, and rejected states.

Understanding createAsyncThunk

graph LR A[Dispatch Thunk] --> B[Pending Action] B --> C[Async Function] C --> D{Success?} D -->|Yes| E[Fulfilled Action] D -->|No| F[Rejected Action] E --> G[Update State] F --> G style A fill:#e3f2fd style B fill:#fff3cd style C fill:#e3f2fd style D fill:#f0f0f0 style E fill:#c8e6c9 style F fill:#ffcdd2 style G fill:#c8e6c9

When you create an async thunk, Redux Toolkit automatically generates three action types:

  • pending: Dispatched immediately when thunk is called
  • fulfilled: Dispatched when promise resolves successfully
  • rejected: Dispatched when promise rejects or throws error

Basic Async Thunk

// src/features/users/usersSlice.ts

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

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

interface UsersState {
  users: User[];
  isLoading: boolean;
  error: string | null;
}

const initialState: UsersState = {
  users: [],
  isLoading: false,
  error: null
};

// Create async thunk
export const fetchUsers = createAsyncThunk(
  'users/fetchUsers', // Action type prefix
  async () => {
    const response = await fetch('https://api.example.com/users');
    
    if (!response.ok) {
      throw new Error('Failed to fetch users');
    }
    
    const data = await response.json();
    return data as User[]; // This becomes the 'fulfilled' action payload
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      // Handle pending state
      .addCase(fetchUsers.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      // Handle fulfilled state
      .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
        state.users = action.payload;
        state.isLoading = false;
      })
      // Handle rejected state
      .addCase(fetchUsers.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.error.message || 'Failed to fetch users';
      });
  }
});

export default usersSlice.reducer;

Using Async Thunks in Components

// src/components/UserList.tsx

import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { fetchUsers } from '../../features/users/usersSlice';

function UserList() {
  const dispatch = useAppDispatch();
  const { users, isLoading, error } = useAppSelector((state) => state.users);
  
  useEffect(() => {
    // Dispatch the async thunk
    dispatch(fetchUsers());
  }, [dispatch]);
  
  if (isLoading) {
    return <div>Loading users...</div>;
  }
  
  if (error) {
    return <div>Error: {error}</div>;
  }
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  );
}

Async Thunks with Arguments

// Fetch a single user by ID
export const fetchUserById = createAsyncThunk(
  'users/fetchUserById',
  async (userId: string) => {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    
    if (!response.ok) {
      throw new Error('User not found');
    }
    
    return await response.json() as User;
  }
);

// Create a new user
export const createUser = createAsyncThunk(
  'users/createUser',
  async (userData: Omit<User, 'id'>) => {
    const response = await fetch('https://api.example.com/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(userData)
    });
    
    if (!response.ok) {
      throw new Error('Failed to create user');
    }
    
    return await response.json() as User;
  }
);

// Update existing user
export const updateUser = createAsyncThunk(
  'users/updateUser',
  async ({ id, updates }: { id: string; updates: Partial<User> }) => {
    const response = await fetch(`https://api.example.com/users/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updates)
    });
    
    if (!response.ok) {
      throw new Error('Failed to update user');
    }
    
    return await response.json() as User;
  }
);

// Delete user
export const deleteUser = createAsyncThunk(
  'users/deleteUser',
  async (userId: string) => {
    const response = await fetch(`https://api.example.com/users/${userId}`, {
      method: 'DELETE'
    });
    
    if (!response.ok) {
      throw new Error('Failed to delete user');
    }
    
    return userId; // Return the deleted user's ID
  }
);

Handling All CRUD Operations

// Complete users slice with CRUD operations

const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    // Synchronous actions if needed
    clearError: (state) => {
      state.error = null;
    }
  },
  extraReducers: (builder) => {
    builder
      // Fetch all users
      .addCase(fetchUsers.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.users = action.payload;
        state.isLoading = false;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.error.message || 'Failed to fetch users';
      })
      
      // Create user
      .addCase(createUser.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(createUser.fulfilled, (state, action) => {
        state.users.push(action.payload);
        state.isLoading = false;
      })
      .addCase(createUser.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.error.message || 'Failed to create user';
      })
      
      // Update user
      .addCase(updateUser.fulfilled, (state, action) => {
        const index = state.users.findIndex(u => u.id === action.payload.id);
        if (index !== -1) {
          state.users[index] = action.payload;
        }
      })
      
      // Delete user
      .addCase(deleteUser.fulfilled, (state, action) => {
        state.users = state.users.filter(u => u.id !== action.payload);
      });
  }
});

export const { clearError } = usersSlice.actions;
export default usersSlice.reducer;

Custom Error Handling

// Thunk with custom error handling
export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('https://api.example.com/users');
      
      if (!response.ok) {
        // Return custom error
        return rejectWithValue({
          message: 'Failed to fetch users',
          status: response.status
        });
      }
      
      return await response.json() as User[];
    } catch (error) {
      // Handle network errors
      if (error instanceof Error) {
        return rejectWithValue({
          message: error.message,
          status: 0
        });
      }
      
      return rejectWithValue({
        message: 'Unknown error occurred',
        status: 0
      });
    }
  }
);

// Handle in reducer
.addCase(fetchUsers.rejected, (state, action) => {
  state.isLoading = false;
  
  if (action.payload) {
    // Custom error from rejectWithValue
    state.error = `Error ${action.payload.status}: ${action.payload.message}`;
  } else {
    // Standard error
    state.error = action.error.message || 'Failed to fetch users';
  }
});

✅ createAsyncThunk Best Practices

  • Use rejectWithValue for custom error payloads
  • Always handle pending, fulfilled, and rejected states
  • Use TypeScript generics for payload and return types
  • Extract API calls to separate service files
  • Use thunkAPI for accessing state, dispatch, etc.
  • Add loading indicators for better UX

Accessing State and Dispatch in Thunks

The thunkAPI provides access to store state, dispatch, and more:

export const conditionalFetch = createAsyncThunk(
  'users/conditionalFetch',
  async (_, { getState, dispatch, rejectWithValue }) => {
    const state = getState() as RootState;
    
    // Only fetch if not already loaded
    if (state.users.users.length > 0) {
      return state.users.users;
    }
    
    try {
      const response = await fetch('https://api.example.com/users');
      
      if (!response.ok) {
        throw new Error('Fetch failed');
      }
      
      const users = await response.json();
      
      // Dispatch another action if needed
      dispatch(someOtherAction());
      
      return users as User[];
    } catch (error) {
      return rejectWithValue('Failed to fetch');
    }
  }
);

Canceling Thunks

// In component
function UserList() {
  const dispatch = useAppDispatch();
  
  useEffect(() => {
    // Dispatch returns a promise with an abort method
    const promise = dispatch(fetchUsers());
    
    // Cancel on unmount
    return () => {
      promise.abort();
    };
  }, [dispatch]);
}

Sequential and Parallel Thunks

// Sequential: Wait for one before starting the next
async function loadUserData(userId: string) {
  const dispatch = useAppDispatch();
  
  try {
    // Wait for user to load
    const user = await dispatch(fetchUserById(userId)).unwrap();
    
    // Then load their posts
    await dispatch(fetchUserPosts(user.id)).unwrap();
    
    // Then load their comments
    await dispatch(fetchUserComments(user.id)).unwrap();
  } catch (error) {
    console.error('Failed to load user data:', error);
  }
}

// Parallel: Start all at once
async function loadDashboardData() {
  const dispatch = useAppDispatch();
  
  try {
    // Start all requests simultaneously
    await Promise.all([
      dispatch(fetchUsers()).unwrap(),
      dispatch(fetchPosts()).unwrap(),
      dispatch(fetchComments()).unwrap()
    ]);
  } catch (error) {
    console.error('Failed to load dashboard:', error);
  }
}

💡 The unwrap() Method

Calling .unwrap() on the returned promise gives you:

  • The fulfilled payload directly (no action wrapper)
  • A thrown error if the thunk was rejected
  • Ability to use try-catch for error handling
  • Better TypeScript inference for the result
try {
  const user = await dispatch(fetchUser(id)).unwrap();
  // user is typed as User, not PayloadAction<User>
  console.log(user.name);
} catch (error) {
  // Handle error
  console.error('Failed:', error);
}

🚀 RTK Query

RTK Query is Redux Toolkit's powerful data fetching and caching solution. It's like React Query, but integrated directly into Redux with automatic cache management, optimistic updates, and more.

Why RTK Query?

RTK Query solves common problems in data fetching:

📖 Definition

RTK Query is a data fetching and caching tool built into Redux Toolkit. It automatically manages loading states, caching, invalidation, and refetching, eliminating the need for hand-written async thunks for most data fetching scenarios.

Without RTK Query With RTK Query
Manually write thunks for each endpoint Define endpoints once, hooks generated automatically
Manage loading states yourself Loading states provided automatically
Handle caching manually Automatic caching and invalidation
Write refetch logic Automatic refetching and polling
Complex optimistic updates Built-in optimistic update helpers

Setting Up RTK Query

// src/services/api.ts

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

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

interface Post {
  id: string;
  userId: string;
  title: string;
  body: string;
}

// Define API slice
export const api = createApi({
  reducerPath: 'api', // The key in the Redux store
  baseQuery: fetchBaseQuery({ 
    baseUrl: 'https://jsonplaceholder.typicode.com' 
  }),
  tagTypes: ['User', 'Post'], // For cache invalidation
  endpoints: (builder) => ({
    // Query endpoints (GET requests)
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'] // This data is tagged as 'User'
    }),
    
    getUserById: builder.query<User, string>({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }]
    }),
    
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post']
    }),
    
    // Mutation endpoints (POST, PUT, DELETE)
    createUser: builder.mutation<User, Omit<User, 'id'>>({
      query: (newUser) => ({
        url: '/users',
        method: 'POST',
        body: newUser
      }),
      invalidatesTags: ['User'] // Invalidate User cache after creating
    }),
    
    updateUser: builder.mutation<User, { id: string; updates: Partial<User> }>({
      query: ({ id, updates }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: updates
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }]
    }),
    
    deleteUser: builder.mutation<void, string>({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE'
      }),
      invalidatesTags: ['User']
    })
  })
});

// Export hooks - automatically generated!
export const {
  useGetUsersQuery,
  useGetUserByIdQuery,
  useGetPostsQuery,
  useCreateUserMutation,
  useUpdateUserMutation,
  useDeleteUserMutation
} = api;

Adding API to Store

// src/store/index.ts

import { configureStore } from '@reduxjs/toolkit';
import { api } from '../services/api';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
  reducer: {
    // Add the API reducer
    [api.reducerPath]: api.reducer,
    counter: counterReducer,
    // ... other reducers
  },
  // Add the API middleware
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware)
});

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

⚠️ Don't Forget Middleware!

RTK Query requires its middleware to be added to the store. This middleware handles:

  • Caching and cache invalidation
  • Automatic refetching
  • Request deduplication
  • Background polling

Using Query Hooks

// src/components/UserList.tsx

import { useGetUsersQuery } from '../services/api';

function UserList() {
  // Automatically fetches data and provides loading/error states
  const { data: users, isLoading, isError, error } = useGetUsersQuery();
  
  if (isLoading) {
    return <div>Loading users...</div>;
  }
  
  if (isError) {
    return <div>Error: {error.toString()}</div>;
  }
  
  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  );
}

// Query with parameter
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useGetUserByIdQuery(userId);
  
  if (isLoading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

Using Mutation Hooks

// src/components/CreateUserForm.tsx

import { useState } from 'react';
import { useCreateUserMutation } from '../services/api';

function CreateUserForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  
  // Mutation hook returns [trigger function, result object]
  const [createUser, { isLoading, isSuccess, isError }] = useCreateUserMutation();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      // Call the mutation
      await createUser({ name, email }).unwrap();
      
      // Reset form on success
      setName('');
      setEmail('');
      alert('User created successfully!');
    } catch (error) {
      alert('Failed to create user');
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
        required
      />
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        type="email"
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Creating...' : 'Create User'}
      </button>
      
      {isSuccess && <p>User created!</p>}
      {isError && <p>Failed to create user</p>}
    </form>
  );
}

✅ RTK Query Features

Out of the box, RTK Query provides:

  • Automatic Caching: Requests are cached and shared
  • De-duplication: Multiple components requesting the same data = 1 request
  • Cache Invalidation: Automatic refetch when cache is invalidated
  • Loading States: isLoading, isFetching, isSuccess, isError
  • Polling: Automatic refetching at intervals
  • Optimistic Updates: Update UI before server responds
  • TypeScript: Full type safety for requests and responses

Query Options and Configuration

RTK Query hooks accept options to customize behavior:

function UserList() {
  const { data: users } = useGetUsersQuery(undefined, {
    pollingInterval: 5000, // Refetch every 5 seconds
    refetchOnMountOrArgChange: true, // Refetch on mount
    refetchOnFocus: true, // Refetch when window gains focus
    refetchOnReconnect: true, // Refetch on network reconnect
    skip: false // Skip the query (useful for conditional fetching)
  });
  
  return <div>{/* render users */}</div>;
}

// Conditional fetching
function UserProfile({ userId }: { userId: string | null }) {
  const { data: user } = useGetUserByIdQuery(userId!, {
    skip: !userId // Don't fetch if userId is null
  });
  
  return user ? <div>{user.name}</div> : null;
}

Manual Cache Management

Sometimes you need to manually refetch or invalidate cache:

import { useGetUsersQuery } from '../services/api';

function UserList() {
  const { data: users, refetch } = useGetUsersQuery();
  
  return (
    <div>
      <button onClick={() => refetch()}>
        Refresh Users
      </button>
      
      <ul>
        {users?.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

// Using useLazyQuery for on-demand fetching
import { useLazyGetUserByIdQuery } from '../services/api';

function SearchUser() {
  const [trigger, result] = useLazyGetUserByIdQuery();
  const [userId, setUserId] = useState('');
  
  const handleSearch = () => {
    if (userId) {
      trigger(userId); // Manually trigger the query
    }
  };
  
  return (
    <div>
      <input value={userId} onChange={(e) => setUserId(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
      
      {result.isLoading && <div>Loading...</div>}
      {result.data && <div>Found: {result.data.name}</div>}
    </div>
  );
}

Optimistic Updates

Update the UI immediately before the server responds:

// src/services/api.ts

export const api = createApi({
  // ... base config
  endpoints: (builder) => ({
    updateUser: builder.mutation<User, { id: string; updates: Partial<User> }>({
      query: ({ id, updates }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: updates
      }),
      // Optimistic update
      async onQueryStarted({ id, updates }, { dispatch, queryFulfilled }) {
        // Optimistically update the cache
        const patchResult = dispatch(
          api.util.updateQueryData('getUserById', id, (draft) => {
            Object.assign(draft, updates);
          })
        );
        
        try {
          // Wait for the mutation to complete
          await queryFulfilled;
        } catch {
          // Undo the optimistic update on error
          patchResult.undo();
        }
      }
    }),
    
    deleteUser: builder.mutation<void, string>({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE'
      }),
      // Optimistically remove from list
      async onQueryStarted(id, { dispatch, queryFulfilled }) {
        const patchResult = dispatch(
          api.util.updateQueryData('getUsers', undefined, (draft) => {
            const index = draft.findIndex(user => user.id === id);
            if (index !== -1) {
              draft.splice(index, 1);
            }
          })
        );
        
        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
        }
      }
    })
  })
});

Transform Responses

Transform API responses before they reach your components:

export const api = createApi({
  // ... config
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      // Transform response
      transformResponse: (response: any[]) => {
        // Add a computed field
        return response.map(user => ({
          ...user,
          fullName: `${user.firstName} ${user.lastName}`,
          isActive: user.status === 'active'
        }));
      },
      // Transform error response
      transformErrorResponse: (response: { status: number; data: any }) => {
        return {
          status: response.status,
          message: response.data?.message || 'An error occurred'
        };
      }
    })
  })
});

Advanced Cache Invalidation

export const api = createApi({
  // ... config
  tagTypes: ['User', 'Post', 'Comment'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      // Provide tags for each user
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'User' as const, id })),
              { type: 'User', id: 'LIST' }
            ]
          : [{ type: 'User', id: 'LIST' }]
    }),
    
    getUserPosts: builder.query<Post[], string>({
      query: (userId) => `/users/${userId}/posts`,
      providesTags: (result, error, userId) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post' as const, id })),
              { type: 'Post', id: userId }
            ]
          : [{ type: 'Post', id: userId }]
    }),
    
    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post
      }),
      // Invalidate specific user's posts and the list
      invalidatesTags: (result, error, { userId }) => [
        { type: 'Post', id: userId },
        { type: 'Post', id: 'LIST' }
      ]
    }),
    
    deleteUser: builder.mutation<void, string>({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE'
      }),
      // Invalidate both the specific user and the list
      invalidatesTags: (result, error, id) => [
        { type: 'User', id },
        { type: 'User', id: 'LIST' }
      ]
    })
  })
});

💡 RTK Query Pro Tips

  • Tag Strategy: Use specific IDs for granular invalidation, 'LIST' for collection endpoints
  • Polling: Great for real-time dashboards, but be mindful of server load
  • Optimistic Updates: Improve perceived performance but always handle rollback
  • Skip Option: Use for conditional fetching to avoid unnecessary requests
  • Transform Responses: Normalize or enhance data in one place

Code Splitting API Slices

For large applications, split your API into multiple files:

// src/services/baseApi.ts
export const baseApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com' }),
  tagTypes: ['User', 'Post', 'Comment'],
  endpoints: () => ({}) // Empty initially
});

// src/services/users.ts
import { baseApi } from './baseApi';

export const usersApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User']
    }),
    // ... other user endpoints
  })
});

export const { useGetUsersQuery } = usersApi;

// src/services/posts.ts
import { baseApi } from './baseApi';

export const postsApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post']
    }),
    // ... other post endpoints
  })
});

export const { useGetPostsQuery } = postsApi;

// In store, only add baseApi
export const store = configureStore({
  reducer: {
    [baseApi.reducerPath]: baseApi.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(baseApi.middleware)
});

⚠️ When NOT to Use RTK Query

RTK Query is powerful, but not always the right choice:

  • Simple, one-off API calls (use fetch or axios directly)
  • Non-REST APIs with complex protocols
  • Heavy GraphQL usage (consider Apollo Client)
  • Real-time with WebSockets (needs custom setup)
  • Projects already using React Query successfully

⭐ Best Practices

Let's consolidate everything into actionable best practices for Redux Toolkit.

Slice Organization

1. Feature-Based Structure

src/
  features/
    auth/
      authSlice.ts          // Slice definition
      authSelectors.ts      // Memoized selectors
      authThunks.ts         // Complex async thunks (optional)
      types.ts              // TypeScript types
      
    cart/
      cartSlice.ts
      cartSelectors.ts
      
    products/
      productsSlice.ts
      productsAPI.ts        // RTK Query API
      
  store/
    index.ts               // Store configuration
    hooks.ts               // Typed hooks
    middleware.ts          // Custom middleware (if needed)

2. Keep Slices Focused

// ✅ Good: Focused slice
interface CartState {
  items: CartItem[];
  total: number;
}

// ❌ Bad: Kitchen sink slice
interface AppState {
  users: User[];
  products: Product[];
  cart: CartItem[];
  auth: AuthData;
  ui: UIState;
  // Everything in one slice!
}

State Shape Design

1. Normalize Nested Data

// ❌ Bad: Nested arrays
interface State {
  users: {
    id: string;
    name: string;
    posts: {
      id: string;
      title: string;
      comments: Comment[];
    }[];
  }[];
}

// ✅ Good: Normalized structure
interface State {
  users: {
    byId: { [id: string]: User };
    allIds: string[];
  };
  posts: {
    byId: { [id: string]: Post };
    allIds: string[];
  };
  comments: {
    byId: { [id: string]: Comment };
    allIds: string[];
  };
}

2. Separate UI State from Domain State

// UI state - belongs in components or UI slice
interface UIState {
  isSidebarOpen: boolean;
  activeModal: string | null;
  theme: 'light' | 'dark';
}

// Domain state - belongs in feature slices
interface ProductsState {
  items: Product[];
  isLoading: boolean;
  error: string | null;
}

Performance Optimization

1. Use Memoized Selectors

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

// ✅ Good: Memoized with createSelector
export const selectFilteredTodos = createSelector(
  [(state: RootState) => state.todos.items, 
   (state: RootState) => state.todos.filter],
  (items, filter) => {
    // Expensive filtering only runs when items or filter change
    return items.filter(item => {
      if (filter === 'active') return !item.completed;
      if (filter === 'completed') return item.completed;
      return true;
    });
  }
);

// ❌ Bad: Filtering in component or non-memoized selector
function TodoList() {
  const items = useAppSelector(state => state.todos.items);
  const filter = useAppSelector(state => state.todos.filter);
  
  // Runs on every render!
  const filtered = items.filter(item => {
    if (filter === 'active') return !item.completed;
    if (filter === 'completed') return item.completed;
    return true;
  });
}

2. Select Minimal Data

// ✅ Good: Select only what you need
function UserName({ userId }: { userId: string }) {
  const name = useAppSelector(state => 
    state.users.byId[userId]?.name
  );
  
  return <div>{name}</div>;
}

// ❌ Bad: Select entire user
function UserName({ userId }: { userId: string }) {
  const user = useAppSelector(state => state.users.byId[userId]);
  
  // Re-renders when ANY user property changes
  return <div>{user?.name}</div>;
}

Async Logic Best Practices

1. Choose the Right Tool

Use Case Recommended Approach
REST API CRUD operations RTK Query
Complex multi-step async logic createAsyncThunk
Simple one-off API call useEffect with fetch
Real-time data (WebSocket) Custom middleware

2. Handle All Async States

// ✅ Good: Handle all states
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    data: null,
    isLoading: false,
    error: null
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.data = action.payload;
        state.isLoading = false;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.error.message || 'Failed';
      });
  }
});

TypeScript Best Practices

1. Define Interfaces First

// ✅ Good: Clear type definitions
interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodosState {
  items: Todo[];
  filter: 'all' | 'active' | 'completed';
  isLoading: boolean;
  error: string | null;
}

// Then use them
const initialState: TodosState = {
  items: [],
  filter: 'all',
  isLoading: false,
  error: null
};

2. Use Type Inference

// ✅ Good: Let TypeScript infer
const increment = (state: CounterState) => {
  state.value += 1; // Type of value is inferred
};

// ❌ Unnecessary: Over-annotation
const increment = (state: CounterState): void => {
  state.value = (state.value as number) + (1 as number);
};

Testing Redux

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

describe('counter reducer', () => {
  const initialState = { value: 0, step: 1 };
  
  it('should handle increment', () => {
    const actual = counterReducer(initialState, increment());
    expect(actual.value).toEqual(1);
  });
  
  it('should handle decrement', () => {
    const actual = counterReducer(initialState, decrement());
    expect(actual.value).toEqual(-1);
  });
});

// Testing selectors
import { selectFilteredTodos } from './todosSelectors';

describe('selectFilteredTodos', () => {
  const state = {
    todos: {
      items: [
        { id: '1', text: 'Test', completed: false },
        { id: '2', text: 'Done', completed: true }
      ],
      filter: 'active'
    }
  };
  
  it('returns active todos when filter is active', () => {
    const result = selectFilteredTodos(state as RootState);
    expect(result).toHaveLength(1);
    expect(result[0].id).toBe('1');
  });
});

// Testing components with Redux
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import Counter from './Counter';

function renderWithRedux(
  ui: React.ReactElement,
  { initialState, ...renderOptions } = {}
) {
  const store = configureStore({
    reducer: { counter: counterReducer },
    preloadedState: initialState
  });
  
  return render(
    <Provider store={store}>{ui}</Provider>,
    renderOptions
  );
}

test('displays count', () => {
  renderWithRedux(<Counter />, {
    initialState: { counter: { value: 5, step: 1 } }
  });
  
  expect(screen.getByText(/count: 5/i)).toBeInTheDocument();
});

✅ Redux Toolkit Golden Rules

  1. Use RTK, not classic Redux - Less boilerplate, better DX
  2. Feature-based organization - Group by feature, not by type
  3. Normalize complex data - Avoid deep nesting
  4. Use RTK Query for APIs - Built-in caching and loading states
  5. createSelector for derived data - Memoize expensive computations
  6. Type everything - Full TypeScript support
  7. Keep slices focused - Single responsibility per slice
  8. Test reducer logic - Pure functions are easy to test

📝 Summary

Congratulations! You've mastered Redux Toolkit, the modern, powerful way to manage state in React applications. Let's recap what you've learned:

Key Takeaways

🎯 What You've Learned

  • Redux Fundamentals: Understanding Redux principles and when to use it
  • Redux Toolkit Setup: Configuring store with TypeScript support
  • Creating Slices: Using createSlice with Immer for simpler state updates
  • Async Operations: Handling async logic with createAsyncThunk
  • RTK Query: Powerful data fetching with automatic caching
  • TypeScript Integration: Full type safety for actions, state, and selectors
  • Best Practices: Organizing code, optimizing performance, and testing

Redux Toolkit vs Alternatives

Feature Redux Toolkit Zustand Context API
Bundle Size ~8KB ~1KB 0 (built-in)
Boilerplate Low (with RTK) Minimal Low-Moderate
DevTools Excellent Good (via middleware) No
Time Travel Yes No No
Middleware Extensive ecosystem Limited No
Data Fetching RTK Query (excellent) Manual Manual
Learning Curve Moderate Easy Easy
Best For Large, complex apps Small-medium apps Simple state sharing

Quick Reference

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

interface CounterState {
  value: number;
}

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

export const { increment, decrement, setCount } = counterSlice.actions;
export default counterSlice.reducer;
Async Thunk
import { createAsyncThunk } from '@reduxjs/toolkit';

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

// In slice
extraReducers: (builder) => {
  builder
    .addCase(fetchUsers.pending, (state) => {
      state.isLoading = true;
    })
    .addCase(fetchUsers.fulfilled, (state, action) => {
      state.users = action.payload;
      state.isLoading = false;
    })
    .addCase(fetchUsers.rejected, (state, action) => {
      state.error = action.error.message;
      state.isLoading = false;
    });
}
RTK Query Setup
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User']
    }),
    createUser: builder.mutation<User, Partial<User>>({
      query: (user) => ({
        url: '/users',
        method: 'POST',
        body: user
      }),
      invalidatesTags: ['User']
    })
  })
});

export const { useGetUsersQuery, useCreateUserMutation } = api;

When to Choose Redux Toolkit

Redux Toolkit is the right choice when you need:

  • ✅ Complex state logic with many actions
  • ✅ Time-travel debugging capabilities
  • ✅ Predictable, traceable state updates
  • ✅ Extensive middleware ecosystem
  • ✅ Large team with standardized patterns
  • ✅ Powerful data fetching with RTK Query
  • ✅ Applications that will scale significantly

Consider alternatives when you have:

  • ❌ Simple state needs (use Context or Zustand)
  • ❌ Small prototypes or MVPs (use Zustand)
  • ❌ Mostly server state (use React Query)
  • ❌ Need minimal bundle size (use Zustand)

Next Steps

  1. Practice: Build a complete app with Redux Toolkit
  2. Explore: Try RTK Query for all data fetching
  3. Compare: Build the same feature with Redux and Zustand
  4. Learn: Explore Redux middleware ecosystem
  5. Master: Advanced patterns like entity adapters

💡 Final Thoughts

Redux Toolkit has transformed Redux from a verbose, boilerplate-heavy library into a developer-friendly, powerful state management solution. While it may be overkill for small apps, for medium-to-large applications with complex state needs, it's an excellent choice that will scale with your project.

Resources

🏋️ Practice Exercises

Exercise 1: Shopping Cart with Redux Toolkit

Objective: Build a complete shopping cart feature using Redux Toolkit.

Requirements:

  • Create a cart slice with add, remove, update quantity actions
  • Calculate totals with memoized selectors
  • Add discount codes functionality
  • Persist cart to localStorage using middleware
  • Full TypeScript typing
💡 Hint

Start with this structure:

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  discountCode: string | null;
  discountPercent: number;
}

Use createSelector to calculate totals:

export const selectCartTotal = createSelector(
  [(state: RootState) => state.cart.items,
   (state: RootState) => state.cart.discountPercent],
  (items, discount) => {
    const subtotal = items.reduce((sum, item) => 
      sum + item.price * item.quantity, 0
    );
    return subtotal * (1 - discount / 100);
  }
);

Exercise 2: Blog Platform with RTK Query

Objective: Create a blog platform using RTK Query for all data operations.

Requirements:

  • Set up RTK Query API with posts and comments endpoints
  • Implement CRUD operations for posts
  • Add nested comments functionality
  • Use optimistic updates for creating posts
  • Implement proper cache invalidation
  • Add polling for real-time updates
💡 Hint

API structure:

export const blogApi = createApi({
  reducerPath: 'blogApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'Comment'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post']
    }),
    getPostComments: builder.query<Comment[], string>({
      query: (postId) => `/posts/${postId}/comments`,
      providesTags: (result, error, postId) => 
        [{ type: 'Comment', id: postId }]
    }),
    // Add more endpoints...
  })
});

Exercise 3: Dashboard with Async Thunks

Objective: Build an admin dashboard with complex async operations.

Requirements:

  • Fetch users, products, and orders data
  • Handle sequential data loading (users → their orders → order details)
  • Implement error handling and retry logic
  • Add loading states for each data type
  • Create memoized selectors for dashboard stats
  • Handle authentication token refreshing

❓ Knowledge Check

Question 1: Immer in Redux Toolkit

Why can you write "mutating" code in Redux Toolkit reducers?

Show Answer

Answer: B

Immer uses JavaScript Proxy objects to track all changes you make to a draft state. Behind the scenes, it produces a completely new immutable state based on those changes. This allows you to write simpler code that looks like mutations while maintaining immutability.

Question 2: When to Use RTK Query

Which scenario is RTK Query BEST suited for?

Show Answer

Answer: B

RTK Query excels at REST APIs with CRUD operations. It provides automatic caching, cache invalidation, loading states, and request deduplication. For simple one-off calls, plain fetch is sufficient. For WebSockets, you'd need custom middleware or other solutions.

Question 3: createAsyncThunk States

What three action types does createAsyncThunk automatically generate?

Show Answer

Answer: B

createAsyncThunk generates three action types following Promise terminology: pending (when the async function starts), fulfilled (when it completes successfully), and rejected (when it throws an error or returns a rejected promise). You handle these in extraReducers.