with RTK Query and createSlice
Redux Toolkit has largely replaced classic Redux with its endless switch statements and action type constants. With createSlice, RTK Query and Entity Adapter, global state in React applications can be modeled with far less code, more type safety and built in server state caching.
Table of Contents
- 1. Why Redux Toolkit and not classic Redux
- 2. createSlice: reducer logic without boilerplate
- 3. RTK Query: server state without manual caching logic
- 4. Selectors with createSelector and memoization
- 5. Entity Adapter: managing normalized data
- 6. Async flows: createAsyncThunk versus RTK Query
- 7. TypeScript integration: typed slices and hooks
- 8. Middleware and DevTools: debugging and extensibility
- 9. Redux Toolkit compared to other approaches
- 10. Summary
- 11. FAQ
1. Why Redux Toolkit and not classic Redux
Classic Redux required three action type constants, at least one action creator and a switch statement reducer with manually written immutability logic for every tiny piece of state. Redux Toolkit was built by the Redux maintainers themselves to eliminate exactly this boilerplate, without abandoning the core idea of Redux: a single predictable store with a unidirectional data flow. For years now, Redux Toolkit has been the officially recommended way to use Redux at all, classic Redux without the toolkit is considered a legacy pattern.
The core of Redux Toolkit consists of configureStore, createSlice and createAsyncThunk, complemented by the separate RTK Query package for server state. Under the hood, Redux Toolkit uses the Immer library, which lets reducer functions look as if they mutate state directly, even though a new immutable state is produced behind the scenes. For teams already working with Redux and migrating to React 19, Redux Toolkit is usually the most pragmatic path, because the store itself stays intact and only the way slices are written gets modernized.
An often overlooked benefit of Redux Toolkit is that DevTools integration is preconfigured, along with sensible middleware defaults such as the serializable check and the immutability check, which automatically catch violations of the core Redux rules during development. Teams that take these warnings seriously prevent subtle bugs that in classic Redux often only surface in production.
2. createSlice: reducer logic without boilerplate
createSlice is the central function of Redux Toolkit and bundles action types, action creators and the reducer into a single declaration. Instead of defining a separate string for every action, createSlice automatically generates action types from the slice name and the reducer function names. Inside the reducer functions, Immer allows direct assignment to object properties and array push operations, keeping the code much closer to normal JavaScript functions than the plain Redux pattern with spread operators.
A second important building block is prepare, which transforms the payload of an action before the actual reducer runs, for example to generate a unique ID before an entry is written to state. For asynchronous flows that do not go through RTK Query, extra reducers can be attached to a slice via extraReducers, so a slice can react to the pending, fulfilled and rejected states of a separately defined thunk without the thunk having to be defined inside the slice itself.
// features/cartSlice.js — createSlice replaces action types, creators and reducer boilerplate
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 },
reducers: {
itemAdded: (state, action) => {
// Immer lets us "mutate" state directly, a new immutable state is created behind the scenes
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
state.total = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
itemRemoved: (state, action) => {
state.items = state.items.filter((item) => item.id !== action.payload);
state.total = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
cartCleared: (state) => {
state.items = [];
state.total = 0;
},
},
});
export const { itemAdded, itemRemoved, cartCleared } = cartSlice.actions;
export default cartSlice.reducer;
3. RTK Query: server state without manual caching logic
RTK Query is arguably the biggest leap Redux Toolkit has taken beyond classic Redux. Instead of writing thunks by hand for every API call, including loading flags, error handling and caching, createApi defines a declarative API slice from which typed React hooks are generated automatically. RTK Query handles request deduplication, tag based cache invalidation and automatic refetching as soon as dependent data changes, all without an extra library.
The tag based invalidation system is the most powerful concept here: every query can provide tags, every mutation can invalidate the same tags, and RTK Query automatically triggers a refetch of all affected queries. For teams that already use Redux, RTK Query often replaces separate data fetching libraries entirely, because server state ends up in the same store as client state and can be inspected with the same DevTools.
// features/apiSlice.js — RTK Query replaces manual thunks and caching logic
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product'],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => 'products',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Product', id })), { type: 'Product', id: 'LIST' }]
: [{ type: 'Product', id: 'LIST' }],
}),
updateProduct: builder.mutation({
query: ({ id, ...patch }) => ({ url: `products/${id}`, method: 'PATCH', body: patch }),
// Invalidating this tag triggers an automatic refetch of getProducts
invalidatesTags: (result, error, { id }) => [{ type: 'Product', id }],
}),
}),
});
export const { useGetProductsQuery, useUpdateProductMutation } = productsApi;
4. Selectors with createSelector and memoization
Without memoization, every useSelector call recomputes derived data on every render, even when the relevant input data has not changed. createSelector from Redux Toolkit solves this through memoization: the selector remembers its last result and only recomputes when at least one of its input selectors returns a changed value. For complex derivations such as filtered and sorted lists, that is the difference between a rerender in microseconds and a noticeable stutter with larger data sets.
A common mistake is writing selectors that return a new object reference, for example state => ({ items: state.cart.items }). This object literal produces a new reference on every call, which makes React Redux treat every render as a change and renders createSelector's memoization useless. The fix is either to return primitive values or to memoize the entire derived object itself through createSelector, so a new reference is only created on an actual change.
// features/cartSelectors.js — createSelector memoizes derived state
import { createSelector } from '@reduxjs/toolkit';
const selectCartItems = (state) => state.cart.items;
const selectCategoryFilter = (state) => state.filters.category;
// Recomputes only when selectCartItems or selectCategoryFilter actually change
export const selectFilteredCartTotal = createSelector(
[selectCartItems, selectCategoryFilter],
(items, category) =>
items
.filter((item) => !category || item.category === category)
.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
5. Entity Adapter: managing normalized data
Lists of entities, such as products, users or orders, are stored as a plain array in many Redux stores. That leads to linear search cost when updating single entries and to unnecessarily complex code for ID based access. createEntityAdapter automatically normalizes such collections into the shape { ids: [], entities: {} }, turning access to a single element by ID into a constant time object lookup instead of a linear array scan.
Entity Adapter also generates ready made reducer functions such as addOne, updateOne, removeMany and upsertMany, which plug directly into the reducers definition of a createSlice call. Combined with RTK Query, the adapter can even be used directly inside the transformResponse function of a query, so server data already arrives normalized in the cache before a component fetches it through a generated selector.
// features/productsSlice.js — Entity Adapter normalizes list state
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
const productsAdapter = createEntityAdapter({
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
const productsSlice = createSlice({
name: 'products',
initialState: productsAdapter.getInitialState({ status: 'idle' }),
reducers: {
productUpserted: productsAdapter.upsertOne,
productsReceived: productsAdapter.upsertMany,
productRemoved: productsAdapter.removeOne,
},
});
// Generated selectors give O(1) lookups instead of array scans
export const {
selectAll: selectAllProducts,
selectById: selectProductById,
selectIds: selectProductIds,
} = productsAdapter.getSelectors((state) => state.products);
export const { productUpserted, productsReceived, productRemoved } = productsSlice.actions;
export default productsSlice.reducer;
6. Async flows: createAsyncThunk versus RTK Query
Before RTK Query existed, createAsyncThunk was the standard way to handle async logic in Redux Toolkit, and for cases beyond plain data fetching it remains relevant, for example multi step workflows, file uploads with progress tracking, or actions that orchestrate several API calls. createAsyncThunk automatically generates three action types, pending, fulfilled and rejected, which a slice can react to via extraReducers to model loading state, result and error in the store.
For pure server state management, meaning loading, caching and updating data from a REST or GraphQL endpoint, RTK Query is by far the better choice today, because it brings deduplication, cache invalidation and polling without extra code. The rule of thumb in modern Redux Toolkit projects is: RTK Query for anything that comes from a data source and needs to stay in sync, createAsyncThunk for business logic that is asynchronous but does not need a classic cache.
7. TypeScript integration: typed slices and hooks
Redux Toolkit was designed with TypeScript in mind from the start, and createSlice infers most types automatically from the initialState object, so almost no manual type annotations are needed inside reducer functions. For actions with a payload, PayloadAction<Type> is enough as the type for the action parameter, so the compiler flags incorrect payload shapes as soon as the reducer is written, long before a test fails.
For the store itself, it is recommended to derive RootState and AppDispatch from configureStore and export typed versions of useSelector and useDispatch. This pattern avoids having to specify the state type manually in every component, and ensures that refactorings in the store immediately surface as compile errors in every affected component, instead of showing up later as undefined errors at runtime.
// app/store.ts — typed store setup for React and Redux Toolkit
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from '../features/cartSlice';
import { productsApi } from '../features/apiSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
[productsApi.reducerPath]: productsApi.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(productsApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// hooks.ts — typed hooks, use these everywhere instead of plain useSelector/useDispatch
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
8. Middleware and DevTools: debugging and extensibility
configureStore enables a set of middleware checks by default that surface typical Redux mistakes early during development. The immutability middleware detects accidental direct state mutations outside of Immer contexts, the serializable check warns when non serializable values such as class instances or promises end up in state or in actions, which would later make persistence or time travel debugging impossible.
For custom cross cutting concerns, such as central logging, analytics tracking or automatically attaching auth tokens to certain actions, custom middleware can be added via getDefaultMiddleware().concat(customMiddleware). The Redux DevTools extension is enabled by default with configureStore and allows time travel debugging, rewinding individual actions and exporting complete action histories, which helps enormously when reproducing hard to pin down bugs.
9. Redux Toolkit compared to other approaches
Today, Redux Toolkit competes less with classic Redux, which is now considered outdated, and more with lighter libraries such as Zustand or Jotai as well as pure server state tooling like TanStack Query. The table below places Redux Toolkit against the criteria that decide this choice in practice.
| Criterion | Redux Toolkit | Zustand | TanStack Query alone |
|---|---|---|---|
| Boilerplate per feature | Low thanks to createSlice, but store setup required | Minimal, one hook per store | Minimal, only for server state |
| Server state caching | Built in through RTK Query | Not built in, external solution required | Core feature of the library |
| DevTools and time travel | Complete, production ready | Rudimentary via middleware | Own panel, focused on queries |
| Fit for large teams | Conventions enforce consistency | Requires own conventions | Good, but only for server state |
| Learning curve | Higher, store, slices, provider | Very low | Low for data fetching |
In practice, the deciding factors are usually team size and the amount of shared client state: small teams with little complex client state often do better with Zustand plus TanStack Query, while large teams with many slices benefit from Redux Toolkit's enforced conventions and mature DevTools.
Mironsoft
React architecture, state management and modern frontend infrastructure
A Redux store that grows instead of slowing you down?
We analyze existing Redux stores, migrate legacy Redux to Redux Toolkit and introduce RTK Query for your server state, with clean slices, type safe selectors and a DevTools friendly architecture.
Redux migration
Migrate legacy Redux to createSlice and Entity Adapter without a big bang rewrite
RTK Query rollout
Replace manual thunks with declarative API slices and cache invalidation
TypeScript hardening
Typed hooks, RootState derivation and compile time safety across the whole store
10. Summary
Redux Toolkit solves the classic Redux boilerplate problem with createSlice, which bundles action types, action creators and reducers into a single declaration and, thanks to Immer, allows direct mutations in reducer code. RTK Query fully takes over server state management, including deduplication, tag based cache invalidation and automatic refetching, which often makes separate data fetching libraries unnecessary. Entity Adapter normalizes list data for constant time access by ID, createSelector memoizes expensive derivations and prevents unnecessary rerenders.
For teams with large, shared client state and a need for enforced conventions, Redux Toolkit remains the most robust choice among state management options for React, precisely because DevTools, middleware and TypeScript integration ship production ready out of the box. Smaller applications with manageable client state, on the other hand, often move faster with leaner alternatives such as Zustand combined with TanStack Query.
Redux Toolkit: the essentials at a glance
createSlice
Bundles action types, action creators and reducer. Thanks to Immer, direct mutations in reducer code are allowed and still immutable underneath.
RTK Query
Declarative API slices with generated hooks, tag based cache invalidation and automatic refetching, replaces manual thunks.
Entity Adapter & selectors
createEntityAdapter normalizes lists for O(1) access, createSelector memoizes expensive derivations.
TypeScript & DevTools
Typed hooks via RootState and AppDispatch, DevTools with time travel debugging enabled from the start.