Introducing our new Payments API. For humans and agents.

Product

Frontend components refactoring #1: core and hooks

The first step in a multi-part series on rebuilding our React library by extracting framework-independent business logic into reusable TypeScript packages.

AC
Alessandro
· August 31, 2026

The starting point

It all begins with an 805-line file.

OrderReducer.ts is the beating heart of order management in the library. Inside it live API calls, error handling, cart business logic, order creation, updates, coupon management, payment source operations, local state serialization, and action dispatching to the reducer. Everything in a single file. Everything coupled to React.

It's the perfect symbol of an architecture that worked well for a long time, but started creaking under the weight of its own complexity.

The commercelayer-react-components library was born as a monolith — a single npm package exporting ready-to-use React components for building ecommerce experiences powered by the Commerce Layer API. It was a sensible choice at the beginning: a single entry point, one dependency to install, everything under control. But over time, the code grew. And with it, the problems.

The architecture that was

The dominant pattern was Container + Reducer + Context. Let's take orders as an example.

OrderContainer was a React component that fetched the order from the API on mount, stored it in state managed by useReducer, and made it available to its children through an OrderContext. Every operation — creating an order, adding to cart, applying a coupon — lived inside OrderReducer.ts, one function after another, each accepting dispatch, config, state as parameters, mixing pure business logic with React state machinery.

The same pattern repeated everywhere: PricesContainer with its own context, SkusContainer with its own, AvailabilityContainer with its own. Each domain had its own container, its own reducer, its own context. In total, 36 context files and 11 reducers — a maze of distributed global state where every child component had to be nested inside the right container to work.

This approach brought with it several concrete problems.

  • Total coupling to React
    Every function that talked to the Commerce Layer APIs depended on Dispatch<OrderActions>, on CommerceLayerConfig, on types tied to the React context. Wanted to use the order creation logic in a server-side Node.js script? In a unit test without mounting components? In a Vue or Svelte app? In a React Server Component? Impossible. The business logic was held hostage by the framework.
  • The difficulty of testing
    Testing createOrder meant mocking dispatch, building a compatible config object, simulating state. You weren't testing business logic — you were testing the integration with React's state system. Tests became fragile and verbose.
  • The "invisible prop drilling" problem
    Every child component had to be inside the right container. <TotalAmount> only worked inside <OrderContainer>. <PriceAmount> only inside <PricesContainer>. Forgot a wrapper? Runtime error. The TypeScript compiler couldn't help you — the contract was implicit, hidden in internal useContext calls.
  • Scattered batching logic
    The duplicate request problem was already solved, but the solution was fragmented: each container implemented its own version of the collection and deduplication logic. PricesContainer had its own way of accumulating SKU codes, SkusContainer another. The same idea — collect codes, make a single call, distribute the results — was reimplemented case by case, with different nuances, different bugs, and no possibility of reuse across domains.

The decision

In April 2026, the time came to change. The idea is simple in its formulation, radical in its execution: separate pure business logic from the React framework, organizing it into independent layers.

This is how the three-package structure was born:

Package

Description

Core components

Pure TypeScript, zero React dependencies.

React hooks components

React hooks + SWR, depends only on core.

React components

UI components, depends on core + hooks.

It's not just a folder reorganization. It's a change in philosophy.

Core — The truth without opinions

The core package is the foundation of everything. It's pure TypeScript — no React imports, no useState, no useEffect, no dispatch. Just functions that do one thing and do it well.

Take order creation. It used to live in OrderReducer.ts, among dozens of other functions, coupled to React's dispatch. Now it looks like this:

export async function createOrder({
  accessToken,
	interceptors,
	metadata,
	attributes = {},
  }: CreateOrderParams): Promise<Order> {
    getSdk({ accessToken, interceptors })
	  return await orders.create({ metadata, ...attributes })
})

Ten lines. No React dependency. No dispatch. No try/catch that dispatches errors into global state. It takes parameters, calls the SDK, returns a Promise with the created order. Error handling? That's the caller's responsibility, not the function's.

The same goes for retrieveOrder, updateOrder, getPrices, getSkus, getAvailability. Each function is a self-contained unit that can be imported and used anywhere — in a React component, in a Node.js script, in a test, in a worker, in an app of any framework.

But core doesn't just wrap the SDK. It also contains createBatchStore, an elegant piece of engineering that solves the duplicate request problem. It's a factory that creates a module-level store where multiple hook instances can register the SKU codes they need. A 50-millisecond debounce collects all codes, produces an immutable snapshot, and notifies subscribers — who then trigger a single fetch. SWR deduplicates further. The result? Ten <Price> components on the same page generate a single API call.

export function createBatchStore() {
  const store = new Map<string, StoreEntry>()
	function registerCode(accessToken: string, code: string): void {
	  const entry = getOrCreate(accessToken)
		if (entry.skuCodes.has(code)) return entry.skuCodes.add(code)
		scheduleFlush(entry)  // 50ms debounce → immutable snapshot → notify
  }
  return { subscribe, getSnapshot, registerCode, unregisterCode }
}

createBatchStore is also pure TypeScript. It uses Map, Set, setTimeout. No React. Yet it integrates seamlessly with useSyncExternalStore in the layer above.

Hooks — The bridge to React

The hooks package is the translator. It takes the pure functions from core and dresses them with React primitives: useState for local state, SWR for data fetching with cache and revalidation, useCallback for referential stability.ap

useOrder is the perfect example of this translation:

export function useOrder({
  accessToken,
	orderId,
	interceptors
  }: UseOrderParams): UseOrderReturn {
    const { data, error, isLoading, mutate } = useSWR<Order>(
	    accessToken && orderId ? ["order", "retrieve", accessToken, orderId] : null,
	    async () => retrieveOrder({ accessToken, interceptors, id: orderId! }),
      { revalidateOnFocus: false }
  )
  const createOrder = useCallback(
    async (params?) => coreCreateOrder({ accessToken, interceptors, ...params }),
    [accessToken, interceptors]
	  )
  return { order: data, isLoading, error: error?.message ?? null, createOrder, ... }
}

No reducer. No dispatch. No context provider. The hook takes an accessToken and an orderId, returns the order and functions to manipulate it. SWR handles caching, deduplication, and revalidation. The component using this hook doesn't need to be wrapped in any container.

The same pattern repeats for usePrices, useSkus, useSkuList, useAvailability — each a self-sufficient hook that composes core functions with React primitives.

The concrete improvements

This separation isn't cosmetic. It brings measurable benefits.

  • Radical testability
    Testing createOrder from core means calling an async function and verifying the result. No renderHook, no act(), no provider wrappers. Tests are fast, deterministic, easy to write and read.
  • Reusability beyond React
    A team using Vue, Svelte, or vanilla JavaScript can import @commercelayer/core-components and have access to all the business logic without dragging React along as a dependency. The core package has zero peer dependencies — just the Commerce Layer SDK and the authentication library.
  • Centralized and reusable batching
    The request collection and deduplication logic, previously scattered and reimplemented in each container, now lives in a single place: createBatchStore in the core package. It's a generic factory — create it once for prices, once for SKUs, and you get the same guaranteed behavior: 50ms debounce, immutable snapshot, automatic cleanup on unmount. No more parallel implementations with different bugs. One solution, tested once, used everywhere.
  • Intelligent caching with SWR
    SWR brings request deduplication, in-memory caching, configurable revalidation, and optimistic updates for free (as seen in updateOrder, where mutate(updated, { revalidate: false }) updates the cache immediately without waiting for a new fetch). Before, all of this had to be reimplemented by hand in the reducers.
  • Gradual deprecation
    The old OrderContainer wasn't deleted. It became a thin wrapper that delegates to the new Order component, printing a warning in development. Library consumers can migrate at their own pace, component by component. No immediate breaking changes.

The pattern that emerges

Each domain of the library now follows the same refactoring path:

  1. Pure functions are extracted into core — no React, no state, just input/output.
  2. A hook in hooks composes those functions with SWR and React primitives.
  3. A standalone component in react-components uses the hook internally.
  4. The old container is deprecated and turned into a proxy to the new component.

It's a repeatable, predictable, and — most importantly — reversible pattern. If a choice turns out to be wrong, the abstraction layer allows changing it without rewriting the world.

What comes next

This is just the beginning. The separation into core and hooks laid the foundation, but refactoring is a journey, not a destination. In the next episodes, we'll see how this pattern was applied domain by domain — prices, SKUs, availability, orders — and what specific challenges emerged along the way.

But the lesson from this first episode is already clear:

The best architecture isn't one that anticipates every possible future, but one that makes change cheap.

Separating pure logic from the framework is the first step toward making a codebase resilient — not because it will never change, but because it can change without fear.