I recently came to the conclusion that React container components do not scale well. we usually put the business logic in Container components, which eventually grow into very large containers with too many responsibilities. so it's very difficult to maintain, refactor, and work with.
There are numerous approaches to writing maintainable React components. But for the purposes of this article, I'm leaning toward the useState -> useReducer -> Domain Hook path.
The useReducer hook is an alternative to the useState hook in appropriate situation. Let's see how to component transition from useState to useReducer and then useReducer in the next level with a custom hook also how React can help us solve this problem without the use of any additional libraries.
Domain Component Objectives:
Consider managing a simple inventory system. Here we are more interested in building product list components with minimal functionality such as marking as sold out, adding stock, sorting by title, and rating, and when one of the products is completely sold out, the render stock availability is red badge(Not Available) otherwise it will be green badge (Available).
With useState:
Here is ProductList component created using React.useState
import React from "react";
import { mockProducts, Product } from "../products-mock";
export function ProductList() {
const [products, setProducts] = React.useState(mockProducts);
const handleSortBy = (by: "rating" | "title") => {
let sortedProducts;
if (by === "rating") {
sortedProducts = [...products].sort((a, b) => a[by] - b[by]);
} else {
sortedProducts = [...products].sort((a, b) => a[by].localeCompare(b[by]));
}
setProducts(sortedProducts);
};
const isProductsInStock = products.every((p) => p.stock > 0);
const handleSoldOut = (id: number) => {
const results = products.map((p) => {
if (p.id === id) {
return {
...p,
stock: 0,
};
}
return p;
});
setProducts(results);
};
const handleAddStock = (id: number, stock: number) => {
const results = products.map((p) => {
if (p.id === id) {
return {
...p,
stock: stock,
};
}
return p;
});
setProducts(results);
};
return (
<div className="container">
<pre>
Product Availability :
{isProductsInStock ? " Available " : " Not Available "}
</pre>
<div className="action-buttons">
<button onClick={() => handleSortBy("rating")}>sort by rating</button>
<button onClick={() => handleSortBy("title")}>sort by name</button>
</div>
<ul className="product-list">
{products.map((product) => {
return (
<li className="product-list__item">
<span>
{product.title} - ( {product.stock} )
</span>
<button onClick={() => handleSoldOut(product.id)}>Sold out</button>
<button
onClick={() =>
handleAddStock(product.id, Math.ceil(Math.random() * 50 + 50))
}
>
Add Stock
</button>
</li>
);
})}
</ul>
</div>
);
}
Keep in mind that all logic is close to the component and reveals how we manipulate the model within the component itself.
Pros:
- Basic features are simple to implement.
Cons:
- The component became fat as a result of state management within the component.
- Separate of concerns
- Difficult to manage dependent states such as isLoading, success, failure while fetching Product List, and so on.
- With independent state variables, a complex operation like filtering based on other state variables is overly complex.
- Undo/Redo functions
With useReducer:
The useReducer hook accepts a reducer type (state, action) => newState
and returns a state object paired with a dispatch method much like Redux.
import React from "react";
import { mockProducts, Product } from "../products-mock";
type ProductListState = {
products: Product[];
};
enum ProductListActionKind {
SORT_BY_RATING = "SORT_BY_RATING",
SORT_BY_NAME = "SORT_BY_NAME",
PRODUCTS_IN_STOCKS = "PRODUCTS_IN_STOCKS",
PRODUCT_SOLD_OUT = "PRODUCT_SOLD_OUT",
ADD_STOCK_PRODUCT = "ADD_STOCK_PRODUCT",
}
type ProductListAction = {
type: ProductListActionKind;
};
function productListReducer(
state: ProductListState,
action: ProductListAction
) {
switch (action.type) {
case ProductListActionKind.SORT_BY_RATING: {
return {
...state,
products: [...state.products].sort((a, b) => a.rating - b.rating),
};
}
case ProductListActionKind.SORT_BY_NAME: {
return {
...state,
products: [...state.products].sort((a, b) =>
a.title.localeCompare(b.title)
),
};
}
case ProductListActionKind.PRODUCT_SOLD_OUT: {
return {
...state,
products: state.products.map((p) => {
if (p.id === action.payload.id) {
return {
...p,
stock: 0,
};
}
return p;
}),
};
}
case ProductListActionKind.ADD_STOCK_PRODUCT: {
return {
...state,
products: state.products.map((p) => {
if (p.id === action.payload.id) {
return {
...p,
stock: action.payload.stock,
};
}
return p;
}),
};
}
default:
return state;
}
}
export function ProductList() {
const [state, dispatch] = React.useReducer(productListReducer, {
products: mockProducts,
});
const isProductsInStock = state.products.every((p) => p.stock > 0);
return (
<div className="container">
<pre>
Product Availability :
{isProductsInStock ? " Available " : " Not Available "}
</pre>
<div className="action-buttons">
<button
onClick={() =>
dispatch({ type: ProductListActionKind.SORT_BY_RATING })
}
>
sort by rating
</button>
<button
onClick={() => dispatch({ type: ProductListActionKind.SORT_BY_NAME })}
>
sort by name
</button>
</div>
<ul className="product-list">
{state.products.map((product) => {
return (
<li className="product-list__item">
<span>
{product.title} - ( {product.stock} )
</span>
<button
onClick={() =>
dispatch({
type: ProductListActionKind.PRODUCT_SOLD_OUT,
payload: {
id: product.id,
},
})
}
>
Sold out
</button>
<button
onClick={() =>
dispatch({
type: ProductListActionKind.ADD_STOCK_PRODUCT,
payload: {
id: product.id,
stock: Math.ceil(Math.random() * 50 + 50),
},
})
}
>
Add Stock
</button>
</li>
);
})}
</ul>
</div>
);
}
pros:
- Isolate state management with reducer functions
- The component appears to be cleaner than before.
- Undo/Redo capability
cons:
- There is no way to write a faster component because there is a lot of boilerplate code, such as constants/enum for action type.
Introduce Custom Hook with abstraction:
Abstraction: we extracted all business logic into custom hooks called useProducts along with useReducer hooks, and now the client doesn't have to worry about constant types, importing all the action types inside the component. Also it's not necessary to understand the underlying implementation. It reduces the possibility of errors during dispatch type construction and we can refactor reducer function without worrying too much about the consumer of useProducts hook.
We abstracted dispatch method with a nice set of API methods exposed, and we can test the custom hooks separately.
src/shared/hooks/useProducts.js
export function useProduct() {
const [{ products }, dispatch] = React.useReducer(productListReducer, {
products: mockProducts,
});
const sortBy = (by: "rating" | "title") => {
const type =
by === "rating"
? ProductListActionKind.SORT_BY_RATING
: ProductListActionKind.SORT_BY_NAME;
dispatch({ type });
};
const isProductsInStock = () => products.every((p) => p.stock > 0);
const setSoldOut = (id: number) =>
dispatch({ type: ProductListActionKind.PRODUCT_SOLD_OUT, payload: { id } });
const addStock = (id: number, stock: number) =>
dispatch({
type: ProductListActionKind.ADD_STOCK_PRODUCT,
payload: { id, stock },
});
return {
products,
sortBy,
isProductsInStock,
setSoldOut,
addStock,
};
}
Encapsulation: Much cleaner than before, capable of managing complex state through useReducer and abstracting useReducer's functions like dispatch. In addition, I encapsulated all of the behaviour under a custom hook with useful methods like sortBy, setSoldOut, and addStock.
import React from "react";
import { useProduct } from "../hooks/useProduct";
export function ProductList() {
const { products, sortBy, isProductsInStock, setSoldOut, addStock } =
useProduct();
return (
<div className="container">
<pre>
Product Availability :
{isProductsInStock() ? " Available " : " Not Available "}
</pre>
<div className="action-buttons">
<button onClick={() => sortBy("rating")}>sort by rating</button>
<button onClick={() => sortBy("title")}>sort by name</button>
</div>
<ul className="product-list">
{products.map((product) => {
return (
<li className="product-list__item">
<span>
{product.title} - ( {product.stock} )
</span>
<button onClick={() => setSoldOut(product.id)}>Sold out</button>
<button
onClick={() =>
addStock(product.id, Math.ceil(Math.random() * 50 + 50))
}
>
Add Stock
</button>
</li>
);
})}
</ul>
</div>
);
}
Dealing with the custom hook's performance issue:
So far, so good. But what if we need to pass these methods to the down in the component? Yes, it will re-render an unnecessary. How do you handle unnecessary re-rendering? Here React.useCallback and React.useMemo come in handy.
// omitted unwanted snippet
export function useProduct() {
const [{ products }, dispatch] = React.useReducer(productListReducer, {
products: mockProducts,
});
const sortBy = useCallback((by: "rating" | "title") => {
const type =
by === "rating"
? ProductListActionKind.SORT_BY_RATING
: ProductListActionKind.SORT_BY_NAME;
dispatch({ type, payload:{}});
}, []);
const isProductsInStock = useMemo(() => {
return products.every((p) => p.stock > 0);
}, [products]);
const setSoldOut = useCallback((id: number) => {
dispatch({ type: ProductListActionKind.PRODUCT_SOLD_OUT, payload: { id } });
}, []);
const addStock = useCallback((id: number, stock: number) => {
dispatch({
type: ProductListActionKind.ADD_STOCK_PRODUCT,
payload: { id, stock },
});
}, []);
return [
{
products,
isProductsInStock,
},
{
sortBy,
setSoldOut,
addStock,
},
];
}
Custom Hook Expert Level:
The pattern below is an excellent way to avoid using useCallback multiple times with useMemo; it is more concise and cleaner than using useCallback multiple times.
const handlers = React.useMemo(
() => ({
sortBy(by: "rating" | "title") {...},
setSoldOut(id: number) {...},
addStock(id: number, quantity: number) {...},
}),
[]
);
// omitted unwanted code
export function useProduct() {
const [{ products }, dispatch] = React.useReducer(productListReducer, {
products: mockProducts,
});
const isProductsInStock = useMemo(() => {
return products.every((p) => p.stock > 0);
}, [products]);
const handlers = useMemo(
() => ({
sortBy(by: "rating" | "title") {
const type =
by === "rating"
? ProductListActionKind.SORT_BY_RATING
: ProductListActionKind.SORT_BY_NAME;
dispatch({ type, payload: {} });
},
setSoldOut(id: number) {
dispatch({
type: ProductListActionKind.PRODUCT_SOLD_OUT,
payload: { id },
});
},
addStock(id: number, quantity: number) {
dispatch({
type: ProductListActionKind.ADD_STOCK_PRODUCT,
payload: { id, stock: quantity },
});
},
}),
[]
);
return [
{ products, isProductsInStock },
handlers
] as const;
}
Bonus:
How do we take our useProduct
custom hook to the next level?
What if we need to create a CategoryList
with useCategory
custom hook with operations such as addCategory, removeCategory, and updateCategory, sortByCategory and so on ? that is very similar to a ProductList
and useProducts
You may encounter code duplication because the same data structures are used in underlying. so it's time to create a new custom hook!, let's call it useArray with these features add, remove, update and sortBy and so on.
const [data, {add, update, remove, sortBy}] = useArray(initialState)
We can now remove the reducer function
and its implementation from the useProduct
and useCategory
custom hook, making both domain custom hooks and components more concise.
Wrap up:
Custom hooks are a natural way to encapsulate and abstract business logic, and they allow us to create very powerful abstractions for React applications.
Use your imagination to take it to the next level.
Good luck!
Top comments (0)