@reduxjs/toolkit
Redux Toolkit (RTK) is the official Redux package, and it exists because raw Redux made you hand-write action type constants, action creators, switch-statement reducers, and store wiring for every feature. RTK replaces all of that with createSlice, which takes a name, an initial state, and an object of reducer functions, then hands back the reducer plus matching action creators. configureStore sets up the store with the thunk middleware, the Redux DevTools connection, and development-only checks for accidental mutation and non-serializable state already turned on. Immer is built in, so inside a reducer you write state.items.push(item) and get a correctly immutable update out. The package also bundles RTK Query, a separate opt-in entry point that handles data fetching, caching, request deduplication, and cache invalidation, and generates React hooks for each endpoint you declare.
If you are writing Redux, write it with RTK; the hand-rolled alternative is strictly worse. The real decision is upstream of that, because plenty of apps that reach for Redux would be better served by zustand for client state and TanStack Query for server data.
Use it if
- You already have Redux in the app: RTK is the officially recommended way to write it and cuts the per-feature boilerplate to one createSlice call
- Many unrelated components read and write the same state, and you want one inspectable store plus DevTools time-travel rather than prop drilling or a pile of contexts
- You want server data caching and global client state from one dependency, since RTK Query ships inside the same package and stores its cache in the same store
- Your team wants strong TypeScript inference without writing action union types by hand, which createSlice and the builder callback give you
- You need middleware-level control over side effects, whether that is createListenerMiddleware for reactive logic or your own middleware for logging and analytics
- Your shared state is a handful of values: zustand or jotai gets you there in a fraction of the code and a fraction of the bytes, since RTK core is around 13.6 KB gzipped before react-redux and before RTK Query
- Almost all your state is server data: TanStack Query is a more complete cache with a much larger community around it, and adopting Redux only to reach RTK Query is a lot of machinery for one feature
- You do not want Immer: createSlice reducers are Immer producers, so returning a new value and mutating the draft in the same reducer throws at runtime, and every value you put in state has to survive proxying
- You store Dates, Maps, class instances, or functions in state: the default serializableCheck middleware warns on all of them in development, and you either restructure the data or start adding ignore lists
- Your app is mostly React Server Components: the store is client-only and needs a fresh instance per request on the server, which is enough extra wiring that RTK has a dedicated docs page about it
- You are counting concepts your team has to learn: slices, thunks, selectors, middleware, entity adapters, and RTK Query cache tags is a much bigger surface than useState plus a fetch wrapper
Setup reality
npm install @reduxjs/toolkit react-redux gets you running, and TypeScript types are bundled with no @types package needed. React and react-redux are optional peer dependencies, so RTK installs cleanly in a Node script, but the moment you use the React bindings your package manager will complain if react-redux is missing. Six runtime dependencies come along including redux, immer, reselect, and redux-thunk, which is normal but does mean your lockfile grows. The setup that actually takes time is typing: you export RootState and AppDispatch from the store file, then build typed useAppSelector and useAppDispatch hooks with react-redux's withTypes helpers, and every tutorial older than RTK 2.0 shows a pattern that no longer compiles. Two more edges worth knowing before you start: the object form of extraReducers was removed in 2.0 so it is builder callbacks only, and RTK Query lives at separate entry points (@reduxjs/toolkit/query and @reduxjs/toolkit/query/react) rather than the package root.
Patterns
Create the store and export its typesconfigure-store
// app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from '../features/todos/todosSlice'
import filtersReducer from '../features/filters/filtersSlice'
export const store = configureStore({
reducer: {
todos: todosReducer,
filters: filtersReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatchconfigureStore combines the reducer object for you, adds redux-thunk, wires up DevTools, and turns on the immutability and serializability checks in development only. Deriving RootState from getState rather than declaring it by hand is what keeps selectors typed as slices come and go.
Define a slice with reducers and selectorscreate-slice
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
interface Todo { id: string; text: string; done: boolean }
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] as Todo[] },
reducers: {
added(state, action: PayloadAction<Todo>) {
state.items.push(action.payload)
},
toggled(state, action: PayloadAction<string>) {
const todo = state.items.find((t) => t.id === action.payload)
if (todo) todo.done = !todo.done
},
cleared(state) {
state.items = []
},
},
selectors: {
selectAll: (state) => state.items,
selectRemaining: (state) => state.items.filter((t) => !t.done).length,
},
})
export const { added, toggled, cleared } = todosSlice.actions
export const { selectAll, selectRemaining } = todosSlice.selectors
export default todosSlice.reducerThe mutating code is Immer draft syntax, not real mutation. Never both mutate the draft and return a value from the same reducer: Immer throws on that. slice.selectors are written against the slice state and get rooted at slice.name automatically.
Build typed useSelector and useDispatchtyped-hooks
// app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()withTypes is the react-redux 9 form and replaces the older `useDispatch: () => AppDispatch` alias pattern you will still find in tutorials. The typed dispatch is what lets you dispatch a thunk without a TypeScript error.
Fetch with createAsyncThunk and handle its lifecycleasync-thunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
export const fetchUser = createAsyncThunk(
'users/fetchById',
async (userId: string, { rejectWithValue, signal }) => {
const res = await fetch(`/api/users/${userId}`, { signal })
if (!res.ok) return rejectWithValue({ status: res.status })
return (await res.json()) as User
},
{
condition: (userId, { getState }) => {
const { users } = getState() as RootState
return !users.byId[userId] // skip if already cached
},
},
)
const usersSlice = createSlice({
name: 'users',
initialState: { byId: {} as Record<string, User>, loading: false, error: null as unknown },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => { state.loading = true })
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.byId[action.payload.id] = action.payload
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
state.error = action.payload ?? action.error.message
})
},
})The object form of extraReducers was removed in RTK 2.0, so the builder callback is the only option. Returning rejectWithValue puts your data on action.payload; a thrown error lands on action.error instead, which is why the rejected case checks both.
Declare an API slice and use the generated hooksrtk-query-queries
// services/pokemon.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api/',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token
if (token) headers.set('authorization', `Bearer ${token}`)
return headers
},
}),
tagTypes: ['Post'],
endpoints: (build) => ({
getPosts: build.query<Post[], void>({
query: () => 'posts',
providesTags: ['Post'],
}),
getPost: build.query<Post, string>({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
}),
})
export const { useGetPostsQuery, useGetPostQuery } = api
// store.ts
// reducer: { [api.reducerPath]: api.reducer },
// middleware: (gDM) => gDM().concat(api.middleware),Forgetting to add api.middleware is the classic RTK Query bug: hooks stay stuck in the loading state with no error. Import from @reduxjs/toolkit/query/react for the hooks, or @reduxjs/toolkit/query for the framework-free version.
Mutate and invalidate the cachertk-query-mutations
addPost: build.mutation<Post, Partial<Post>>({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: ['Post'],
}),
// component
const [addPost, { isLoading, error }] = useAddPostMutation()
async function onSubmit(values: Partial<Post>) {
try {
const created = await addPost(values).unwrap()
navigate(`/posts/${created.id}`)
} catch (err) {
// err is the serialized error, not a thrown Response
}
}Mutation triggers resolve with a result object rather than rejecting, so call .unwrap() when you want a try/catch. invalidatesTags matched against providesTags is what triggers the refetch; get the tag names wrong and nothing happens, silently.
Normalize a collection with createEntityAdapterentity-adapter
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
const booksAdapter = createEntityAdapter<Book>({
sortComparer: (a, b) => a.title.localeCompare(b.title),
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({ loading: false }),
reducers: {
bookAdded: booksAdapter.addOne,
booksReceived: booksAdapter.setAll,
bookUpdated: booksAdapter.updateOne,
bookRemoved: booksAdapter.removeOne,
},
})
export const { selectAll: selectAllBooks, selectById: selectBookById } =
booksAdapter.getSelectors((state: RootState) => state.books)The adapter keeps { ids, entities } in state so lookups are O(1) instead of Array.find. updateOne takes { id, changes } rather than a whole entity, which trips people up on the first try.
Derive data without re-rendering on every dispatchmemoized-selector
import { createSelector } from '@reduxjs/toolkit'
export const selectVisibleTodos = createSelector(
[(state: RootState) => state.todos.items, (state: RootState) => state.filters.status],
(items, status) =>
status === 'all' ? items : items.filter((t) => (status === 'done' ? t.done : !t.done)),
)Any selector that builds a new array or object needs memoizing, because useSelector compares with reference equality and a fresh array every call means a re-render on every dispatch. createSelector is Reselect, re-exported by RTK.
React to actions without sagaslistener-middleware
import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'
export const listenerMiddleware = createListenerMiddleware()
listenerMiddleware.startListening({
matcher: isAnyOf(todoAdded, todoToggled),
effect: async (action, listenerApi) => {
listenerApi.cancelActiveListeners()
await listenerApi.delay(500) // debounce
const state = listenerApi.getState() as RootState
localStorage.setItem('todos', JSON.stringify(state.todos.items))
},
})
// store.ts
// middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware),Use prepend rather than concat so the listener sees actions before other middleware. cancelActiveListeners plus delay is the supported debounce; the effect is cancelled by throwing internally, so do not swallow errors around await points.
Generate part of the payload inside the action creatorprepare-payload
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] as Todo[] },
reducers: {
added: {
reducer(state, action: PayloadAction<Todo>) {
state.items.push(action.payload)
},
prepare(text: string) {
return { payload: { id: nanoid(), text, done: false } }
},
},
},
})Reducers have to stay pure, so nanoid() and Date.now() belong in prepare, not in the reducer body. prepare must return an object with a payload key; returning the payload directly is a common mistake.
Inject slices after the store is createdlazy-load-slices
import { combineSlices, configureStore } from '@reduxjs/toolkit'
import { todosSlice } from './features/todos/todosSlice'
const rootReducer = combineSlices(todosSlice)
export const store = configureStore({ reducer: rootReducer })
export type RootState = ReturnType<typeof rootReducer>
// in a lazily loaded route bundle
const injected = rootReducer.inject(adminSlice)
const selectAdmin = injected.selector((state) => state.admin)Code splitting a large app means the store cannot know every reducer up front. combineSlices keeps RootState typed as the injected slices are optional, so downstream selectors have to handle the slice being undefined before injection.
Tune the development checks instead of fighting themconfigure-middleware-checks
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['file/uploadStarted'],
ignoredPaths: ['upload.fileHandle'],
},
immutableCheck: { warnAfter: 128 },
}).concat(api.middleware),
})Both checks are development-only and both walk your state on every action, so a large store makes dispatches visibly slow in dev. Reach for these options only after deciding the non-serializable value genuinely belongs in the store.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zustand | npm | You want a small global store with hooks and no providers, actions, or middleware pipeline |
| @tanstack/react-query | npm | Your state is mostly server data and you want caching, refetching, and mutations without adopting Redux at all |
| jotai | npm | You prefer bottom-up atomic state that re-renders only the components reading a given atom |