Use this when authoring or refactoring slices with createSlice, selectors, create.asyncThunk, entity adapters, or lazy reducer injection. Covers Immer-backed mutation syntax, slice selectors, getSelectors, injectInto, withLazyLoadedSlices, and current RTK 2 slice patterns.
// file: src/app/createAppSlice.ts
import { asyncThunkCreator, buildCreateSlice } from '@reduxjs/toolkit'
export const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator },
})
// file: src/features/posts/postsSlice.ts
import { createSelector } from '@reduxjs/toolkit'
import { createAppSlice } from '../../app/createAppSlice'
type PostsState = {
items: { id: string; title: string; published: boolean }[]
status: 'idle' | 'pending' | 'succeeded' | 'failed'
}
const initialState: PostsState = {
items: [],
status: 'idle',
}
export const postsSlice = createAppSlice({
name: 'posts',
initialState,
reducers: (create) => ({
postAdded: create.reducer<{ id: string; title: string }>((state, action) => {
state.items.push({ ...action.payload, published: false })
}),
fetchPosts: create.asyncThunk(
async () => {
const response = await fetch('/api/posts')
return (await response.json()) as { id: string; title: string; published: boolean }[]
},
{
pending: (state) => {
state.status = 'pending'
},
fulfilled: (state, action) => {
state.status = 'succeeded'
state.items = action.payload
},
rejected: (state) => {
state.status = 'failed'
},
},
),
}),
selectors: {
selectPosts: (state) => state.items,
selectPublishedPosts: createSelector(
[(state: PostsState) => state.items],
(items) => items.filter((post) => post.published),
),
},
})
export const { postAdded, fetchPosts } = postsSlice.actions
export const { selectPosts, selectPublishedPosts } = postsSlice.selectors
import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: [] as { id: string; text: string; done: boolean }[],
reducers: {
todoAdded(state, action: { payload: { id: string; text: string } }) {
state.push({ ...action.payload, done: false })
},
todoToggled(state, action: { payload: { id: string } }) {
const todo = state.find((item) => item.id === action.payload.id)
if (todo) {
todo.done = !todo.done
}
},
},
})
Immer is the default inside createSlice; write the reducer logic directly instead of copying arrays and objects by hand.
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment(state) {
state.value += 1
},
},
selectors: {
selectValue: (state) => state.value,
selectIsPositive: (state) => state.value > 0,
},
})
const { selectValue, selectIsPositive } = counterSlice.selectors
Slice selectors keep state-location knowledge next to the slice.
create.asyncThunk when the async lifecycle belongs to the sliceimport { asyncThunkCreator, buildCreateSlice } from '@reduxjs/toolkit'
const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator },
})
const usersSlice = createAppSlice({
name: 'users',
initialState: { items: [] as { id: string; name: string }[], status: 'idle' as 'idle' | 'pending' | 'failed' },
reducers: (create) => ({
fetchUsers: create.asyncThunk(
async () => {
const response = await fetch('/api/users')
return (await response.json()) as { id: string; name: string }[]
},
{
pending: (state) => {
state.status = 'pending'
},
fulfilled: (state, action) => {
state.status = 'idle'
state.items = action.payload
},
rejected: (state) => {
state.status = 'failed'
},
},
),
}),
})
Use this when the async lifecycle handlers naturally live with the slice; otherwise regular createAsyncThunk is still fine.
import {
combineSlices,
createEntityAdapter,
createSlice,
} from '@reduxjs/toolkit'
type Book = { bookId: string; title: string }
const booksAdapter = createEntityAdapter<Book>({
selectId: (book) => book.bookId,
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
booksReceived: booksAdapter.setAll,
},
})
export interface LazyLoadedSlices {}
export const rootReducer =
combineSlices().withLazyLoadedSlices<LazyLoadedSlices>()
declare module './rootReducer' {
export interface LazyLoadedSlices {}
}
const injectedBooksSlice = booksSlice.injectInto(rootReducer)
const selectors = booksAdapter.getSelectors(
(state: ReturnType<typeof rootReducer.selector.original>) =>
injectedBooksSlice.selectSlice(state),
)
Entity adapters standardize normalized collections, and injectInto lets a slice stay aware of its injected location.
Wrong:
type Todo = { id: string; text: string }
export function addTodo(todos: Todo[], todo: Todo) {
todos.push(todo)
return todos
}
Correct:
type Todo = { id: string; text: string }
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
todoAdded(state, action: { payload: Todo }) {
state.push(action.payload)
},
},
})
Mutation syntax is only safe inside Immer-backed reducer contexts such as createSlice and createReducer.
Source: reduxjs/redux-toolkit:docs/usage/immer-reducers.md
Wrong:
export default function todosReducer(state = initialState, action: { type: string; payload?: Todo }) {
switch (action.type) {
case 'todos/todoAdded':
return state.concat(action.payload as Todo)
default:
return state
}
}
Correct:
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action: { payload: Todo }) {
state.push(action.payload)
},
},
})
Hand-written reducers are an escape hatch for proven bottlenecks, not the normal thing an agent should generate in RTK code.
Source: reduxjs/redux-toolkit:docs/usage/migrating-to-modern-redux.mdx
extraReducersWrong:
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
const initialState = { items: [] as { id: string; title: string }[] }
const fetchPosts = createAsyncThunk('posts/fetch', async () => {
const response = await fetch('/api/posts')
return (await response.json()) as { id: string; title: string }[]
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {},
extraReducers: {
[fetchPosts.fulfilled.type]: (state, action) => {
state.items = action.payload
},
},
})
Correct:
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
const initialState = { items: [] as { id: string; title: string }[] }
const fetchPosts = createAsyncThunk('posts/fetch', async () => {
const response = await fetch('/api/posts')
return (await response.json()) as { id: string; title: string }[]
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchPosts.fulfilled, (state, action) => {
state.items = action.payload
})
},
})
RTK 2 removed the object form; agents trained on RTK 1.x still generate it.
Source: reduxjs/redux-toolkit:docs/usage/migrating-rtk-2.md
entity.id exists for every collectionWrong:
type Book = { bookId: string; title: string }
const booksAdapter = createEntityAdapter<Book>()
Correct:
type Book = { bookId: string; title: string }
const booksAdapter = createEntityAdapter<Book>({
selectId: (book) => book.bookId,
})
Adapters default to entity.id; collections keyed by another field must provide selectId.
Source: reduxjs/redux-toolkit:docs/api/createEntityAdapter.mdx