Redux Cheatsheet - Toolkit, Slices, Thunks & Store
This reference is for React developers who want predictable, central state with the Redux Toolkit. It covers store configuration, createSlice with its auto-generated actions and Immer-powered immutability, async logic via createAsyncThunk with pending/fulfilled/rejected cases, middleware, and the React bindings useSelector/useDispatch. A closing section shows normalized state with createEntityAdapter and a first look at RTK Query. After reading you should be able to set up a slice, dispatch both sync and async actions, read state reactively, and model collections as normalized entities.
Typical Use Case
For front-end teams consolidating scattered useState and Context state into one store, especially projects migrating hand-written redux to Redux Toolkit (RTK). The real value of RTK is not fewer action constants but three conventions: reducers stay pure (Immer handles immutability for you), a slice name automatically becomes the action prefix, and the pending / fulfilled / rejected actions generated by createAsyncThunk map exactly onto the three states of a request. This sheet covers store configuration, slices, thunks, middleware, selectors and the entity adapter, plus the performance trap everyone hits: what useSelector returns must be a stable reference.
Store Setup 5
const store = configureStore({ reducer: rootReducer })configureStore({ reducer: { users: usersReducer, posts: postsReducer } })configureStore({ middleware: (gdm) => gdm().concat(logger) })configureStore({ devTools: process.env.NODE_ENV !== "production" })const rootReducer = combineReducers({ users, posts })Slice Creation 6
const slice = createSlice({ name, initialState, reducers })name: "counter"initialState: { count: 0 }reducers: { increment(state) { state.count += 1 } }extraReducers: (builder) => builder.addCase(fulfilled, ...)export const { increment } = slice.actionsActions & Dispatch 5
dispatch(increment())dispatch(fetchUser(id))const { increment } = counterSlice.actionsstore.dispatch({ type: "counter/increment" })export default slice.reducerAsync Handling 5
const fetchUser = createAsyncThunk("user/fetch", async (id) => {})builder.addCase(fetchUser.pending, (state) => {})builder.addCase(fetchUser.fulfilled, (state, action) => {})builder.addCase(fetchUser.rejected, (state, action) => {})await dispatch(fetchUser(id)).unwrap()Middleware 5
const logger = () => (next) => (action) => {}middleware: (gdm) => gdm().concat(customMiddleware)configureStore({ middleware: (gdm) => gdm({ serializableCheck: false }) })import { thunk } from "redux-thunk"gdm({ immutableCheck: false, serializableCheck: false })Selectors 5
useSelector((state) => state.counter.value)const dispatch = useDispatch()const selectCount = (state) => state.counter.valueconst selectUserById = createSelector(...)useSelector(selectUserById, shallowEqual)Common Patterns 6
<Provider store={store}><App /></Provider>state.entities[id]createEntityAdapter({ selectId: (item) => item.id })adapter.getSelectors((state) => state.items)adapter.upsertOne(state, item)import { createApi } from "@reduxjs/toolkit/query/react"Parameter matrix
| Parameter | Effect | Example |
|---|---|---|
configureStore({ reducer }) | Create the store with thunk and devTools middleware enabled by default | const store = configureStore({ reducer: rootReducer }) |
configureStore({ reducer: { a, b } }) | Passing an object merges multiple reducers into the root state automatically | configureStore({ reducer: { users: usersReducer, posts: postsReducer } }) |
createSlice({ name, initialState, reducers }) | Define name / initialState / reducers once and get matching actions and reducer | const slice = createSlice({ name, initialState, reducers }) |
extraReducers: (builder) => ... | Respond inside this slice to actions from other slices or external thunks | extraReducers: (builder) => builder.addCase(fulfilled, ...) |
createAsyncThunk(typePrefix, payloadCreator) | Create an async thunk that emits three lifecycle actions | const fetchUser = createAsyncThunk("user/fetch", async (id) => {}) |
dispatch(thunk).unwrap() | Await a thunk from a component, throwing on failure instead of returning a rejected action | await dispatch(fetchUser(id)).unwrap() |
middleware: (gdm) => gdm().concat(custom) | Keep the default middleware and append a custom one | configureStore({ middleware: (gdm) => gdm().concat(logger) }) |
useSelector(selectorFn, shallowEqual) | Subscribe to state; a shallow comparison avoids re-renders from freshly created objects | useSelector(selectUserById, shallowEqual) |
createSelector(...) | Create a memoized selector that reuses the last result while inputs are unchanged | const selectUserById = createSelector(...) |
adapter.getSelectors(state => state.items) | Obtain the adapter selector set, used with normalized state | adapter.getSelectors((state) => state.items) |
Common pitfalls
SymptomA reducer calls fetch / setTimeout / localStorage writes, Redux warns about a side effect in a reducer, and state order becomes wrong.
CauseReducers must be pure — same input, same output — and side effects break Redux DevTools time travel.
FixMove the side effects into a thunk or the component layer: const fetchUser = createAsyncThunk("user/fetch", async (id) => {}); the reducer only changes state.
SymptomA reducer does something other than an in-place mutation and returns the same reference, so the component never updates.
CauseThe reducer returned the original state object or an unmodified child, keeping the reference identical, and useSelector sees no change.
FixMutate the Immer draft and return state; Immer produces a new reference for writes such as state.count += 1.
SymptomuseSelector(() => ({ a: state.a, b: state.b })) re-renders on every store update.
CauseThe selector returns a brand-new object each time, and the default reference comparison always reports a change.
FixUse a shallow comparison: useSelector(selectUserById, shallowEqual), or extract the selector and memoize it with createSelector.
SymptomA failed request shows no error and awaiting the dispatched thunk never throws.
CauseDispatching a rejected thunk resolves to a rejected action object by default rather than throwing.
FixCall .unwrap() at the call site: await dispatch(fetchUser(id)).unwrap() inside try/catch, or read action.payload for the reason.
SymptomA custom middleware never runs, or an error complains that thunk is missing.
CauseThe middleware callback skipped gdm(), so the default middleware chain was replaced instead of extended.
FixWrite configureStore({ middleware: (gdm) => gdm().concat(customMiddleware) }) to append only; use gdm({ serializableCheck: false }) only when the cost is understood.
SymptomThe dev console floods with immutableCheck / serializableCheck warnings although the app works.
CauseThe state holds Date, Map or function values that are not serializable or are mutable, and the checks are simply telling you so.
FixMove such values out of the store (keep timestamp strings or id lists), and only then consider gdm({ immutableCheck: false, serializableCheck: false }).
Troubleshooting
1Confirm store wiring and the slice namespace
configureStore({ reducer: { users: usersReducer, posts: postsReducer } })Each slice name becomes the action prefix (e.g. counter/increment); check that prefix first when tracing an action.
2Trace the full lifecycle of one async request
builder.addCase(fetchUser.pending, (state) => {})Add a log line to the pending / fulfilled / rejected cases to confirm the request fired and which branch it took.
3Find out why a component re-renders so often
useSelector(selectUserById, shallowEqual)Memoize the selector and add a shallow comparison; if re-renders persist, the subscribed slice itself changes too often and should be split finer.
4Verify the middleware chain was not replaced
configureStore({ middleware: (gdm) => gdm().concat(logger) })gdm() returns the default chain before concat; omitting it also drops thunk.
5Get dispatch in a component and dispatch a raw action
store.dispatch({ type: "counter/increment" })Handy for testing a reducer in isolation, but application code should use the generated action creators from the slice.
Command Examples
Assemble a store and combine reducers
const store = configureStore({ reducer: rootReducer })
configureStore({ reducer: { users: usersReducer, posts: postsReducer } })
const rootReducer = combineReducers({ users, posts })Passing an object merges the reducers into root state automatically; configureStore already ships the default thunk and devTools middleware.
One slice from definition to dispatch
const slice = createSlice({
name: "counter",
initialState: { count: 0 },
reducers: { increment(state) { state.count += 1 } }
})
export const { increment } = slice.actions
export default slice.reducer
dispatch(increment())name becomes the action prefix (counter/increment), and the in-place assignment inside reducers is turned into an immutable update by Immer.
The three lifecycle branches of an async thunk
const fetchUser = createAsyncThunk("user/fetch", async (id) => {})
builder.addCase(fetchUser.pending, (state) => {})
builder.addCase(fetchUser.fulfilled, (state, action) => {})
builder.addCase(fetchUser.rejected, (state, action) => {})
await dispatch(fetchUser(id)).unwrap()Only with .unwrap() does a failure throw at the call site; without it dispatch resolves with an action object rather than the result.
Reading state in components without extra renders
const dispatch = useDispatch()
useSelector((state) => state.counter.value)
useSelector(selectUserById, shallowEqual)
const selectUserById = createSelector(...)Returning a primitive is the safest form; when you must return an object, pass shallowEqual or wrap it in createSelector so the previous result is reused while inputs are unchanged.
Common Pitfalls
- Never put fetch, setTimeout or localStorage writes in a reducer: the console reports an unexpected side effect and state ordering goes wrong.
- useSelector(() => ({ a: state.a, b: state.b })) returns a new reference on every run and re-renders the component endlessly; read a primitive or pass shallowEqual.
- await dispatch(thunk) without .unwrap() resolves with a rejected action instead of throwing, so the failure has to be read from the rejected branch yourself.
- Write middleware as (gdm) => gdm().concat(...): a plain array replaces the default chain, and RTK already bundles redux-thunk so a second copy errors.
- Mutating state outside the draft (holding another reference to the same object and changing it in place) leaves the reference unchanged and nothing re-renders; use adapter.upsertOne for entity collections.
Tips
- Redux three principles: single source of truth, state is read-only, changes via pure functions.
- RTK bundles Immer, so you can 'mutate' state directly in reducers; it generates immutable updates.
- useSelector re-runs on store updates; returning a new reference triggers a re-render, so watch performance.
- Redux Toolkit (RTK) is the official recommended way; configureStore and createSlice cut boilerplate.
- createAsyncThunk with extraReducers handles async; redux-thunk is bundled in RTK's default middleware.
FAQ
What boilerplate does createSlice actually remove?
Three things: hand-written action type constant strings, the matching action creator functions, and a switch-based reducer. Writing createSlice({ name, initialState, reducers }) once makes name the automatic prefix of the generated actions, and each key in reducers yields a matching creator (counter/increment), so you dispatch through const { increment } = slice.actions. It does not decide the shape of your state, and it does not relax the rule that reducers must stay pure.
Why does mutating an Immer-managed state sometimes not re-render?
The first requirement is that the change actually goes through the Immer draft. If you keep a separate reference to the same object outside the managed state and mutate that in place, you hand the store the old reference, and reference equality means subscribers are never notified. To check, dispatch that action alone and see whether the state changes; if it does not, the mutation happened outside the draft. For collections managed by createEntityAdapter({ selectId: (item) => item.id }), update through adapter.upsertOne(state, item) instead.
Where should the three createAsyncThunk actions be handled?
Handle pending, fulfilled and rejected in the slice extraReducers via builder.addCase so the in-flight flag, the payload and the error all land in one place instead of being scattered across local component state. Components only dispatch: dispatch(fetchUser(id)), or await dispatch(fetchUser(id)).unwrap() when you want the return value and want a failure to throw at the call site. Leaving the assignment logic in a component bypasses exactly the convention extraReducers establishes.
Why does useSelector cause renders it should not?
The selector runs again on every store update, and returning a freshly built object such as () => ({ a: state.a, b: state.b }) produces a new reference each time, so even a shallow comparison reports a change. Two remedies: prefer a primitive-returning selector like useSelector((state) => state.counter.value), and when you must return an object, pass shallowEqual as the second argument or wrap it in createSelector(...) so an unchanged input reuses the previous result. For id-based lookups, the selectors from adapter.getSelectors((state) => state.items) already handle this.
How do I confirm a custom middleware is actually in the chain?
Write middleware: (gdm) => gdm().concat(customMiddleware): call gdm() first to get the defaults and concatenate onto them, because passing a plain array replaces the default chain. RTK already bundles redux-thunk (import { thunk } from "redux-thunk" refers to exactly that middleware), so adding a second one throws. The signature is triple-curried, const logger = () => (next) => (action) => {}, with the outermost layer invoked once at store setup. To verify, put a console log inside and dispatch a synchronous action to see whether it prints.
Official References
Each command links to its official documentation below, so you can verify the latest usage and read deeper.
Maintained by LaoHand
Publicly updated on Jul 21, 2026, continuously proofread against official docs.
Contact Us
Wrong command or description? Send us corrections, business inquiries or product feedback by email.
Contact Us