redux
Redux is a state container built on one rule: all your state lives in a single immutable object, and the only way to change it is to dispatch a plain action object that a pure reducer function turns into a new state. The whole core is about 1.4 KB gzipped and exports six functions: createStore, combineReducers, applyMiddleware, compose, bindActionCreators, and isAction. Because every change is a serialisable action processed by a pure function, you get replayable state, time-travel debugging, and a single place to reason about mutations. It is not React-specific and works in Node, on a server, or with any view layer. The important thing to know before installing it is that the Redux team no longer recommend using this package directly: createStore is marked deprecated in its own type definitions, and the official answer is Redux Toolkit, which wraps this core and writes most of the boilerplate for you.
Redux the idea is still sound, and the 1.4 KB core is a fine state container for non-React code or for learning how the pattern works. Redux the install is the wrong one for a new app: use @reduxjs/toolkit, which is this package plus everything you would otherwise write by hand.
Use it if
- You are learning how Redux actually works, following the Fundamentals tutorial, which deliberately builds everything from the bare core before introducing abstractions
- You need a predictable state container outside React: a CLI, a Node service, a game loop, or a web component, where the 1.4 KB core and zero dependencies matter
- You are maintaining an existing Redux codebase and want to migrate to Redux Toolkit slice by slice rather than in one rewrite
- You want total control of the middleware and enhancer pipeline, for example to implement event sourcing or a custom persistence layer, without a framework deciding the defaults for you
- You need state that is fully serialisable so you can log every action, ship it to an error reporter, and replay a user session
- You are starting a new app: the maintainers deprecate createStore in the types and point everyone at configureStore from @reduxjs/toolkit, so hand-rolling switch-statement reducers now means writing legacy code on purpose
- You do not want the boilerplate: with the bare core you hand-write action type constants, action creators, immutable spread updates, the devtools wiring, and the thunk middleware, and Redux Toolkit removes all five
- Your app just needs some shared state across a few components: React context with useReducer, or zustand, gets you there without a store, a provider, action objects, and a selector layer
- You have async work: the core has no async story whatsoever, so every fetch needs middleware you install yourself, and caching, deduplication, and invalidation need RTK Query or TanStack Query on top
- You care about re-render performance and will not add reselect: a selector that builds a new array or object on every call makes every connected component re-render on every action, and nothing in the core memoises anything
- You are looking for new features: 5.0.1 shipped in December 2023 and there has been no core release since, because all the work now happens in Redux Toolkit
Setup reality
npm install redux is the whole install: no dependencies, types bundled, works anywhere. The honest setup instruction is different though, and the README says it outright: npm install @reduxjs/toolkit react-redux. If you do use the core directly, v5 changed several things quietly. The primary build artifact is now ESM at dist/redux.mjs targeting ES2020, the UMD builds are gone, and action.type must be a string at runtime rather than a symbol or number. In TypeScript, AnyAction is deprecated in favour of UnknownAction, the standalone PreloadedState type was removed in favour of a third generic on Reducer, and middleware now receives action typed as unknown, so every custom middleware needs the isAction type guard before it can read action.type. Your editor will also strike through createStore because of its deprecation tag; legacy_createStore is the identical function without the marker if the noise bothers you. Redux DevTools is not wired for you either: you compose the extension enhancer yourself, which is one of the many things configureStore does by default.
Patterns
Create a store from a reducercreate-a-store
import { createStore } from 'redux'
const initialState = { value: 0 }
function counter(state = initialState, action) {
switch (action.type) {
case 'counter/incremented':
return { ...state, value: state.value + 1 }
case 'counter/amountAdded':
return { ...state, value: state.value + action.payload }
default:
return state
}
}
const store = createStore(counter)Your editor will strike through createStore: it carries a @deprecated tag pointing at configureStore, and legacy_createStore is the same function without the tag. The default case returning state unchanged is mandatory, because Redux dispatches an internal init action before yours and a reducer that returns undefined throws.
Read state, dispatch, and react to changesdispatch-and-subscribe
const unsubscribe = store.subscribe(() => {
console.log(store.getState())
})
store.dispatch({ type: 'counter/incremented' })
store.dispatch({ type: 'counter/amountAdded', payload: 5 })
unsubscribe()subscribe fires after every dispatch regardless of whether anything you care about changed, and it hands you no arguments, so you compare getState() yourself. In v5 action.type must be a string at runtime; symbols and numbers, which used to work, now throw.
Split state across reducerscombine-reducers
import { combineReducers, createStore } from 'redux'
const rootReducer = combineReducers({
counter,
todos,
auth,
})
const store = createStore(rootReducer)
// state shape: { counter: {...}, todos: [...], auth: {...} }Each slice reducer only sees its own branch and cannot read a sibling, which is the constraint people hit first; cross-slice logic goes in a wrapping reducer or a thunk. Every action is passed to every reducer, so slices that both care about a logout action can each handle it.
Add middleware to the dispatch pipelineapply-middleware
import { applyMiddleware, createStore, isAction } from 'redux'
const logger = store => next => action => {
if (isAction(action)) console.group(action.type)
const result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}
const store = createStore(rootReducer, applyMiddleware(logger))In v5 the action parameter is typed unknown, so TypeScript will not let you read action.type until isAction narrows it. Middleware order matters and runs left to right on the way in, right to left on the way out, so a logger placed after a thunk middleware never sees the function you dispatched.
Handle async work without a libraryasync-with-thunks
const thunk = ({ dispatch, getState }) => next => action =>
typeof action === 'function' ? action(dispatch, getState) : next(action)
const store = createStore(rootReducer, applyMiddleware(thunk))
const loadUser = id => async (dispatch) => {
dispatch({ type: 'user/loading' })
try {
const res = await fetch(`/api/users/${id}`)
dispatch({ type: 'user/loaded', payload: await res.json() })
} catch (err) {
dispatch({ type: 'user/failed', error: String(err) })
}
}
store.dispatch(loadUser(7))That five-line middleware is essentially all redux-thunk is, and the core has no async concept without it. What it does not give you is caching, request deduplication, or invalidation, which is the point at which RTK Query or TanStack Query stops being optional.
Wire up Redux DevToolscompose-enhancers
import { applyMiddleware, compose, createStore } from 'redux'
const composeEnhancers =
(typeof window !== 'undefined' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
compose
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk, logger))
)The core does nothing with DevTools on its own; this composition is manual, and the window guard matters for server rendering. configureStore from Redux Toolkit does exactly this for you and disables it in production builds, which the snippet above does not.
Hydrate a store from the serverpreloaded-state
// server
const store = createStore(rootReducer)
await store.dispatch(loadInitialData())
const html = `<script>window.__STATE__ = ${JSON.stringify(store.getState()).replace(/</g, '\\u003c')}</script>`
// client
const store = createStore(rootReducer, window.__STATE__)The second argument to createStore is preloaded state, and it must match the shape combineReducers expects or the slice reducers silently fall back to their defaults. Escape the < characters when embedding JSON in a script tag, or a string in your state can close the tag and become an XSS hole. Create a new store per request on the server; a module-level store leaks one user's data into the next request.
Type the store, state, and dispatchtypescript-types
import type { Action, Reducer } from 'redux'
interface CounterState { value: number }
type CounterAction =
| { type: 'counter/incremented' }
| { type: 'counter/amountAdded'; payload: number }
const counter: Reducer<CounterState, CounterAction> = (
state = { value: 0 },
action
) => { /* ... */ return state }
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatchv5 removed the standalone PreloadedState type; it is now a third generic on Reducer defaulting to S, which is what you touch when the hydrated state is a partial. AnyAction still exists but is deprecated in favour of UnknownAction, which forces you to narrow before reading a payload.
Stop selectors from causing re-rendersmemoized-selectors
import { createSelector } from 'reselect'
// recomputes and returns a new array on every call
const selectDone = state => state.todos.filter(t => t.done)
// memoised: same reference until state.todos changes
const selectDoneMemo = createSelector(
[state => state.todos],
todos => todos.filter(t => t.done)
)The core has no selector concept at all. Any selector returning a fresh object or array fails the reference check in react-redux and re-renders the component on every dispatched action, including unrelated ones. reselect is a separate install here, and it comes bundled with Redux Toolkit.
Pre-bind action creators to dispatchbind-action-creators
import { bindActionCreators } from 'redux'
const actionCreators = {
increment: () => ({ type: 'counter/incremented' }),
addAmount: (n) => ({ type: 'counter/amountAdded', payload: n }),
}
const actions = bindActionCreators(actionCreators, store.dispatch)
actions.addAmount(5) // dispatches for youMostly useful for handing callbacks to a component that should know nothing about Redux. Calling it inside a React render creates new function identities every time and defeats memoisation, so bind once outside the component or wrap it in useMemo.
Swap reducers at runtime for code splittingreplace-reducer
const staticReducers = { auth, router }
const asyncReducers = {}
function buildRootReducer() {
return combineReducers({ ...staticReducers, ...asyncReducers })
}
export function injectReducer(key, reducer) {
asyncReducers[key] = reducer
store.replaceReducer(buildRootReducer())
}This is how lazily loaded routes add their slice to an existing store. Existing state survives the swap, but any slice whose reducer is no longer in the map keeps its old data in the tree forever unless you strip it, which shows up as stale state after a route unmounts.
Replace the boilerplate with Redux Toolkitmigrate-to-toolkit
import { configureStore, createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
incremented: (state) => { state.value += 1 },
amountAdded: (state, action) => { state.value += action.payload },
},
})
export const { incremented, amountAdded } = counterSlice.actions
const store = configureStore({
reducer: { counter: counterSlice.reducer },
})The mutating syntax is safe because Immer produces a new state behind the scenes; returning a value and mutating in the same reducer is the one thing that breaks. configureStore already includes thunk, devtools, and development-only checks for accidental mutation and non-serialisable values. You can adopt this one slice at a time, since configureStore accepts your existing hand-written reducers unchanged.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @reduxjs/toolkit | npm | Any new Redux code: same store underneath, with createSlice, configureStore, thunks, devtools, and RTK Query included |
| zustand | npm | You want global state in a hook with no actions, reducers, or provider, and you can live without time-travel debugging |
| jotai | npm | Your state is naturally lots of small independent pieces and you want atom-level subscriptions rather than one big tree plus selectors |