Before You Write a Single Component, we build the store. So go head and create folder name store under src folder in your app
Why a Folder Called store?
When a new developer joins your project, they need to find things fast. The folder name store is a signal. It says: everything inside here is Redux. State lives here. Logic lives here. UI does not.
You'll never find JSX in store/. You'll never find API calls or styling. Just state shape, transitions, and types. That discipline, keeping concerns separated is what makes large codebases survivable.
Two files live here right now: so go ahead and create these 2 files.
store/
├── store.ts
└── cart-slice.ts
They'll grow across this series. But the boundary never moves.
Check out the full implementation and give the repo a star on redux-simplified
Start With the Shape
Slice
A slice is a self-contained piece of your Redux state. It holds three things together in one file, the state shape, the initial state, and the reducers that change it.
We named it cart-slice.ts because it owns exactly one slice of the global store, the cart. Nothing else. The naming convention [feature]-slice.ts is intentional. When your app grows and you have user-slice.ts, orders-slice.ts, payments-slice.ts you know exactly what each file owns just by reading the name.
Open cart-slice.ts
Before you write any logic, you need to answer one question: what does the cart actually look like?
1. The Shape
export type CartItem = {
id: string;
title: string;
price: number;
quantity: number;
}
type CartState = {
items: CartItem[];
}
This is your contract. Every item in the cart must have these four fields.
Notice CartItem is exported, CartState is not. CartItem will be used by components later, they'll need to know the shape of what they're rendering. CartState is internal. It's the store's concern, not the UI's.
2. The Empty Cart
const initialState: CartState = {
items: []
};
What does the app look like the very first time it loads?
Redux always needs an initial state because on first load, there's nothing in the store yet. The reducer fires with undefined as the current state, and initialState is what it falls back to.
3. CartSlice
createSlice from Redux Toolkit bundles three things together, the name, the initial state, and the reducers.
export const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addToCart(...),
removeFromCart(...)
}
});
The name field matters more than it looks. Redux Toolkit uses it to generate action type strings automatically. So when addToCart fires, the action type becomes "cart/addToCart".
4. Reducers
Every reducer function in Redux receives two parameters automatically.
state is the current value of the cart. Redux passes it in for you. You never call this function yourself and pass state manually. Redux does that. Your job is just to use it.
action is the object that was dispatched. It always has two things: a type (the action name, like "cart/addToCart") and a payload (the data that came with it). When a component calls dispatch(addToCart(product)), that product becomes action.payload.
4.1 addToCart
When someone clicks "Add to Cart" button, one of two things is true:
This product is not in the cart yet. Add it with quantity 1.
This product is already in the cart. Just increment the quantity.
addToCart(state, action: PayloadAction<CartItem>) {
const existingIndex = state.items.findIndex(
(item) => item.id === action.payload.id
);
if (existingIndex >= 0) {
state.items[existingIndex].quantity++;
}
else {
state.items.push({ ...action.payload, quantity: 1 });
}
}
You'll notice state.items.push(...) and quantity++. That looks like mutation. In plain JavaScript, that would be illegal in a Redux reducer, you're supposed to return new state, never modify existing state.
But Redux Toolkit uses a library called Immer under the hood. Immer watches your "mutations", intercepts them, and produces a new immutable state object behind the scenes. So you can write readable code.
PayloadAction is a TypeScript type that comes from Redux Toolkit. It types the action parameter so TypeScript knows exactly what shape the payload will be.
<CartItem> is the generic. It tells PayloadAction the payload is specifically a CartItem. So when you write action.payload.id or action.payload.price
4.2 removeFromCart
Two Cases Again, remove works the mirror way:
removeFromCart(state, action: PayloadAction<{ id: string }>) {
const existingIndex = state.items.findIndex(
(item) => item.id === action.payload.id
);
if (existingIndex >= 0) {
if (state.items[existingIndex].quantity === 1) {
state.items.splice(existingIndex, 1);
}
else {
state.items[existingIndex].quantity--;
}
}
}
If quantity is 1 and the user clicks minus, remove the item entirely. If it's more than 1, just decrement. Splice method removes 1 item from the array at the given index.
5. Export the Actions
export const { addToCart, removeFromCart } = cartSlice.actions;
Redux Toolkit generated these action creators automatically when you defined the reducers. This line just pulls them out so components can import and dispatch them. When a component calls dispatch(addToCart(product)), it's calling one of these.
Let's see how your complete cart-slice.ts file looks like:
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export type CartItem = {
id: string;
title: string;
price: number;
quantity: number;
}
type CartState = {
items: CartItem[];
}
const initialState: CartState = {
items: [],
};
export const cartSlice = createSlice({
name: "cart",
initialState: initialState,
reducers: {
addToCart(state, action : PayloadAction<{id: string, title: string, price: number;}>) {
const itemIndex = state.items.findIndex((item) => item.id === action.payload.id);
if(itemIndex >= 0) {
state.items[itemIndex].quantity++;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeFromCart(state, action : PayloadAction<string>) {
const itemIndex = state.items.findIndex((item) => item.id === action.payload);
if(state.items[itemIndex].quantity === 1) {
state.items.splice(itemIndex, 1);
}
else {
state.items[itemIndex].quantity--;
}
},
},
});
export const { addToCart, removeFromCart } = cartSlice.actions;
Now Build the Store
store.ts has one job: take all your slices and combine them into one global store.
export const store = configureStore({
reducer: {
cart: cartSlice.reducer
},
});
Right now there's only one slice. That's fine. As the app grows, user slice, orders slice, they each get a key here. The store stays in one place.
The Two Types You'll Use Everywhere
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
RootState for reading: Every component that reads from the store needs to know what's in it. This is that knowledge.
AppDispatch for writing: Every component that sends actions needs this to stay type-safe.
Here's complete content of file store.ts
import { configureStore } from "@reduxjs/toolkit";
import { cartSlice } from "./cart-slice";
export const store = configureStore({
reducer: {
cart: cartSlice.reducer
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
If you want to see the 'moving parts' in action, the code is waiting for you on redux-simplified
Previous: Part 1: Redux fundamentals