[React Redux] Redux toolkit project notes
Config store
app/store.ts
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({ reducer: {} });
main.tsx
import React from "react";
import ReactDOM from "react-dom";
+ import { Provider } from "react-redux";
import "./index.css";
import App from "./App";
+ import { store } from "./app/store";
ReactDOM.render(
<React.StrictMode>
+ <Provider store={store}>
<App />
+ </Provider>
</React.StrictMode>,
document.getElementById("root")
);
Slices
Slice
is a concept that each slice needs to own the shape of its part of the data and is generally responsible for owning any reducers, selectors or thunks that primarily access or maniulate that information.
app/features/cart/cartSlice.ts
import { createSlice } from "@reduxjs/toolkit";
export interface CartState {
items: { [producetID: string]: number };
}
const initialState: CartState = {
items: {},
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {},
});
export default cartSlice.reducer;
app/features/products/productsSlice.ts
import { createSlice } from "@reduxjs/toolkit";
import { Product } from "../../app/api";
export interface ProductsState {
products: { [id: string]: Product };
}
const initialState: ProductsState = {
products: {},
};
const productsSlice = createSlice({
name: "products",
initialState,
reducers: {},
});
export default productsSlice.reducer;
app/store.ts
import { configureStore } from "@reduxjs/toolkit";
+ import cartReducer from "../features/cart/cartSlice";
+ import productsReducer from "../features/products/productsSlice";
export const store = configureStore({
reducer: {
+ cart: cartReducer,
+ products: productsReducer,
},
});
Type-aware hooks
app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";
import productsReducer from "../features/products/productsSlice";
export const store = configureStore({
reducer: {
cart: cartReducer,
products: productsReducer,
},
});
+ export type RootState = ReturnType<typeof store.getState>;
+ export type AppDispatch = typeof store.dispatch;
app/hooks.ts
import { TypedUseSelectorHook, useSelector, useDispatch } from "react-redux";
import { AppDispatch, RootState } from "./store";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
update to use useAppSelector
in app/features/products/Products.tsx
- import React, { useEffect, useState } from "react";
- import { getProducts, Product } from "../../app/api";
+ import React from "react";
import styles from "./Products.module.css";
+ import { useAppSelector } from "../../app/hooks";
export function Products() {
+ const products = useAppSelector((state) => state.products.products);
- const [products, setProducts] = useState<Product[]>([]);
- useEffect(() => {
- getProducts().then((products) => {
- setProducts(products);
- });
- }, []);
return (
<main className="page">
<ul className={styles.products}>
- {products.map((product) => (
+ {Object.values(products).map((product) => (
<li key={product.id}>
<article className={styles.product}>
<figure>
<img src={product.imageURL} alt={product.imageAlt} />
<figcaption className={styles.caption}>
{product.imageCredit}
</figcaption>
</figure>
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
<button>Add to Cart 🛒</button>
</div>
</article>
</li>
))}
</ul>
</main>
);
}
First reducer method
app/features/products/productsSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Product } from "../../app/api";
export interface ProductsState {
products: { [id: string]: Product };
}
const initialState: ProductsState = {
products: {},
};
const productsSlice = createSlice({
name: "products",
initialState,
reducers: {
+ receivedProducts(state, action: PayloadAction<Product[]>) {
+ const products = action.payload;
+ products.forEach((product) => {
+ state.products[product.id] = product;
+ });
+ },
},
});
+ export const { receivedProducts } = productsSlice.actions;
export default productsSlice.reducer;
app/feature/Products.tsx
export function Products() {
const dispatch = useAppDispatch();
useEffect(() => {
getProducts().then((products) => {
+ dispatch(receivedProducts(products));
});
});
...
Using Adapter
import {
createSlice,
PayloadAction,
createEntityAdapter,
} from "@reduxjs/toolkit";
import { Product } from "../../app/api";
import { RootState } from "../../app/store";
export interface ProductsState {
products: { [id: string]: Product };
}
+ const productsAdapter = createEntityAdapter<Product>({
+ selectId: (product) => product.id,
+ });
const productsSlice = createSlice({
name: "products",
+ initialState: productsAdapter.getInitialState(),
reducers: {
receivedProducts(state, action: PayloadAction<Product[]>) {
const products = action.payload;
+ productsAdapter.setAll(state, products);
},
},
});
+ const productsSelector = productsAdapter.getSelectors<RootState>(
+ (state) => state.products
+ );
+ export const { selectAll } = productsSelector;
export const { receivedProducts } = productsSlice.actions;
export default productsSlice.reducer;
app/feature/Products.tsx
import React, { useEffect } from "react";
import styles from "./Products.module.css";
import { receivedProducts } from "./productsSlice";
import * as productSlice from "./productsSlice";
import { useAppSelector, useAppDispatch } from "../../app/hooks";
import { getProducts } from "../../app/api";
export function Products() {
const dispatch = useAppDispatch();
useEffect(() => {
getProducts().then((products) => {
dispatch(receivedProducts(products));
});
});
+ const products = useAppSelector(productSlice.selectAll);
return (
<main className="page">
<ul className={styles.products}>
+ {products.map((product) => {
return (
product && (
<li key={product.id}>
<article className={styles.product}>
<figure>
<img src={product.imageURL} alt={product.imageAlt} />
<figcaption className={styles.caption}>
{product.imageCredit}
</figcaption>
</figure>
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
<button>Add to Cart 🛒</button>
</div>
</article>
</li>
)
);
})}
</ul>
</main>
);
}
Another flow example
app/features/cart/cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
export interface CartState {
items: { [producetID: string]: number };
}
const initialState: CartState = {
items: {},
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addToCart(state, action: PayloadAction<string>) {
const id = action.payload;
if (state.items[id]) {
state.items[id]++;
} else {
state.items[id] = 1;
}
},
},
});
export const { addToCart } = cartSlice.actions;
export default cartSlice.reducer;
export function getNumItems(state: RootState) {
let numItems = 0;
for (let id in state.cart.items) {
numItems += state.cart.items[id];
}
return numItems;
}
app/features/products/Products.tsx
import { addToCart } from "../cart/cartSlice";
...
<button onClick={() => dispatch(addToCart(product.id))}>Add to Cart 🛒</button>;
app/features/products/Products.tsx
import React from "react";
import { Link } from "react-router-dom";
import styles from "./CartLink.module.css";
+ import { getNumItems } from "./cartSlice";
+ import { useAppSelector } from "../../app/hooks";
export function CartLink() {
+ const numItems = useAppSelector(getNumItems);
return (
<Link to="/cart" className={styles.link}>
<span className={styles.text}>
+ 🛒 {numItems ? numItems : "Cart"}
</span>
</Link>
);
}
createSelector
In previous section we wrote
export function getNumItems(state: RootState) {
let numItems = 0;
for (let id in state.cart.items) {
numItems += state.cart.items[id];
}
return numItems;
}
This function actually get called whenever store get updated. But we only want to call this function when items in cart changes.
app/features/cart/cartSlice.ts
import { createSlice, PayloadAction, createSelector } from "@reduxjs/toolkit";
...
export const getNumItemsMemo = createSelector(
(state: RootState) => state.cart.items,
(items) => {
let numItems = 0;
for (let id in items) {
numItems += items[id];
}
return numItems;
}
);
Aggregate values from multi slices
app/features/prodcuts/productsSlice.ts
+ export const { selectAll, selectEntities } = productsSelector;
app/features/cart/cartSlice.ts
export const getTotalPrice = createSelector(
(state: RootState) => state.cart.items,
productsSlice.selectEntities,
(items, products) => {
let total = 0;
for (let id in items) {
total += (products[id]?.price ?? 0) * items[id];
}
return total.toFixed(2);
}
);
extraReducers
Everything we added to reducers
will be exported to Action.
// Slices
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addToCart(state, action: PayloadAction<string>) {
const id = action.payload;
if (state.items[id]) {
state.items[id]++;
} else {
state.items[id] = 1;
}
},
removeFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
delete state.items[id];
},
updateQuantity(
state,
action: PayloadAction<{ id: string; quantity: number }>
) {
const { id, quantity } = action.payload;
state.items[id] = quantity;
},
},
});
// Actions
export const { addToCart, removeFromCart, updateQuantity } = cartSlice.actions;
So what is some action you want custom action creator or you don't want action being created automatically.
app/features/cart/cartSlice.ts
// Slices
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addToCart(state, action: PayloadAction<string>) {
const id = action.payload;
if (state.items[id]) {
state.items[id]++;
} else {
state.items[id] = 1;
}
},
removeFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
delete state.items[id];
},
updateQuantity(
state,
action: PayloadAction<{ id: string; quantity: number }>
) {
const { id, quantity } = action.payload;
state.items[id] = quantity;
},
},
+ extraReducers: function (builder) {
+ builder.addCase("cart/checkout/pending", (state, action) => {
+ state.checkoutState = "LOADING";
+ });
+ },
});
app/features/cart/Cart.tsx
function onCheckout(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
dispatch({
type: "cart/checkout/pending",
});
}
<form onSubmit={onCheckout}>
<button className={styles.button} type="submit">
Checkout
</button>
</form>;
Thunk
Redux toolkit has intergated thunk already.
app/features/cart/cartSlice.ts
extraReducers: function (builder) {
builder.addCase("cart/checkout/pending", (state, action) => {
state.checkoutState = CheckoutEnmus.LOADING;
});
builder.addCase("cart/checkout/fulfilled", (state, action) => {
state.checkoutState = CheckoutEnmus.READY;
});
},
// Thunks
export function checkout() {
return function checkoutThunk(dispatch: AppDispatch) {
dispatch({
type: "cart/checkout/pending",
});
setTimeout(() => {
dispatch({
type: "cart/checkout/fulfilled",
});
}, 3000);
};
}
app/features/cart/Cart.tsx
import {
getTotalPrice,
removeFromCart,
updateQuantity,
getCheckoutState,
CheckoutEnmus,
checkout,
} from "./cartSlice";
function onCheckout(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
dispatch(checkout());
}
<form onSubmit={onCheckout}>
<button className={styles.button} type="submit">
Checkout
</button>
</form>;
CreateAsyncThunk
The key reason to use createAsyncThunk is that it generates actions for each of the different outcomes for any promised-based async call: pending, fulfilled, and rejected. We then have to use the builder callback API on the extraReducers property to map these actions back into reducer methods we then use to update our state. It's a bit of of a process but it's simpler than the alternative, which is to create a bunch of actions by hand and manually dispatch them.
app/features/cart/cartSlice.ts
import {
createSlice,
+ createAsyncThunk,
PayloadAction,
createSelector,
} from "@reduxjs/toolkit";
import { RootState, AppDispatch } from "../../app/store";
import * as productsSlice from "../products/productsSlice";
+ import { checkout, CartItems } from "../../app/api";
export enum CheckoutEnmus {
LOADING = "LOADING",
READY = "READY",
ERROR = "ERROR",
}
type CheckoutState = keyof typeof CheckoutEnmus;
export interface CartState {
items: { [producetID: string]: number };
checkoutState: CheckoutState;
}
const initialState: CartState = {
items: {},
checkoutState: "READY",
};
// Slices
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addToCart(state, action: PayloadAction<string>) {
const id = action.payload;
if (state.items[id]) {
state.items[id]++;
} else {
state.items[id] = 1;
}
},
removeFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
delete state.items[id];
},
updateQuantity(
state,
action: PayloadAction<{ id: string; quantity: number }>
) {
const { id, quantity } = action.payload;
state.items[id] = quantity;
},
},
extraReducers: function (builder) {
+ builder.addCase(checkoutCart.pending, (state, action) => {
state.checkoutState = CheckoutEnmus.LOADING;
});
+ builder.addCase(checkoutCart.fulfilled, (state, action) => {
state.checkoutState = CheckoutEnmus.READY;
});
+ builder.addCase(checkoutCart.rejected, (state, action) => {
+ state.checkoutState = CheckoutEnmus.ERROR;
+ });
},
});
// Thunks
- export function checkout() {
- return function checkoutThunk(dispatch: AppDispatch) {
- dispatch({
- type: "cart/checkout/pending",
- });
- setTimeout(() => {
- dispatch({
- type: "cart/checkout/fulfilled",
- });
- }, 3000);
- };
- }
+ export const checkoutCart = createAsyncThunk(
+ "cart/checkout",
+ async (items: CartItems) => {
+ const response = await checkout(items);
+ return response;
+ }
+ );
// Actions
export const { addToCart, removeFromCart, updateQuantity } = cartSlice.actions;
// Selectors
export const getCart = (state: RootState) => state.cart;
export const getCartItems = createSelector(getCart, (cart) => cart.items);
export const getCartItemsIds = createSelector(getCartItems, (items) =>
Object.keys(items)
);
export const getItemCounts = createSelector(getCartItems, (items) =>
Object.values(items)
);
export const getNumItems = createSelector(getItemCounts, (counts) =>
counts.reduce((acc, count) => acc + count, 0)
);
export const getTotalPrice = createSelector(
getCartItems,
productsSlice.selectEntities,
(items, products) =>
Object.keys(items)
.reduce((total, id) => total + (products[id]?.price ?? 0) * items[id], 0)
.toFixed(2)
);
export const getCheckoutState = createSelector(
getCart,
(cart) => cart.checkoutState
);
// Reducer
export default cartSlice.reducer;
It generates promise lifecycle action types based on the action type prefix that you pass in, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.
checkoutCart.pending;
checkoutCart.fulfilled;
checkoutCart.rejected;
Error message for Async Thunk action
app/features/cart/cartSlice.ts
import {
createSlice,
createAsyncThunk,
PayloadAction,
createSelector,
} from "@reduxjs/toolkit";
import { RootState, AppDispatch } from "../../app/store";
import * as productsSlice from "../products/productsSlice";
import { checkout, CartItems } from "../../app/api";
export enum CheckoutEnmus {
LOADING = "LOADING",
READY = "READY",
ERROR = "ERROR",
}
type CheckoutState = keyof typeof CheckoutEnmus;
export interface CartState {
items: { [producetID: string]: number };
checkoutState: CheckoutState;
+ errorMessage: string;
}
const initialState: CartState = {
items: {},
checkoutState: "READY",
+ errorMessage: "",
};
// Slices
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addToCart(state, action: PayloadAction<string>) {
const id = action.payload;
if (state.items[id]) {
state.items[id]++;
} else {
state.items[id] = 1;
}
},
removeFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
delete state.items[id];
},
updateQuantity(
state,
action: PayloadAction<{ id: string; quantity: number }>
) {
const { id, quantity } = action.payload;
state.items[id] = quantity;
},
},
extraReducers: function (builder) {
builder.addCase(checkoutCart.pending, (state) => {
state.checkoutState = CheckoutEnmus.LOADING;
});
builder.addCase(checkoutCart.fulfilled, (state) => {
state.checkoutState = CheckoutEnmus.READY;
});
+ // action for rejected promise has a payload of type Error
+ builder.addCase(checkoutCart.rejected, (state, action) => {
+ state.checkoutState = CheckoutEnmus.ERROR;
+ state.errorMessage = action.error.message || "";
+ });
},
});
// Thunks
export const checkoutCart = createAsyncThunk(
"cart/checkout",
async (items: CartItems) => {
const response = await checkout(items);
return response;
}
);
// Actions
export const { addToCart, removeFromCart, updateQuantity } = cartSlice.actions;
// Selectors
export const getCart = (state: RootState) => state.cart;
export const getCartItems = createSelector(getCart, (cart) => cart.items);
export const getCartItemsIds = createSelector(getCartItems, (items) =>
Object.keys(items)
);
export const getItemCounts = createSelector(getCartItems, (items) =>
Object.values(items)
);
export const getNumItems = createSelector(getItemCounts, (counts) =>
counts.reduce((acc, count) => acc + count, 0)
);
export const getTotalPrice = createSelector(
getCartItems,
productsSlice.selectEntities,
(items, products) =>
Object.keys(items)
.reduce((total, id) => total + (products[id]?.price ?? 0) * items[id], 0)
.toFixed(2)
);
export const getCheckoutState = createSelector(
getCart,
(cart) => cart.checkoutState
);
+ export const getCartErrorMessage = createSelector(
+ getCart,
+ (cart) => cart.errorMessage
+ );
// Reducer
export default cartSlice.reducer;
`app/features/Cart.tsx``
import {
getTotalPrice,
removeFromCart,
updateQuantity,
getCheckoutState,
CheckoutEnmus,
checkoutCart,
getCartErrorMessage,
} from "./cartSlice";
...
<form onSubmit={onCheckout}>
{checkoutState === "ERROR" && errorMessage ? (
<p className={styles.errorBox}>{errorMessage}</p>
) : null}
<button className={styles.button} type="submit">
Checkout
</button>
</form>
Global State inside of Async Thunks
// export const checkoutCart = createAsyncThunk(
// "cart/checkout",
// async (items: CartItems) => {
// const response = await checkout(items);
// return response;
// }
// );
export const checkoutCart = createAsyncThunk(
"cart/checkout",
async (_, thunkAPI) => {
const state = thunkAPI.getState() as RootState;
const items = state.cart.items;
const response = await checkout(items);
return response;
}
);
From Course
git clone git@github.com:xjamundx/redux-shopping-cart.git