typescript 132 lines · 3 tabs

Shopping Cart Pricing with a useReducer State Machine and a Pure Discount Engine

Shared by codesnips Aug 2026
3 tabs
export interface CartItem {
  sku: string;
  name: string;
  unitPrice: number; // cents
  qty: number;
}

export interface CartTotals {
  subtotal: number;
  discountTotal: number;
  total: number;
}

export type DiscountRule = (items: CartItem[], coupons: Set<string>) => number | null;

const lineTotal = (i: CartItem): number => i.unitPrice * i.qty;
const subtotalOf = (items: CartItem[]): number => items.reduce((s, i) => s + lineTotal(i), 0);

export function percentOff(coupon: string, pct: number): DiscountRule {
  return (items, coupons) => {
    if (!coupons.has(coupon)) return null;
    return Math.round(subtotalOf(items) * (pct / 100));
  };
}

export function bogo(sku: string): DiscountRule {
  return (items) => {
    const line = items.find((i) => i.sku === sku);
    if (!line || line.qty < 2) return null;
    return Math.floor(line.qty / 2) * line.unitPrice;
  };
}

export function priceCart(
  items: CartItem[],
  coupons: Set<string>,
  rules: DiscountRule[]
): CartTotals {
  const subtotal = subtotalOf(items);
  const discountTotal = rules.reduce((sum, rule) => sum + (rule(items, coupons) ?? 0), 0);
  const total = Math.max(0, subtotal - discountTotal);
  return { subtotal, discountTotal: Math.min(discountTotal, subtotal), total };
}
3 files · typescript Explain with highlit

This snippet models a shopping cart where line items live in a useReducer state machine and all money math is delegated to a pure pricing service. The separation is deliberate: the reducer owns what is in the cart, while pricing.ts owns what the cart costs. Keeping the two apart means the discount logic can be unit-tested in isolation and re-run deterministically without a running component tree.

In pricing.ts, prices are represented in integer cents to avoid floating-point drift, a classic e-commerce pitfall where 0.1 + 0.2 fails to equal 0.3. Discount rules are modeled as an array of DiscountRule objects, each a pure function returning either a discount or null. percentOff and bogo are rule factories, so new promotions are composed by pushing more rules into the list rather than editing a growing conditional. priceCart folds every rule over the current items, sums the applicable discounts, and returns a CartTotals breakdown of subtotal, discountTotal, and total, clamped so a stack of promotions can never drive the total negative.

In cartReducer.ts, the state is just the raw CartItem[] plus an appliedCoupons set — intentionally free of any computed money. ADD_ITEM merges quantities for an existing SKU instead of duplicating a row, SET_QTY removes the line when quantity hits zero, and every case returns a new array so React sees a fresh reference. Modeling the cart as an event-driven reducer makes each mutation an auditable, replayable action, which is valuable when carts must survive refreshes or sync across tabs.

In useCart.ts, the hook wires the reducer to the pricing service. The expensive priceCart fold is wrapped in useMemo keyed on state, so totals recompute only when items or coupons actually change, not on every render. The hook exposes ergonomic action creators (addItem, setQty, applyCoupon) alongside the derived totals, giving components a clean API while the reducer and pricing internals stay hidden.

The trade-off is an extra fold on each cart change, but for realistic cart sizes that cost is negligible and buys full determinism. This layering — reducer for state, pure service for derivation, memoized hook to bridge them — scales cleanly as promotions, taxes, and shipping rules accumulate.


Related snips

Share this code

Here's the card — post it anywhere.

Shopping Cart Pricing with a useReducer State Machine and a Pure Discount Engine — share card
Link copied