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.
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 onDispatch<OrderActions>, onCommerceLayerConfig, 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
TestingcreateOrdermeant 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 internaluseContextcalls. - 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 |
|---|---|
Pure TypeScript, zero React dependencies. | |
React hooks + SWR, depends only on core. | |
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
TestingcreateOrderfrom core means calling an async function and verifying the result. NorenderHook, noact(), 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-componentsand 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:createBatchStorein 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 inupdateOrder, wheremutate(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 oldOrderContainerwasn't deleted. It became a thin wrapper that delegates to the newOrdercomponent, 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:
- Pure functions are extracted into core — no React, no state, just input/output.
- A hook in hooks composes those functions with SWR and React primitives.
- A standalone component in react-components uses the hook internally.
- 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:
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.