Redux Toolkit from First Slice to Async Data: A Working Mental Model
Learn how Redux Toolkit's store, slices, Immer-powered reducers, selectors and createAsyncThunk fit together, using a counter, a shopping cart and a product list.
This article is published in English.
Sources: Redux Toolkit: A Complete Guide from Basics to Production
As a React app grows, shared state such as the user, the cart, preferences, filters and API data spreads across many screens, and passing it all through props becomes fragile. Redux solves this with a central store, and Redux Toolkit (RTK) is the recommended way to write Redux today. This guide walks from a counter slice to a cart with derived totals and an API-backed list, so you can read and write RTK code confidently.
First confirm you need a store at all: prop drilling alone is not a reason to install Redux.
The core idea behind Redux
Redux keeps shared state in one store and changes it only through actions handled by reducers, so every update is predictable. Without it, data travels down one layer of props at a time.
Component
↓
Props
↓
Component
↓
Props
↓
Component
With a store, components read what they need and dispatch actions to request changes.
Redux Store
↑
│
┌─────────┴─────────┐
│ │
Component A Component B
│ │
└──── dispatch ─────┘
What Redux Toolkit adds
RTK bundles utilities for the common Redux tasks.
Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query
Classic Redux spread one feature across many files.
Action Types
Action Creators
Reducers
Constants
Store
Middleware
createSlice() lets you express most of that in one place.
How data flows through an RTK app
Components read with useSelector() and send actions with useDispatch(); reducers produce new state.
React Application
│
↓
useSelector()
│
↓
Redux Store
↑
│
useDispatch()
↑
│
Actions
│
↓
Reducer
│
↓
New State
The essential loop:
UI
↓
dispatch(action)
↓
Reducer
↓
State update
↓
Subscribed components
↓
UI re-render
Installing and creating the store
Install two packages:
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit holds the Redux APIs; react-redux connects them to React.
useSelector()
useDispatch()
Provider
The example counter app is organized by feature.
src/
│
├── app/
│ └── store.js
│
├── features/
│ └── counter/
│ └── counterSlice.js
│
├── App.jsx
└── main.jsx
configureStore() takes a map of slice reducers; each key becomes a top-level state branch.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
The resulting state:
{
counter: {
value: 0
}
}
Slices: one feature, one file
A slice owns one logical part of the state, and apps usually have several.
authSlice
cartSlice
productSlice
userSlice
notificationSlice
The counter slice declares a name, initial state and one reducer per operation, then exports the generated action creators and reducer.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
},
});
export const {
increment,
decrement,
incrementByAmount,
} = counterSlice.actions;
export default counterSlice.reducer;
One call replaces three hand-written pieces.
Action Types
+
Action Creators
+
Reducer
What createSlice generates
Take a minimal slice:
createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
},
});
RTK joins the slice name and reducer key into an action type
"counter/increment"
and creates an action creator.
increment()
So dispatch(increment()) is equivalent to dispatching:
dispatch({
type: "counter/increment",
});
Wiring the store into React
In main.jsx, Provider makes the store available to every component below it.
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App";
import { store } from "./app/store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={store}>
<App />
</Provider>
);
Reading with useSelector
useSelector() picks exactly the piece of state a component needs.
import { useSelector } from "react-redux";
function Counter() {
const count = useSelector(
state => state.counter.value
);
return <h1>{count}</h1>;
}
The component re-renders only when that value changes; see how React-Redux decides to re-render.
Writing with useDispatch
useDispatch() returns the store's dispatch.
import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";
function Counter() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
);
}
A click then flows like this:
Button Click
↓
dispatch(increment())
↓
Action
↓
Reducer
↓
State changes
↓
useSelector detects relevant change
↓
Component re-renders
Passing data with payload
Actions carry data in payload, read from the reducer's second argument:
incrementByAmount(state, action) {
state.value += action.payload;
}
The caller passes the value:
dispatch(incrementByAmount(10));
producing this action:
{
type: "counter/incrementByAmount",
payload: 10
}
Every action has a type and optionally a payload.
action
│
├── type
└── payload
Why mutating state is allowed here
A classic interview question: this reducer seems to mutate state, which Redux forbids.
increment(state) {
state.value += 1;
}
RTK runs reducers through Immer, which passes in a draft proxy, records changes and produces a new immutable object.
Reducer
↓
Immer Proxy
↓
Track changes
↓
Create new immutable state
You get readable code without breaking immutability. Either modify the draft or return a new value, never both, and do not mutate like this outside RTK reducers.
A shopping cart slice
An e-commerce cart starts empty.
const initialState = {
items: [],
};
The slice adds, removes and clears items; removeItem assigns a new array, which Immer also accepts.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
items: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem(state, action) {
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
},
clearCart(state) {
state.items = [];
},
},
});
export const {
addItem,
removeItem,
clearCart,
} = cartSlice.actions;
export default cartSlice.reducer;
Adding an item
Given a product object:
const product = {
id: 101,
name: "Laptop",
price: 60000,
};
a click handler dispatches it:
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
and the whole product becomes the payload.
{
type: "cart/addItem",
payload: {
id: 101,
name: "Laptop",
price: 60000
}
}
The reducer then pushes it onto:
state.items
Removing an item
Removal filters by id:
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
}
The component passes only the id:
dispatch(removeItem(101));
so inside the reducer:
action.payload === 101
Deriving values instead of storing them
Suppose the cart holds these entries:
[
{
price: 100,
quantity: 2,
},
{
price: 50,
quantity: 3,
}
]
The total follows directly:
100 × 2 + 50 × 3
= 200 + 150
= 350
Storing the total next to the items is tempting:
{
items,
total
}
but then every reducer must keep both in sync. Compute it instead:
const selectCartTotal = state =>
state.cart.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
and read it:
const total = useSelector(selectCartTotal);
Store the source of truth; derive everything computable from it.
Selectors as a single access point
A selector is a function that reads state.
const selectCartItems = state =>
state.cart.items;
Components use it instead of knowing the state shape.
const items = useSelector(selectCartItems);
Otherwise this path repeats everywhere:
state.cart.items
With a selector, restructuring touches one function.
Memoizing with createSelector
createSelector() caches a derived result until its inputs change.
import { createSelector } from "@reduxjs/toolkit";
const selectCartItems = state =>
state.cart.items;
export const selectCartTotal = createSelector(
[selectCartItems],
items =>
items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
)
);
The logic is simple:
Input unchanged
↓
Return cached result
Input changed
↓
Recalculate
This helps with expensive calculations and with selectors returning new arrays or objects, which would otherwise re-render on every store update.
Loading data with createAsyncThunk
Apps call APIs such as:
GET /api/products
A request goes from idle through loading to success
idle
↓
loading
↓
success
or to an error.
idle
↓
loading
↓
error
createAsyncThunk() models this: give it a type prefix and an async function; throwing marks the request failed.
import {
createAsyncThunk,
createSlice,
} from "@reduxjs/toolkit";
export const fetchProducts = createAsyncThunk(
"products/fetchProducts",
async () => {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
);
It generates three lifecycle actions:
fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected
Handling the lifecycle in a slice
The products slice tracks items, status and error, updating them in extraReducers.
const productsSlice = createSlice({
name: "products",
initialState: {
items: [],
status: "idle",
error: null,
},
reducers: {},
extraReducers: builder => {
builder
.addCase(
fetchProducts.pending,
state => {
state.status = "loading";
}
)
.addCase(
fetchProducts.fulfilled,
(state, action) => {
state.status = "success";
state.items = action.payload;
}
)
.addCase(
fetchProducts.rejected,
(state, action) => {
state.status = "failed";
state.error =
action.error.message;
}
);
},
});
reducers versus extraReducers
reducers defines the slice's own actions, with generated creators.
reducers: {
addItem() {}
}
extraReducers responds to actions defined elsewhere, such as those from createAsyncThunk():
pending
fulfilled
rejected
The slice only listens to them.
Dispatching the thunk from a component
Start the request on mount:
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
Strict Mode runs effects twice in development, so expect two requests there. Then select the state
const {
items,
status,
error,
} = useSelector(
state => state.products
);
and render by status:
if (status === "loading") {
return <p>Loading...</p>;
}
if (status === "failed") {
return <p>{error}</p>;
}
return (
<div>
{items.map(product => (
<div key={product.id}>
{product.name}
</div>
))}
</div>
);
For heavier data fetching, RTK Query can replace this pattern.
Middleware and configureStore
Middleware sits between dispatch() and the reducer.
dispatch(action)
↓
Middleware
↓
Reducer
↓
New State
Common uses:
- Async logic
- Logging
- Analytics
- Authentication flows
- Side effects
- Intercepting actions
configureStore() replaces manual setup, adding default middleware (including thunks) and Redux DevTools.
const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
products: productsReducer,
},
});
Key takeaways
- A slice bundles state, reducers and generated action creators for one feature.
- Immer makes mutation-style reducers safe, but only inside RTK reducers.
- Store minimal state; derive the rest with selectors, memoized when costly.
createAsyncThunk()plusextraReducersmodels loading, success and failure.configureStore()defaults cover most apps.