Skip to Content
Tutorials

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:

  1. 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, changing store.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.
  2. 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.
  3. 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 of product.price for each item in the items 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.

Last updated on