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.

Languages·37 commands·Last updated 2026-07-21
reduxreactState Managementstore

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 })
Create a store with RTK
configureStore({ reducer: { users: usersReducer, posts: postsReducer } })
Auto-merge multiple reducers
configureStore({ middleware: (gdm) => gdm().concat(logger) })
Append custom middleware
configureStore({ devTools: process.env.NODE_ENV !== "production" })
Enable devTools in dev only
const rootReducer = combineReducers({ users, posts })
Manually combine multiple reducers

Slice Creation 6

const slice = createSlice({ name, initialState, reducers })
Create a slice
name: "counter"
Slice namespace (auto action prefix)
initialState: { count: 0 }
Slice initial state
reducers: { increment(state) { state.count += 1 } }
Synchronous reducer (Immer keeps it immutable)
extraReducers: (builder) => builder.addCase(fulfilled, ...)
Handle external async actions
export const { increment } = slice.actions
Export auto-generated action creators

Actions & Dispatch 5

dispatch(increment())
Dispatch a sync action
dispatch(fetchUser(id))
Dispatch an async thunk
const { increment } = counterSlice.actions
Get the action creator from the slice
store.dispatch({ type: "counter/increment" })
Dispatch a raw action object directly
export default slice.reducer
Export the reducer for the store

Async Handling 5

const fetchUser = createAsyncThunk("user/fetch", async (id) => {})
Create an async thunk
builder.addCase(fetchUser.pending, (state) => {})
Handle the pending state
builder.addCase(fetchUser.fulfilled, (state, action) => {})
Handle the fulfilled state
builder.addCase(fetchUser.rejected, (state, action) => {})
Handle the rejected state
await dispatch(fetchUser(id)).unwrap()
Get the result or throw on error

Middleware 5

const logger = () => (next) => (action) => {}
Custom middleware signature (triple curry)
middleware: (gdm) => gdm().concat(customMiddleware)
Keep defaults and append custom middleware
configureStore({ middleware: (gdm) => gdm({ serializableCheck: false }) })
Configure default middleware options
import { thunk } from "redux-thunk"
redux-thunk async middleware (bundled in RTK)
gdm({ immutableCheck: false, serializableCheck: false })
Disable checks in production for performance

Selectors 5

useSelector((state) => state.counter.value)
Read state from the store
const dispatch = useDispatch()
Get the dispatch function
const selectCount = (state) => state.counter.value
Define a selector function
const selectUserById = createSelector(...)
Create a memoized selector (reselect)
useSelector(selectUserById, shallowEqual)
Shallow compare to avoid needless re-renders

Common Patterns 6

<Provider store={store}><App /></Provider>
Inject the store at the root
state.entities[id]
Normalized state (store entities by id)
createEntityAdapter({ selectId: (item) => item.id })
Entity adapter manages collections
adapter.getSelectors((state) => state.items)
Get entity adapter selectors
adapter.upsertOne(state, item)
Upsert one (update if exists, insert if not)
import { createApi } from "@reduxjs/toolkit/query/react"
RTK Query data fetching

Parameter matrix

ParameterEffectExample
configureStore({ reducer })Create the store with thunk and devTools middleware enabled by defaultconst store = configureStore({ reducer: rootReducer })
configureStore({ reducer: { a, b } })Passing an object merges multiple reducers into the root state automaticallyconfigureStore({ reducer: { users: usersReducer, posts: postsReducer } })
createSlice({ name, initialState, reducers })Define name / initialState / reducers once and get matching actions and reducerconst slice = createSlice({ name, initialState, reducers })
extraReducers: (builder) => ...Respond inside this slice to actions from other slices or external thunksextraReducers: (builder) => builder.addCase(fulfilled, ...)
createAsyncThunk(typePrefix, payloadCreator)Create an async thunk that emits three lifecycle actionsconst fetchUser = createAsyncThunk("user/fetch", async (id) => {})
dispatch(thunk).unwrap()Await a thunk from a component, throwing on failure instead of returning a rejected actionawait dispatch(fetchUser(id)).unwrap()
middleware: (gdm) => gdm().concat(custom)Keep the default middleware and append a custom oneconfigureStore({ middleware: (gdm) => gdm().concat(logger) })
useSelector(selectorFn, shallowEqual)Subscribe to state; a shallow comparison avoids re-renders from freshly created objectsuseSelector(selectUserById, shallowEqual)
createSelector(...)Create a memoized selector that reuses the last result while inputs are unchangedconst selectUserById = createSelector(...)
adapter.getSelectors(state => state.items)Obtain the adapter selector set, used with normalized stateadapter.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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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