Tutorial: Building a Shopping Cart
This tutorial guides you through implementing a simple yet practical shopping cart feature in a React environment using the Caro-Kann library. You will learn how to efficiently manage state and easily integrate various additional functionalities through Caro-Kann’s intuitive state management approach and powerful middleware system.
This tutorial is aimed at developers with a basic understanding of React, and its goal is to help you learn the core concepts and practical usage of Caro-Kann so you can apply it to real projects.
Creating a Store
Before starting state management for an application, it’s good practice to define the structure of the data we’ll be handling. Here, we’ll create a TypeScript type to clarify the attributes of a ‘Product’ that will be in the shopping cart. Each product will have a unique id
, name
, and price
температураs mandatory, and can optionally include imageUrl
and description
. Defining types 이렇게 beforehand can reduce data-related errors during development and improve code readability and maintainability.
export type Product = {
id: string,
name: string,
price: number,
imageUrl?: string,
description?: string,
}
Next, we import the create
function from the Caro-Kann library to create a global state store. The create
function takes an initial state value as an argument and returns a custom hook that can manage that state. In this example, we set an empty array []
as the initial state, indicating that the shopping cart is initially empty. The useCart
hook, returned by the create<Array<Product>>([])
call, will now be the core interface used by all components in our application to access and modify the shopping cart state.
export const useCart = create<Array<Product>>([])
Changing Global State with useCart
Now let’s look at how to change the shopping cart state in actual components using the useCart
hook we created.
First, the ProductBinder
component receives a list of products (productList
) from the server as props and is responsible for rendering a ProductCard
component for each product. It acts as a list renderer, serving as a container to display multiple product information on the screen.
function ProductBinder({ productList }: { productList: Array<Product> }) {
const [cart, setCart] = useCart()
return (
<>
{productList.map(product => (
<ProductCard key={product.id}>
{product.imageUrl && <ProductCard.Image image={product.imageUrl}/>}
<ProductCard.Title name={product.name}/>
<ProductCard.Description description={product.description}/>
<ProductCard.Price price={product.price}/>
<button onClick={() => setCart(prev => [...prev, product])}>Add to Cart</button>
</ProductCard>
))}
</>
)
}
Calling the useCart()
hook inside this component returns a tuple containing the current cart state (cart
) and a function to change the state (setCart
), similar to React’s useState
hook.
const [cart, setCart] = useCart()
In the onClick
handler of the “Add to Cart” button, the setCart
function is used. It employs a functional update approach (setCart(prev => [...prev, product])
), taking the previous state (prev
) and returning a new array with the new product (product
) added to the end. This is a good pattern for maintaining state immutability and helps React detect changes and update the UI efficiently.
However, looking at the current implementation of the ProductBinder
component, it doesn’t display the contents of the cart. That is, it doesn’t need to read the cart
state value directly; it only performs the function of adding items to the cart via the setCart
function. In such cases, optimization can be considered to prevent the component from unnecessarily re-rendering whenever the cart state changes.
Caro-Kann provides the writeOnly
method built into the useCart
hook for such situations. Using useCart.writeOnly()
retrieves only the state-modifying function (corresponding to setCart
) without fetching the state value itself.
const setCart = useCart.writeOnly()
This allows the ProductBinder
component to not subscribe to changes in the current state of the cart, enabling it to solely perform the role of adding products while avoiding unnecessary re-renders. This is a useful optimization technique that can contribute to performance improvements, especially when state changes frequently or many components share the same state.
function ProductBinder({ product }: { product: Product }) {
const setCart = useCart.writeOnly()
return (
<>
{productList.map(product => (
<ProductCard key={product.id}>
{product.imageUrl && <ProductCard.Image image={product.imageUrl}>}
<ProductCard.Title name={product.name}>
<ProductCard.Description description={product.description}>
<ProductCard.Price price={product.price}>
<button onClick={() => setCart(prev => [...prev, product])}>Add to Cart</button>
</ProductCard>
))}
</>
)
}
Managing Derived State with readOnly method
The application’s Header component plays an important role in visually showing the user the total number of products currently in the shopping cart. To implement this feature, we utilize the readOnly
method of the useCart
hook.
The readOnly
method is a powerful feature that allows access to the current value of the state store to extract or calculate desired data. This method takes a callback function as an argument, and this callback function receives the current state (store
) as a parameter and returns a derived value.
In the example below, useCart.readOnly(store => store.length)
is used to calculate the total number of products in the cart (cartLength
) through the length
property of the cart array (store
).
function Header() {
const cartLength = useCart.readOnly(store => store.length)
return (
<header>
<Menu />
<Logo />
<Link href="/cart">
<div className="cart">
<Image src="/cart.svg" alt="cart" width={24} height={24} />
{cartLength !== 0 && <span className="cart-count">{cartLength}</span>}
</div>
</Link>
</header>
)
}
The main advantages of using readOnly
are as follows:
- Selective Subscription: The component re-renders only in response to changes in the specific value returned by the callback function, not the entire state object. For example, the
Header
component will only re-render when products are added or removed from the cart, changingstore.length
. If only an attribute of a specific item in the cart changes but the total count remains the same, this component will not re-render, reducing unnecessary operations. - Derived State Calculation: It’s not just about fetching a part of the state; you can create calculated values (derived state) based on the state. For example, logic to calculate the total cart amount or to filter and show the count of items meeting specific criteria can be handled within the
readOnly
callback. - Performance Optimization: By subscribing precisely to only the necessary data, it prevents unnecessary re-renders that occur when other parts of the state change. This significantly contributes to performance optimization, especially in applications with complex state structures or frequent state updates.
Consequently, useCart.readOnly
is very useful for creating components that depend only on specific parts of the state or derived values, and it helps improve the reactivity and performance of the application.
Creating the Cart Page
The cart page displays a list of products selected by the user and provides functionality to remove individual products or clear the entire cart. It also calculates and displays the total amount for all products in the cart. This page is primarily composed of a Cart
component, and each cart item can be rendered via ProductCard
(or a separate CartItemCard
).
In the Cart
component, a selector function is passed to the useCart
hook to efficiently retrieve only the necessary data. This selector function takes the current cart state (store
) as an argument and returns an object containing the list of cart items (items
) and the total price of all items (priceTotal
). This way, the Cart
component only re-renders when the cart items or the total amount changes.
items
: An array of product objects currently in the cart.priceTotal
: The sum ofproduct.price
for each item in theitems
array.
function Cart() {
const [cartData, setCart] = useCart(store => ({
items: store, // The entire cart item array
priceTotal: store.reduce((sum, product) => sum + product.price, 0) // Sum of each product's price
}));
return (
<>
{cartData.items.map(product => (
<ProductCard key={product.id}>
{product.imageUrl && <ProductCard.Image image={product.imageUrl}/>}
<ProductCard.Title name={product.name}/>
<ProductCard.Description description={product.description}/>
<ProductCard.Price price={product.price}/>
<button onClick={() => setCart(prev => prev.filter(item => item.id !== product.id))}>Remove from Cart</button>
</ProductCard>
))}
<div>
{/* Using priceTotal directly */}
<strong>Total: ${cartData.priceTotal.toFixed(2)}</strong>
</div>
<button onClick={() => setCart([] as Array<Product>)}>Clear Cart</button>
</>
)
}
Extending Cart Functionality with Middleware Composition: Backup and Action Verification
One of Caro-Kann’s powerful features is the ability to easily compose middleware to extend store functionality. Here, we’ll apply persist
and logger
middleware to keep the cart state in the browser and easily track state changes.
1. Persisting cart state with persist middleware
By default, an web application’s state is reset when the page is refreshed or the browser is closed. To prevent users from losing the products they’ve added to their cart, the state needs to be stored somewhere. The persist
middleware makes managing this state persistence very simple.
The persist
middleware automatically saves data to a specified storage (e.g., localStorage
) whenever the state changes, and restores the state by loading this data when the application reloads.
Here’s how to apply the persist
middleware to the useCart
store:
import { create } from 'caro-kann';
import { persist } from 'caro-kann/middleware';
export const useCart = create(
persist<Array<Product>>([], { local: "cart-storage" })
)
2. Tracking state changes with logger middleware
During development, understanding how the state changes and what actions caused those changes is crucial for debugging. The logger
middleware helps track this process easily by printing state change-related information to the console.
The logger
middleware can be composed with the persist
middleware. Middleware is applied by wrapping functions, so you can think of it as executing from the inside out. That is, when a state change occurs, logger
acts first, then persist
acts.
import { create } from 'caro-kann';
import { persist, logger } from 'caro-kann/middleware';
export const useCart = create(
persist(
logger<Array<Product>>([], { diff: true, timestamp: true }),
{ local: 'cart-storage' }
)
)
Now, whenever a product is added to or removed from the cart, relevant logs will be printed to the browser console, allowing clear verification of the state change process. You can also adjust logger options to selectively view only the necessary information.
Merging stores with merge
As an application grows, it can be more efficient to manage related states in separate stores. For example, cart state can be managed in a useCart
store, and user profile information in a useProfile
store.
Caro-Kann provides a merge
utility to conveniently use and manage these separated stores as a single, unified hook. merge
takes multiple store hooks as input and returns a new hook that can access the state and setter functions of each store.
First, define the individual stores. Here, we’ll use cart (useCart
) and user profile (useProfile
) stores as examples. A User
type also needs to be defined.
import { create } from 'caro-kann';
import { persist, logger } from 'caro-kann/middleware';
export const useCart = create(
persist(
logger<Array<Product>>([], { diff: true, timestamp: true }),
{ local: 'cart-storage' }
)
)
export const useProfile = create<User>({
email: 'wpfekdml@me.com',
name: 'Ayden Blair',
phoneNumber: '010-****-****',
age: 30,
});
Now, use the merge
utility to combine the useCart
and useProfile
stores. Pass an object to the merge
function, where each key will be the name used in the merged store, and the value will be the respective store hook.
import { merge } from 'caro-kann/utils';
export const useProfileAndCart = merge({
user: useProfile, // Access useProfile store with 'user' key
cart: useCart // Access useCart store with 'cart' key
});
Using the useProfileAndCart
hook created this way, you can access the state and setter functions of each store as if dealing with one large store.
For example, within a component, you can use it as follows:
function MyComponent() {
const { userName, cartItems } = useProfileAndCart.readOnly(mergedStore => ({
userName: mergedStore.user.name,
cartItems: mergedStore.cart
}));
return (
<div>
<p>User Name: {userName}</p>
<p>Cart Items Count: {cartItems.length}</p>
{/* ... */}
</div>
);
}
Wrapping up: Efficient State Management with Caro-Kann
So far, we’ve explored how to implement basic shopping cart functionality using the Caro-Kann library. Through this tutorial, you’ve learned how to create a store with the create
function, read and update state via the useCart
hook, optimize with writeOnly
and readOnly
methods, and manage derived state using selectors. We also looked at how to easily extend store functionality by composing persist
and logger
middleware, and how to manage multiple stores integratively with the merge
utility.
The topics covered here are just a part of the diverse features Caro-Kann offers. In actual application development, you’ll encounter more complex state logic, asynchronous operations, advanced middleware usage, and various other scenarios. Caro-Kann provides powerful and flexible features to meet these advanced requirements, helping developers handle state management more efficiently and intuitively.
For deeper learning and to explore the full potential of Caro-Kann, we highly recommend referring to the official documentation. The official documentation provides detailed guidance on various APIs not covered in this tutorial, advanced usage patterns, and best practices for real-world production environments. We hope you have a more enjoyable and productive state management experience with Caro-Kann.