Asher Cohen
Back to posts

Redux Toolkit: Modern Redux Development

Complete guide to Redux Toolkit — createSlice, createAsyncThunk, RTK Query, and migration from traditional Redux

Introduction

Redux Toolkit (RTK) is the official, opinionated toolset for efficient Redux development. It simplifies store setup, reduces boilerplate, and provides powerful utilities like createSlice and RTK Query. If you're still writing switch-case reducers and action type constants, it's time to modernize.

Why Redux Toolkit?

Traditional Redux vs Redux Toolkit

// ❌ Traditional Redux - verbose boilerplate
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';

function addTodo(text) {
  return { type: ADD_TODO, payload: { text } };
}

function toggleTodo(index) {
  return { type: TOGGLE_TODO, payload: { index } };
}

function todosReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, { text: action.payload.text, done: false }];
    case TOGGLE_TODO:
      return state.map((todo, i) =>
        i === action.payload.index ? { ...todo, done: !todo.done } : todo
      );
    default:
      return state;
  }
}

// ✅ Redux Toolkit - clean and concise
import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      state.push({ text: action.payload, done: false });
    },
    toggleTodo(state, action) {
      const todo = state[action.payload];
      if (todo) todo.done = !todo.done;
    }
  }
});

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

Core Concepts

createSlice

The heart of Redux Toolkit. It automatically generates action creators and action types:

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

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment(state) {
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    incrementByAmount(state, action) {
      state.value += action.payload;
    },
    reset() {
      return { value: 0 }; // Direct return also works
    }
  }
});

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

// Generated reducer
export default counterSlice.reducer;

// Action types are auto-generated
console.log(increment.type); // "counter/increment"

Key Points:

  • Mutate state directly thanks to Immer integration
  • Action types are "sliceName/reducerName"
  • Action creators accept a single argument as payload
  • Return a new state object to replace entirely

configureStore

Simplifies store setup with good defaults:

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

const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer
  },
  // Middleware and DevTools are configured automatically
});

// TypeScript: infer types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Automatic features:

  • ✅ Redux DevTools Extension enabled
  • redux-thunk middleware included
  • ✅ Development checks for mutations and serializability
  • ✅ Combine reducers automatically

createAsyncThunk

Handles async logic without writing thunks manually:

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

// Create async thunk
export const fetchUserById = createAsyncThunk(
  'users/fetchById',
  async (userId, thunkAPI) => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error('Failed to fetch user');
    }
    return response.json();
  }
);

// Handle in slice
const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: {}, loading: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserById.pending, (state) => {
        state.loading = 'pending';
      })
      .addCase(fetchUserById.fulfilled, (state, action) => {
        state.loading = 'succeeded';
        state.entities[action.payload.id] = action.payload;
      })
      .addCase(fetchUserById.rejected, (state, action) => {
        state.loading = 'failed';
        state.error = action.error.message;
      });
  }
});

Lifecycle Actions:

  • pending — Request started
  • fulfilled — Request succeeded
  • rejected — Request failed

RTK Query

Built-in data fetching and caching solution:

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

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    // Query: fetch data
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['User']
    }),
    
    getUserById: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }]
    }),
    
    // Mutation: modify data
    createUser: builder.mutation({
      query: (user) => ({
        url: '/users',
        method: 'POST',
        body: user
      }),
      invalidatesTags: ['User']
    }),
    
    updateUser: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: patch
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }]
    })
  })
});

// Auto-generated hooks
export const {
  useGetUsersQuery,
  useGetUserByIdQuery,
  useCreateUserMutation,
  useUpdateUserMutation
} = api;

RTK Query Benefits:

  • ✅ Automatic caching and cache invalidation
  • ✅ Loading/error states built-in
  • ✅ Automatic re-fetching on focus/reconnect
  • ✅ Optimistic updates support
  • ✅ Code generation for hooks

Migration Guide

Step 1: Replace switch-case with createSlice

// Before: Traditional reducer
const initialState = { items: [], filter: 'all' };

function todoReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_TODO':
      return { ...state, items: state.items.filter(t => t.id !== action.payload) };
    case 'SET_FILTER':
      return { ...state, filter: action.payload };
    default:
      return state;
  }
}

// After: createSlice
const todoSlice = createSlice({
  name: 'todos',
  initialState: { items: [], filter: 'all' },
  reducers: {
    addTodo(state, action) {
      state.items.push(action.payload);
    },
    removeTodo(state, action) {
      state.items = state.items.filter(t => t.id !== action.payload);
    },
    setFilter(state, action) {
      state.filter = action.payload;
    }
  }
});

Step 2: Replace action type enums with PayloadAction

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

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

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

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

const todoSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo(state, action: PayloadAction<Todo>) {
      state.items.push(action.payload);
    },
    removeTodo(state, action: PayloadAction<string>) {
      state.items = state.items.filter(t => t.id !== action.payload);
    },
    setFilter(state, action: PayloadAction<TodosState['filter']>) {
      state.filter = action.payload;
    }
  }
});

Step 3: Update tests

// Before: Testing traditional reducer
it('should add a todo', () => {
  const action = { type: 'ADD_TODO', payload: { id: '1', text: 'Test' } };
  const newState = todoReducer([], action);
  expect(newState).toHaveLength(1);
});

// After: Testing slice reducer
import todoReducer, { addTodo } from './todoSlice';

it('should add a todo', () => {
  const newState = todoReducer([], addTodo({ id: '1', text: 'Test' }));
  expect(newState.items).toHaveLength(1);
});

TypeScript Patterns

Typed Hooks

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

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Matchers in extraReducers

const slice = createSlice({
  name: 'data',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      // Match a single action
      .addCase(someAction, (state, action) => {})
      
      // Match multiple actions
      .addMatcher(
        (action) => action.type.endsWith('/pending'),
        (state) => { state.loading = true; }
      )
      .addMatcher(
        (action) => action.type.endsWith('/rejected'),
        (state, action) => {
          state.loading = false;
          state.error = action.error.message;
        }
      )
      
      // Default case
      .addDefaultCase((state) => {});
  }
});

Best Practices

Do's

// ✅ Mutate state in reducers (Immer handles immutability)
state.items.push(newItem);
state.user.name = 'New Name';
delete state.tempData;

// ✅ Use prepare callback for complex action creation
reducers: {
  addTodo: {
    reducer(state, action) {
      state.push(action.payload);
    },
    prepare(text) {
      return {
        payload: {
          id: nanoid(),
          text,
          createdAt: new Date().toISOString()
        }
      };
    }
  }
}

// ✅ Use RTK Query for API calls
const { data, isLoading, error } = useGetUsersQuery();

// ✅ Normalize state for collections
{
  entities: {
    '1': { id: '1', name: 'Alice' },
    '2': { id: '2', name: 'Bob' }
  },
  ids: ['1', '2']
}

Don'ts

// ❌ Don't spread state in createSlice reducers
state = { ...state, value: newValue }; // Wrong!

// ❌ Don't use switch-case with createSlice
// createSlice handles this for you

// ❌ Don't call APIs in reducers
// Use createAsyncThunk or RTK Query instead

// ❌ Don't put non-serializable values in state
// No functions, promises, symbols, class instances

// ❌ Don't mutate state outside of createSlice
// Always dispatch actions

Common Patterns

Optimistic Updates with RTK Query

updateTodo: builder.mutation({
  query: (todo) => ({
    url: `/todos/${todo.id}`,
    method: 'PATCH',
    body: todo
  }),
  
  // Optimistic update
  async onQueryStarted(todo, { dispatch, queryFulfilled }) {
    const patchResult = dispatch(
      api.util.updateQueryData('getTodos', undefined, (draft) => {
        const existing = draft.find(t => t.id === todo.id);
        if (existing) Object.assign(existing, todo);
      })
    );
    
    try {
      await queryFulfilled;
    } catch {
      patchResult.undo(); // Rollback on failure
    }
  }
})

Selectors with createSelector

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

const selectTodos = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;

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

Summary Table

FeatureTraditional ReduxRedux Toolkit
Reducerswitch-casecreateSlice
ActionsManual creatorsAuto-generated
StorecreateStoreconfigureStore
Asyncredux-thunkcreateAsyncThunk
Data fetchingManualRTK Query
ImmutabilitySpread operatorsImmer (automatic)
DevToolsManual setupAutomatic
TypeScriptComplex typesBuilt-in support

Conclusion

Redux Toolkit is the modern way to write Redux:

  • createSlice eliminates reducer boilerplate
  • configureStore provides sensible defaults
  • createAsyncThunk simplifies async logic
  • RTK Query handles data fetching and caching
  • Immer integration lets you write mutable-style updates

If you're starting a new Redux project or maintaining an existing one, Redux Toolkit is the recommended approach. The migration is straightforward and the benefits are immediate.

#redux #redux-toolkit #react #state-management #typescript #web-development