← All posts
·15 min read

Claude Code with Redux Toolkit: Slices, RTK Query, and Selectors

Claude CodeReduxReactState Management
Claude Code with Redux Toolkit: Slices, RTK Query, and Selectors

Why Claude Code defaults to legacy Redux without context

Redux Toolkit is the recommended way to write Redux code. It ships createSlice, createAsyncThunk, createApi (RTK Query), createEntityAdapter, and createSelector from reselect. The RTK 2.0 release tightened TypeScript support, removed deprecated patterns, and made entity adapters fully generic.

Claude Code knows Redux Toolkit exists. The problem is what version and pattern set it defaults to. Without project context, Claude reaches for patterns that were common in the Redux ecosystem before RTK became dominant: hand-written action creators with createAction, plain reducer functions with a switch statement, mapStateToProps and connect() for component wiring, and manual thunks that return dispatch => dispatch(...). That code compiles. It runs. It is also 2019 Redux, not 2026 RTK.

The specific failure modes:

  • createAction("users/setLoading") and a hand-rolled switch (action.type) reducer instead of createSlice
  • A custom thunk for an API call instead of an RTK Query endpoint
  • Selectors written as plain functions that return new object references on every call
  • connect(mapStateToProps, mapDispatchToProps) wrappers instead of useSelector and useDispatch
  • No TypeScript inference from RootState and AppDispatch types

Each of these works today. Each one creates maintenance burden you will pay for across the lifetime of the codebase. A CLAUDE.md that encodes RTK 2+ conventions fixes all of them before Claude writes the first line.

If you have not set up Claude Code yet, the Claude Code setup guide covers installation and initial configuration. For a comparison with lighter state management alternatives, Claude Code with Zustand covers the store slicing and selector discipline that applies equally to both libraries.

The Redux Toolkit CLAUDE.md template

The CLAUDE.md at your project root is read before every Claude Code session. For a Redux Toolkit project it has to declare: RTK version, where the store and slice files live, which patterns are in use, the boundary between client state and server state, and the hard rules that prevent legacy code from appearing.

# Redux Toolkit project rules

## Stack
- Redux Toolkit 2.x (RTK 2)
- React-Redux 9.x
- TypeScript strict mode enabled
- reselect 5.x (bundled via RTK, imported from @reduxjs/toolkit)
- Store entry point: src/store/index.ts
- Slices: src/store/slices/{sliceName}Slice.ts
- RTK Query APIs: src/store/api/{resource}Api.ts

## State boundary rules
- Client-only state (UI toggles, filter state, modal open, selected IDs,
  form wizard step, theme/locale preference): use createSlice
- Server state (any data that originates from an API): use RTK Query createApi
- Do NOT use createAsyncThunk for API calls if RTK Query covers the endpoint
- createAsyncThunk is reserved for: complex multi-step workflows, file uploads
  with progress tracking, and operations that involve multiple API calls in sequence

## Core APIs: what to use
- createSlice: all client-side state. Never write plain reducers or switch statements.
- createApi (RTK Query): all server data fetching and mutations
- createEntityAdapter: all collection state that is looked up by ID
- createSelector (reselect): all derived/computed state. Never compute in component.
- createAsyncThunk: non-API async logic only (see boundary rules above)

## TypeScript conventions
- Always export RootState = ReturnType<typeof store.getState>
- Always export AppDispatch = typeof store.dispatch
- Always use TypedUseSelectorHook: const useAppSelector = useSelector<RootState>
- Always use typed dispatch: const useAppDispatch = () => useDispatch<AppDispatch>()
- Slice state type is always explicitly declared as the generic on createSlice

## Component wiring rules
- useSelector / useDispatch only. No connect(), no mapStateToProps, no HOCs.
- Import useAppSelector and useAppDispatch from src/store/hooks.ts
- Never import dispatch directly from the store in a component

## Hard rules
- No switch statements in reducer functions (createSlice handles this internally)
- No hand-written action creators (createSlice exports them automatically)
- No plain selector functions that return new objects/arrays (use createSelector)
- No direct state mutation (Immer handles this inside createSlice reducers)
- No eslint-disable on any redux-specific lint rules

Two rules carry most of the weight.

The state boundary rule matters because Claude will reach for createAsyncThunk as its default for any async work, including API calls that RTK Query handles better. RTK Query eliminates loading/error/data state management, provides automatic caching, and generates typed hooks. Keeping createAsyncThunk reserved for genuinely complex workflows prevents the codebase from splitting API logic across two systems.

The hard rules block prevents the four most common legacy patterns from appearing. Once these are in CLAUDE.md, Claude generates createSlice reducers every time, exports auto-generated action creators, and always wraps derived state in createSelector.

createSlice patterns: client state done correctly

createSlice is the RTK primitive for client state. It generates action creators, action types, and a reducer from a single config object. Claude generates it correctly when the project conventions are explicit.

A filters slice that Claude should produce for a product listing page:

// src/store/slices/filtersSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface FiltersState {
  category: string | null;
  priceRange: [number, number];
  sortBy: "relevance" | "price-asc" | "price-desc" | "newest";
  inStockOnly: boolean;
  searchQuery: string;
}

const initialState: FiltersState = {
  category: null,
  priceRange: [0, 1000],
  sortBy: "relevance",
  inStockOnly: false,
  searchQuery: "",
};

export const filtersSlice = createSlice({
  name: "filters",
  initialState,
  reducers: {
    setCategory(state, action: PayloadAction<string | null>) {
      state.category = action.payload;
    },
    setPriceRange(state, action: PayloadAction<[number, number]>) {
      state.priceRange = action.payload;
    },
    setSortBy(state, action: PayloadAction<FiltersState["sortBy"]>) {
      state.sortBy = action.payload;
    },
    toggleInStock(state) {
      state.inStockOnly = !state.inStockOnly;
    },
    setSearchQuery(state, action: PayloadAction<string>) {
      state.searchQuery = action.payload;
    },
    resetFilters() {
      return initialState;
    },
  },
});

export const {
  setCategory,
  setPriceRange,
  setSortBy,
  toggleInStock,
  setSearchQuery,
  resetFilters,
} = filtersSlice.actions;

export default filtersSlice.reducer;

Three things Claude gets right when given the CLAUDE.md template: the explicit FiltersState interface, Immer-style direct mutation inside the reducer (state.category = action.payload instead of returning a new object), and the named action creator exports from filtersSlice.actions. Without the template, Claude writes return { ...state, category: action.payload } spread returns and sometimes adds createAction calls on top.

The resetFilters reducer returning initialState instead of mutating is intentional. Immer handles mutations. For a full reset, returning a new value from the reducer is the correct pattern. Claude will sometimes generate Object.assign(state, initialState) for resets, which works but is less readable.

RTK Query: eliminating API boilerplate

RTK Query is the largest productivity gain in the RTK stack. For any data that comes from a server, createApi generates the full loading/error/data lifecycle, typed hooks, caching, and cache invalidation from a single endpoint definition. Claude defaults to createAsyncThunk for API calls without guidance, which requires you to manually manage pending/fulfilled/rejected action handlers, write the loading state slice, and write the selectors. RTK Query removes all of that.

A productsApi definition covering read and write operations:

// src/store/api/productsApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { Product, CreateProductInput, UpdateProductInput } from "@/types/product";

export const productsApi = createApi({
  reducerPath: "productsApi",
  baseQuery: fetchBaseQuery({
    baseUrl: "/api",
    prepareHeaders: (headers, { getState }) => {
      // inject auth token from auth slice if present
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set("Authorization", `Bearer ${token}`);
      }
      return headers;
    },
  }),
  tagTypes: ["Product"],
  endpoints: (builder) => ({
    getProducts: builder.query<Product[], { category?: string }>({
      query: ({ category } = {}) =>
        category ? `/products?category=${category}` : "/products",
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: "Product" as const, id })),
              { type: "Product", id: "LIST" },
            ]
          : [{ type: "Product", id: "LIST" }],
    }),
    getProductById: builder.query<Product, string>({
      query: (id) => `/products/${id}`,
      providesTags: (_result, _err, id) => [{ type: "Product", id }],
    }),
    createProduct: builder.mutation<Product, CreateProductInput>({
      query: (body) => ({ url: "/products", method: "POST", body }),
      invalidatesTags: [{ type: "Product", id: "LIST" }],
    }),
    updateProduct: builder.mutation<Product, UpdateProductInput & { id: string }>({
      query: ({ id, ...body }) => ({ url: `/products/${id}`, method: "PATCH", body }),
      invalidatesTags: (_result, _err, { id }) => [{ type: "Product", id }],
    }),
    deleteProduct: builder.mutation<void, string>({
      query: (id) => ({ url: `/products/${id}`, method: "DELETE" }),
      invalidatesTags: (_result, _err, id) => [
        { type: "Product", id },
        { type: "Product", id: "LIST" },
      ],
    }),
  }),
});

export const {
  useGetProductsQuery,
  useGetProductByIdQuery,
  useCreateProductMutation,
  useUpdateProductMutation,
  useDeleteProductMutation,
} = productsApi;

The tag-based invalidation pattern is the most important part. Providing { type: "Product", id } per entity and { type: "Product", id: "LIST" } for the collection means mutations invalidate only the correct cache entries. A deleteProduct mutation invalidates both the specific entity and the list. A createProduct mutation only invalidates the list (the entity does not exist yet). Claude generates this correctly when tagTypes and the list/entity pattern are shown in CLAUDE.md.

Add to your CLAUDE.md:

## RTK Query conventions

### Tag pattern (use for every createApi)
- tagTypes declared at the API level
- List tag: { type: "Resource", id: "LIST" }
- Entity tag: { type: "Resource", id: entity.id }
- Queries: providesTags returns both entity tags + LIST tag
- Create mutations: invalidate LIST only (entity does not exist yet)
- Update/delete mutations: invalidate specific entity tag + LIST tag

### Auto-generated hooks
- Always export named hooks from the api file
- Use useGetXQuery for reads, useXMutation for writes
- Never manually dispatch RTK Query actions from components

### Error handling
- Use isError and error from the query/mutation result object
- RTK Query errors are typed as FetchBaseQueryError | SerializedError
- Handle both cases: "status" in error (HTTP error) vs "message" in error (JS error)

For projects that pair RTK Query with Tanstack Query for certain data domains, the Claude Code Tanstack Query guide covers the query key factory and invalidation patterns that apply when both libraries are in use.

createEntityAdapter: normalised state for collections

createEntityAdapter solves normalised state for collections you look up by ID. Without it, Claude either generates arrays (O(n) lookups, duplication risk) or a manually typed { ids: string[], entities: Record<string, T> } shape. The adapter generates that shape, provides CRUD operations, and keeps the ID array sorted.

// src/store/slices/usersSlice.ts
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
import type { EntityState } from "@reduxjs/toolkit";
import type { User } from "@/types/user";
import type { RootState } from "@/store";

const usersAdapter = createEntityAdapter<User>({
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

interface UsersExtraState {
  status: "idle" | "loading" | "succeeded" | "failed";
  error: string | null;
  selectedUserId: string | null;
}

type UsersState = EntityState<User, string> & UsersExtraState;

const initialExtraState: UsersExtraState = {
  status: "idle",
  error: null,
  selectedUserId: null,
};

export const usersSlice = createSlice({
  name: "users",
  initialState: usersAdapter.getInitialState(initialExtraState),
  reducers: {
    selectUser(state, action) {
      state.selectedUserId = action.payload;
    },
    clearSelection(state) {
      state.selectedUserId = null;
    },
    // adapter methods exposed as reducers
    userAdded: usersAdapter.addOne,
    usersReceived: usersAdapter.setAll,
    userUpdated: usersAdapter.updateOne,
    userRemoved: usersAdapter.removeOne,
  },
});

// Adapter-generated selectors bound to this slice's state location
export const {
  selectAll: selectAllUsers,
  selectById: selectUserById,
  selectIds: selectUserIds,
  selectTotal: selectTotalUsers,
} = usersAdapter.getSelectors((state: RootState) => state.users);

export const { selectUser, clearSelection, userAdded, usersReceived, userUpdated, userRemoved } =
  usersSlice.actions;

export default usersSlice.reducer;

The pattern Claude gets wrong without this example: it calls usersAdapter.getSelectors() without binding to the root state, which produces selectors that accept the slice state rather than the root state. Components then cannot use them with useAppSelector. Showing the bound selector export pattern in CLAUDE.md prevents this.

Add to your CLAUDE.md:

## createEntityAdapter conventions
- All collection state looked up by ID uses createEntityAdapter
- sortComparer is always set (prevents non-deterministic ordering)
- Adapter CRUD methods (addOne, setAll, updateOne, removeOne) are exposed
  directly as slice reducers
- Adapter selectors are always bound to root state via:
  usersAdapter.getSelectors((state: RootState) => state.{sliceName})
- Never use the unbound selector form (getSelectors() without argument)

Selectors and createSelector: memoised derived state

Plain selector functions that return computed values are one of the most common Redux performance problems. state => state.users.entities[id] is fine. state => state.users.ids.filter(id => state.users.entities[id]?.active) is not, because it returns a new array reference on every call, causing every component that subscribes to it to re-render on every store update.

createSelector from reselect (re-exported by RTK) memoises the result. It only recomputes when its input selectors return new values.

// src/store/selectors/userSelectors.ts
import { createSelector } from "@reduxjs/toolkit";
import { selectAllUsers } from "@/store/slices/usersSlice";
import type { RootState } from "@/store";

// Input selectors: primitives or stable references
const selectSearchQuery = (state: RootState) => state.filters.searchQuery;
const selectRoleFilter = (state: RootState) => state.filters.role;

// Memoised derived selector
export const selectFilteredUsers = createSelector(
  [selectAllUsers, selectSearchQuery, selectRoleFilter],
  (users, query, role) => {
    let filtered = users;

    if (query) {
      const lowerQuery = query.toLowerCase();
      filtered = filtered.filter(
        (u) =>
          u.name.toLowerCase().includes(lowerQuery) ||
          u.email.toLowerCase().includes(lowerQuery)
      );
    }

    if (role) {
      filtered = filtered.filter((u) => u.role === role);
    }

    return filtered;
  }
);

// Parametric selector with factory pattern (RTK 2+ preferred)
export const makeSelectUserWithPosts = () =>
  createSelector(
    [(state: RootState) => state.users, (_state: RootState, userId: string) => userId],
    (usersState, userId) => {
      const user = usersState.entities[userId];
      return user ?? null;
    }
  );

The factory pattern for parametric selectors (makeSelectUserWithPosts) is the RTK 2+ recommended approach. Each component instance calls the factory to create its own memoised selector, which prevents cache collisions when multiple components select different user IDs. Claude generates the factory pattern correctly when it is shown in CLAUDE.md. Without context, Claude either writes a plain function or creates a single shared selector that loses memoisation when called with different arguments from different components.

Add to CLAUDE.md:

## Selector conventions
- All derived state uses createSelector from @reduxjs/toolkit (not plain functions)
- Parametric selectors use the factory pattern: makeSelectX = () => createSelector(...)
- Each component instance calls the factory in useMemo or at module level
- Never compute filtered arrays, sums, or joined data inline in useSelector
- Input selectors return primitives or references already in the store (not new objects)

createAsyncThunk: the right use cases

createAsyncThunk is not obsolete in an RTK Query project. It handles workflows that do not map cleanly to a single API call: multi-step flows that read one resource, conditionally write another, and dispatch separate actions at each step; file uploads with progress events; third-party SDK calls that are not HTTP.

// src/store/thunks/userOnboarding.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState, AppDispatch } from "@/store";
import { userAdded } from "@/store/slices/usersSlice";
import { teamMemberAdded } from "@/store/slices/teamsSlice";

interface OnboardingPayload {
  userId: string;
  teamId: string;
  role: "admin" | "member";
}

export const onboardUserToTeam = createAsyncThunk<
  void,
  OnboardingPayload,
  { state: RootState; dispatch: AppDispatch; rejectValue: string }
>(
  "users/onboardToTeam",
  async ({ userId, teamId, role }, { getState, dispatch, rejectWithValue }) => {
    const existingUser = getState().users.entities[userId];

    if (!existingUser) {
      return rejectWithValue(`User ${userId} not found in store`);
    }

    try {
      // Step 1: assign role via a non-RTK-Query API path (e.g. SDK call)
      await permissionsSDK.assignRole({ userId, teamId, role });

      // Step 2: update local store state after SDK confirms
      dispatch(userAdded({ ...existingUser, teamId, role }));
      dispatch(teamMemberAdded({ teamId, userId }));
    } catch (err) {
      return rejectWithValue(
        err instanceof Error ? err.message : "Onboarding failed"
      );
    }
  }
);

The TypeScript generic on createAsyncThunk is where Claude most often underspecifies. The three arguments are: the return type of the payload creator, the argument type, and the thunk API config (state, dispatch, rejectValue). Without the config argument typed, getState() returns unknown and rejectWithValue loses its type. Documenting this in CLAUDE.md eliminates the pattern.

## createAsyncThunk conventions
- Always provide all three TypeScript generics:
  createAsyncThunk<ReturnType, ArgType, { state: RootState; dispatch: AppDispatch; rejectValue: string }>
- Always use rejectWithValue for error cases (not throw)
- Handle pending/fulfilled/rejected in extraReducers using builder.addCase
- Do NOT use createAsyncThunk for simple API calls. Use RTK Query instead.

Store configuration, DevTools, and middleware

The store setup is where Claude most often omits the RTK Query middleware, which is required for caching, polling, and invalidation to function.

// src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { productsApi } from "./api/productsApi";
import filtersReducer from "./slices/filtersSlice";
import usersReducer from "./slices/usersSlice";

export const store = configureStore({
  reducer: {
    filters: filtersReducer,
    users: usersReducer,
    // RTK Query reducer registered under its reducerPath
    [productsApi.reducerPath]: productsApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(productsApi.middleware),
  devTools: process.env.NODE_ENV !== "production",
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// src/store/hooks.ts
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./index";

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

The devTools: process.env.NODE_ENV !== "production" line matters in enterprise apps where Redux DevTools is a primary debugging tool. Claude sometimes omits it because configureStore enables DevTools by default and the omission is invisible until you try to disable it in production. Keeping it explicit in the CLAUDE.md store template means it is always present and always conditional.

Add to CLAUDE.md:

## Store conventions
- All RTK Query APIs must register their reducer and middleware in configureStore
- middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(...apiMiddlewares)
- devTools: process.env.NODE_ENV !== "production" (always explicit, never omit)
- RootState and AppDispatch exported from src/store/index.ts
- useAppSelector and useAppDispatch imported from src/store/hooks.ts in all components
- Never import from react-redux directly in component files (always go via hooks.ts)

Common failure modes without a CLAUDE.md

Five patterns appear consistently in Claude-generated Redux code when there is no CLAUDE.md.

Legacy connect() wiring. Claude wraps components in connect(mapStateToProps, mapDispatchToProps) instead of using useAppSelector and useAppDispatch. The class component era pattern. It works with React-Redux 9.x but is not the modern hook-based approach and does not produce typed selectors.

Hand-written action creators. Claude generates const setLoading = createAction<boolean>("filters/setLoading") alongside a manual switch reducer. createSlice generates these automatically. The hand-written versions are redundant and drift out of sync when you add reducer cases.

createAsyncThunk for every API call. Claude defaults to async thunks for all server interactions, producing a loading/error/data slice for every resource. RTK Query handles this with one endpoint definition. The thunk approach is 80% more code for the same result.

Unbound adapter selectors. usersAdapter.getSelectors() called with no argument produces selectors that accept the slice state, not the root state. Components cannot use these with useAppSelector. Claude generates this pattern without the bound selector example in CLAUDE.md.

Unmemoised derived selectors. useAppSelector(state => state.users.ids.filter(...)) returns a new array every render, causing re-renders across every subscriber. The fix is always createSelector. Claude will use plain selector functions until the CLAUDE.md rule makes createSelector the required approach.

For teams that are evaluating lighter alternatives alongside Redux, the Claude Code with Zustand guide covers the store discipline that avoids the same class of selector performance issues in a smaller API surface. For projects where Redux handles client state and a separate library handles server state, Claude Code Tanstack Query covers the pattern that avoids duplicating RTK Query functionality.

Building a Redux codebase Claude can maintain

The CLAUDE.md template in this guide covers the decisions that determine whether Claude Code generates idiomatic RTK 2 code or 2019-era Redux. The boundary between createSlice and createApi is the highest-value decision: write it down once and Claude never reaches for createAsyncThunk on an API call. The selector memoisation rule eliminates the most common Redux performance regression. The bound adapter selector pattern prevents the unbound form that breaks component wiring.

The underlying principle is the same one that applies across all Claude Code integrations. Claude performs at the level of context it receives. A Redux project without a CLAUDE.md produces Claude that generates whatever Redux pattern its training data overrepresents, which skews toward pre-RTK patterns. A project with the template above produces Claude that generates createSlice, createApi, createEntityAdapter, and createSelector from the first prompt.

For the broader principles that apply across all Claude Code sessions, Claude Code best practices covers the plan-before-execute pattern and session structure that keeps complex state management work on track.

Want a Redux CLAUDE.md template that covers RTK 2+, entity adapters, and selector conventions from day one? Claudify includes the full Redux Toolkit template alongside TypeScript, React, and testing conventions. One command: npx create-claudify.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir