Skip to main content
Technology & EngineeringState Management377 lines

Redux Toolkit

Redux Toolkit for scalable React state — createSlice, configureStore, RTK Query, createAsyncThunk, entity adapter, middleware, and TypeScript patterns

Quick Summary24 lines
Redux Toolkit is the official, opinionated toolset for Redux. It eliminates the boilerplate that made Redux tedious — no more hand-written action types, action creators, or immutability spread patterns. RTK bundles Immer for immutable updates, configures the store with sensible defaults, and provides RTK Query for data fetching. When your application needs predictable, centralized state with time-travel debugging, Redux Toolkit delivers that with minimal ceremony.

## Key Points

- **Official and opinionated** — the recommended way to write Redux logic
- **Immer built in** — write "mutative" code in reducers; Immer produces immutable updates
- **Single store, many slices** — each domain gets its own slice of state with colocated reducers and actions
- **RTK Query** — a powerful data-fetching and caching layer built on top of Redux
- **Middleware-friendly** — saga, thunk, and custom middleware integrate cleanly
- **Excellent DevTools** — time-travel debugging, action logging, and state diffing out of the box
1. **Use `createSlice` for all reducers** — never hand-write action types or switch-case reducers.
2. **Use typed hooks** — create `useAppDispatch` and `useAppSelector` once and import everywhere.
3. **Prefer RTK Query over createAsyncThunk** for data fetching — it handles caching, loading states, and invalidation automatically.
4. **Keep slices feature-scoped** — one slice per feature (auth, todos, settings), not one per data shape.
5. **Use entity adapter for normalized collections** — it provides efficient CRUD operations and memoized selectors.
6. **Memoize selectors with `createSelector`** — derive computed state in selectors rather than storing it.

## Quick Example

```bash
npm install @reduxjs/toolkit react-redux
```
skilldb get state-management-skills/Redux ToolkitFull skill: 377 lines
Paste into your CLAUDE.md or agent config

Redux Toolkit (RTK)

Core Philosophy

Redux Toolkit is the official, opinionated toolset for Redux. It eliminates the boilerplate that made Redux tedious — no more hand-written action types, action creators, or immutability spread patterns. RTK bundles Immer for immutable updates, configures the store with sensible defaults, and provides RTK Query for data fetching. When your application needs predictable, centralized state with time-travel debugging, Redux Toolkit delivers that with minimal ceremony.

  • Official and opinionated — the recommended way to write Redux logic
  • Immer built in — write "mutative" code in reducers; Immer produces immutable updates
  • Single store, many slices — each domain gets its own slice of state with colocated reducers and actions
  • RTK Query — a powerful data-fetching and caching layer built on top of Redux
  • Middleware-friendly — saga, thunk, and custom middleware integrate cleanly
  • Excellent DevTools — time-travel debugging, action logging, and state diffing out of the box

Setup

npm install @reduxjs/toolkit react-redux
// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { todosSlice } from './slices/todosSlice';
import { authSlice } from './slices/authSlice';
import { apiSlice } from './api/apiSlice';

export const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
    auth: authSlice.reducer,
    [apiSlice.reducerPath]: apiSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// store/hooks.ts — typed hooks
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
// main.tsx
import { Provider } from 'react-redux';
import { store } from './store/store';

function App() {
  return (
    <Provider store={store}>
      <TodoApp />
    </Provider>
  );
}

Key Techniques

createSlice

// store/slices/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

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

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

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

export const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<{ id: string; title: string }>) => {
      state.items.push({
        id: action.payload.id,
        title: action.payload.title,
        completed: false,
      });
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
    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;
    },
  },
});

export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions;
// components/TodoList.tsx
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { toggleTodo, removeTodo } from '../store/slices/todosSlice';

export function TodoList() {
  const todos = useAppSelector((s) => s.todos.items);
  const filter = useAppSelector((s) => s.todos.filter);
  const dispatch = useAppDispatch();

  const filtered = todos.filter((t) => {
    if (filter === 'active') return !t.completed;
    if (filter === 'completed') return t.completed;
    return true;
  });

  return (
    <ul>
      {filtered.map((todo) => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => dispatch(toggleTodo(todo.id))}
          />
          <span>{todo.title}</span>
          <button onClick={() => dispatch(removeTodo(todo.id))}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

createAsyncThunk

// store/slices/authSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

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

interface AuthState {
  user: User | null;
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  error: string | null;
}

export const login = createAsyncThunk<
  User,
  { email: string; password: string },
  { rejectValue: string }
>('auth/login', async (credentials, { rejectWithValue }) => {
  try {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
      headers: { 'Content-Type': 'application/json' },
    });
    if (!res.ok) {
      const err = await res.json();
      return rejectWithValue(err.message);
    }
    return (await res.json()) as User;
  } catch {
    return rejectWithValue('Network error');
  }
});

export const authSlice = createSlice({
  name: 'auth',
  initialState: { user: null, status: 'idle', error: null } as AuthState,
  reducers: {
    logout: (state) => {
      state.user = null;
      state.status = 'idle';
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(login.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(login.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.user = action.payload;
      })
      .addCase(login.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload ?? 'Unknown error';
      });
  },
});

export const { logout } = authSlice.actions;

Entity Adapter

import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../store';

interface Comment {
  id: string;
  postId: string;
  author: string;
  body: string;
  createdAt: string;
}

const commentsAdapter = createEntityAdapter<Comment>({
  sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt),
});

export const commentsSlice = createSlice({
  name: 'comments',
  initialState: commentsAdapter.getInitialState(),
  reducers: {
    addComment: commentsAdapter.addOne,
    updateComment: commentsAdapter.updateOne,
    removeComment: commentsAdapter.removeOne,
    setComments: commentsAdapter.setAll,
    upsertComments: commentsAdapter.upsertMany,
  },
});

// Generates memoized selectors: selectAll, selectById, selectIds, etc.
export const {
  selectAll: selectAllComments,
  selectById: selectCommentById,
  selectIds: selectCommentIds,
} = commentsAdapter.getSelectors((state: RootState) => state.comments);

export const { addComment, removeComment, setComments } = commentsSlice.actions;

RTK Query

// store/api/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

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

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'User'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post' as const, id })),
              { type: 'Post', id: 'LIST' },
            ]
          : [{ type: 'Post', id: 'LIST' }],
    }),
    getPost: builder.query<Post, string>({
      query: (id) => `/posts/${id}`,
      providesTags: (_result, _error, id) => [{ type: 'Post', id }],
    }),
    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (body) => ({ url: '/posts', method: 'POST', body }),
      invalidatesTags: [{ type: 'Post', id: 'LIST' }],
    }),
    updatePost: builder.mutation<Post, Pick<Post, 'id'> & Partial<Post>>({
      query: ({ id, ...body }) => ({ url: `/posts/${id}`, method: 'PATCH', body }),
      invalidatesTags: (_result, _error, { id }) => [{ type: 'Post', id }],
    }),
    deletePost: builder.mutation<void, string>({
      query: (id) => ({ url: `/posts/${id}`, method: 'DELETE' }),
      invalidatesTags: (_result, _error, id) => [{ type: 'Post', id }],
    }),
  }),
});

export const {
  useGetPostsQuery,
  useGetPostQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = apiSlice;
// components/PostList.tsx
import { useGetPostsQuery, useDeletePostMutation } from '../store/api/apiSlice';

export function PostList() {
  const { data: posts, isLoading, isError } = useGetPostsQuery();
  const [deletePost] = useDeletePostMutation();

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading posts</p>;

  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <button onClick={() => deletePost(post.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Custom Middleware

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

export const loggerMiddleware: Middleware<{}, RootState> =
  (storeApi) => (next) => (action) => {
    console.group(typeof action === 'object' && action && 'type' in action ? (action as any).type : 'unknown');
    console.log('prev state:', storeApi.getState());
    const result = next(action);
    console.log('next state:', storeApi.getState());
    console.groupEnd();
    return result;
  };

Best Practices

  1. Use createSlice for all reducers — never hand-write action types or switch-case reducers.
  2. Use typed hooks — create useAppDispatch and useAppSelector once and import everywhere.
  3. Prefer RTK Query over createAsyncThunk for data fetching — it handles caching, loading states, and invalidation automatically.
  4. Keep slices feature-scoped — one slice per feature (auth, todos, settings), not one per data shape.
  5. Use entity adapter for normalized collections — it provides efficient CRUD operations and memoized selectors.
  6. Memoize selectors with createSelector — derive computed state in selectors rather than storing it.
  7. Type the rejectValue in thunks — ensures error payloads are typed in the rejected case.

Anti-Patterns

  1. Writing Redux without RTK — hand-rolling action types and spread-based reducers is needlessly verbose and error-prone.
  2. Putting everything in Redux — form input values, modal open/close, and hover state belong in local component state.
  3. Dispatching in useEffect without cleanup — async thunks dispatched on mount should abort on unmount to avoid state updates on unmounted components.
  4. Mutating state outside Immer context — Immer only works inside createSlice reducers; mutating in selectors or components corrupts the store.
  5. Ignoring RTK Query tags — without proper providesTags and invalidatesTags, cache invalidation silently breaks.
  6. Storing API responses without normalization — duplicate entities across queries lead to inconsistent UI; use entity adapter or RTK Query tags.

Install this skill directly: skilldb add state-management-skills

Get CLI access →