š Note:
This post shares patterns and lessons pulled directly from our developers' experience building scalable storefronts.
When you see "I," it reflects real-world insights from the engineers behind these decisions.
Eācommerce at scale doesnāt fail because the code looks ugly.
It fails when a fiveāsecond page tanks revenue, a silent error wipes out uptime, or a single developer holds the only GraphQL map.
Thatās why predictability outranks prettiness. Four facts from the last year to frame the patterns that follow:
Speed is cash. Pages that load in ā¤1s convert 2.5ā3Ć better than 5āsecond pages, so I isolate heavy UI logic off the main thread and lean on edge rendering.
Observability moves the needle. Teams with fullāstack telemetry are 51 % more likely to improve uptime and 44 % more likely to boost efficiency., so I bake in proxy wrappers and tracing hooks from day one.
Static contracts won. TypeScript is now GitHubās #3 language, behind only Python and JavaScript. So I ship every pattern with typed interfaces to prevent āworksāonāmyāmachineā refactors.
Testing is still a blind spot. Roughly 13 % of JS devs use no test framework at work (State of JS 2024), so I fail CI if unitātest coverage dips below 90 %.
JavaScript Patterns That Actually Scale
A PDP in one market may show a single SKU; in another, a subscription bundle with regionāspecific tax rules. When product shapes diverge this fast, patterns are the only way to keep the codebase predictable.
With those stakes set, here are the React & JS patterns that buy us predictability -
1. Factory Pattern
When to reach for it:
- SKU shapes vary by market, channel, or fulfilment method.
- You need to expose a single cart interface while the backāend business logic mutates.
When to avoid:
- Only one immutable product type exists (KISS beats abstraction).
// Core Factory example: illustrates the āsimpleā branch; other branches follow the same pattern
type ProductShape =
| { kind: 'simple'; name: string; price: number }
| { kind: 'subscription'; name: string; monthlyPrice: number; duration: number }
| { kind: 'bundle'; name: string; items: { price: number }[]; discountRate?: number };
export function productFactory(data: ProductShape) {
if (data.kind === 'simple') {
return { ...data, total: data.price };
}
// subscription & bundle logic implemented similarly
}
Why it matters:
KPI | Impact |
---|---|
Cart uptime when adding new product types | Interface stays constant ā < 1 % checkout errors during launches |
Release velocity | Devs extend the switch blockānot the UIāreducing PR churn |
QA surface | Typed discriminated unions catch shape drift at compile time |
2. Module Pattern
Use it for:
- Tax currency, or promo logic that touches every page.
- Shared utilities that must stay frameworkāagnostic (SSR/edge safe).
// storefront/tax.ts
export const taxRates = { US: 0.07, EU: 0.2 } as const;
export function calcTax(amount: number, region: keyof typeof taxRates) {
return amount * (taxRates[region] ?? 0);
}
Observability hook:
import { trace } from '@opentelemetry/api';
export function calcTaxTraced(amount: number, region: keyof typeof taxRates) {
return trace.getTracer('storefront').startActiveSpan('calcTax', span => {
const res = calcTax(amount, region);
span.end();
return res;
});
}
Why it matters:
Think of this module as your single home for all tax and pricing rulesāit runs exactly the same on the server, edge, or browser (no more āworks in dev onlyā surprises). And because itās all in one file, your finance or compliance team can sign off with a single diff instead of hunting through ten different PRs.
3. Proxy Pattern
Use it for:
- Crossācutting concerns (rateālimit, feature flags, telemetry) without touching call sites.
- Gradual API deprecationāwrap the old SDK, add warnings, ship.
// inventoryProxy.ts
import pRetry from 'p-retry';
const inventorySDK = {
check: (id: string) => fetch(`/api/inventory/${id}`).then(r => r.json()),
};
export const inventory = new Proxy(inventorySDK, {
get(target, prop) {
if (prop === 'check') {
return async (productId: string) => {
return pRetry(() => target.check(productId), { retries: 2 });
};
}
// fallback to raw method
// @ts-ignore
return target[prop];
},
});
Why it matters:
Because a Proxy wrapper gives you retries, logging, and featureāflag hooks around every callāwithout ever touching your core logic.
I ended up making this decision cheat sheet, hope it helps -
Pattern | Ship if⦠| Skip if⦠|
---|---|---|
Factory | Product shapes proliferate | Single static SKU model |
Module | Logic reused across views/runtime targets | Logic is truly page-local |
Proxy | Need cross-cutting behavior w/ no consumer changes | Performance is ultra-critical and nanosecond proxy overhead matters |
Patterns We Skip (99 % of the Time)
We embrace patterns that raise predictability and drop those that quietly erode it. Two common offenders:
Pattern | Why We Usually Pass | Acceptable Edge Case |
---|---|---|
Singleton | Global state hides complexity. In React + serverless stacks the same āsingletonā can instantiate per lambda, breaking the very guarantee you wanted. Itās hard to unit-test, near-impossible to version, and leaks across feature flags. | A true infrastructure client that must share pooled resourcesāe.g., a Node database driver or Redis connection. Even then, wrap it behind an interface you can mock in tests. |
Observer | Event chains look elegant until youāre spelunking a log at 2 a.m. wondering who fired what. Modern React already gives us deterministic data flow (state + effects), and scoped pub/sub libraries (e.g., mitt) keep responsibilities explicit. | High-frequency telemetry pipelines where decoupled consumers genuinely outnumber producers and you have distributed tracing in place. Even then, document every event contract like an API. |
From Language Rules to Component Rules
(Now we are Shifting gears: JS ā React)
Weāve just tightened the languageālevel screwsāfactories, modules, proxiesāso our business logic stays predictable no matter how many SKU shapes or tax rules get tossed at it.
But JavaScript patterns alone wonāt stop a promo team, a personalization squad, and a cartāexperiment crew from tripping over each other in the React layer. UI state, render timing, and server/client boundaries introduce a new class of failure modes.
1. Custom Hooks Pattern
Use it when: Multiple components need identical business logic (e.g., delivery slots, promo eligibility).
Skip it when: Logic is purely UIāside or singleāuse.
// useDeliverySlots.ts
import { useState, useEffect, useCallback } from 'react';
export function useDeliverySlots(productId: string | undefined, region: string) {
const [slots, setSlots] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!productId) return;
setLoading(true);
fetch(`/api/delivery-slots?product=${productId}®ion=${region}`)
.then(r => (r.ok ? r.json() : Promise.reject(r.status)))
.then(setSlots)
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [productId, region]);
const sameDayEligible = useCallback(
() => slots.some(s => s.toDateString() === new Date().toDateString()),
[slots]
);
return { slots, loading, error, sameDayEligible };
}
Why it matters: isolates business logic from rendering, slashes test time by 3Ć, and stays SSRāsafe.
2. Compound Component Pattern
Use it when: You need multiāstep, configurable UIs (e.g., product customizers).
Skip it when: Simple UI with minimal configuration or single render state is needed.
// Tabs.tsx
import {
createContext,
useContext,
useState,
ReactNode,
useCallback,
} from 'react';
type Ctx = { index: number; set: (i: number) => void };
const TabsCtx = createContext(null);
export function Tabs({
defaultIndex = 0,
onChange,
children,
}: {
defaultIndex?: number;
onChange?: (i: number) => void;
children: ReactNode;
}) {
const [index, setIndex] = useState(defaultIndex);
const update = useCallback(
(i: number) => {
setIndex(i);
onChange?.(i);
},
[onChange]
);
return (
{children}
);
}
Tabs.List = ({ children }: { children: ReactNode }) => {children};
Tabs.Tab = ({
i,
children,
}: {
i: number;
children: ReactNode;
}) => {
const ctx = useContext(TabsCtx)!;
return (
ctx.set(i)}
>
{children}
);
};
Tabs.Panel = ({ i, children }: { i: number; children: ReactNode }) => {
const ctx = useContext(TabsCtx)!;
return ctx.index === i ? {children} : null;
};
Why it matters:
- Declarative API feels like HTML ā¶ faster onboarding.
- No propādrilling; context stays private to Tabs, so bundle size is minimal.
- Easy to slot A/B variantsāwrap Tabs.Tab without touching internal state.
3. Provider Pattern (React Context API)
Use it when: You need global, lowāchurn state (currency, auth) across layouts.
Skip it when: State is highly pageāspecificāuse a local hook instead.
/ CurrencyProvider.tsx
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
type Rates = Record;
type Ctx = {
currency: string;
setCurrency: (c: string) => void;
convert: (val: number, to?: string) => number;
};
const CurrencyCtx = createContext(null);
export function CurrencyProvider({ children }: { children: ReactNode }) {
const [currency, setCurrency] = useState('USD');
const [rates, setRates] = useState({});
useEffect(() => {
fetch(`/api/rates?base=${currency}`).then(r =>
r.json().then((d: Rates) => setRates(d))
);
}, [currency]);
const convert = (val: number, to = currency) =>
to === currency ? val : val * (rates[to] ?? 1);
return (
{children}
);
}
export const useCurrency = () => {
const ctx = useContext(CurrencyCtx);
if (!ctx) throw new Error('useCurrency outside provider');
return ctx;
};
Why it matters:
- Zero propādrilling across multiālayout storefronts.
- Only 3 Ć HTTP calls max (base currency change) versus perāpage fetch spam.
- Throwāifāmissing guard prevents silent provider misāconfig in tests.
NOTE : While Context is cleaner than prop drilling, it can trigger re-renders across the tree. For high-frequency updates, consider context splitting or state management libraries.
4. Container / Presentational Split
Use it when: You manage a shared component library with varying data.
Skip it when: Component is oneāoffācoālocate fetch + render.
// PromoTile.container.tsx
import { useEffect, useState } from 'react';
import { PromoTile } from './PromoTile.view';
export function PromoTileContainer({ slot }: { slot: string }) {
const [promo, set] = useState<{ title: string } | null>(null);
useEffect(() => {
fetch(`/api/promos?slot=${slot}`)
.then(r => r.json())
.then(set);
}, [slot]);
return ;
}
-----------------------
// PromoTile.view.tsx
export function PromoTile({ promo }: { promo: { title: string } | null }) {
if (!promo) return null;
return {promo.title};
}
Why it matters:
- UI teams A/Bātest layout without touching fetch code.
- API migrations swap the container only; design tokens stay intact.
- Cypress e2e tests stub API and snapshot the presentational componentāno flaky mocks.
And like before, here is a decision cheat sheet for React patterns:
Pattern | Reach for it | Skip if⦠|
---|---|---|
Custom Hook | Logic reused across components | Single-use logic |
Compound Component | Multi-step, configurable UI flow | Only one sub-component |
Provider | Global, low-churn state | Highly page-specific data |
Container / Presentational | Shared design, varying data | Component truly one-off |
How We Choose Patterns (and When We Donāt)
Axis | Ship the pattern if⦠| Default to simpler code if⦠| āGo-toā patterns |
---|---|---|---|
Team Topology | ā„ 2 squads touch the same feature or brand layer | A single, long-lived team owns it end-to-end | Compound Components; Container / Presentational |
Codebase Footprint | Module lives in a monorepo, multi-tenant, or > 50 k LOC | App is < 10 k LOC or feature is truly isolated | Custom Hooks; Modules |
Business Complexity | Logic changes per market, promo, or fulfilment rule | Single, static SKU & workflow | Factory; Module |
Change Velocity | Weekly A/B tests, fast campaigns, or feature flags | Regulated workflows, quarterly release cadence | Proxy; Custom Hooks |
Risk / Blast Radius | A bug here can halt checkout or settlement | Failure manifests as a cosmetic glitch | Add tracing hooks, typed APIs; else YAGNI |
Litmus test we run on every PR
- Will a junior dev grok ownership boundaries in < 30 seconds?
- Can we unitātest the business logic without rendering React?
- Does rollback require touching more than one folder?
If any answer is āno,ā we downgrade the abstractionāor scrap it.
The only nonānegotiable rule: If a pattern doesnāt cut complexity or blastāradius, we donāt ship it.
(Thatās the whole decision engineāeverything else is commentary.)
Closing Thought
Patterns arenāt merit badges. Theyāre circuitābreakers for future headaches.
Every abstraction in this post made it into our stack only after it saved an onācall engineer, cut a rollback, or shaved build time. Thatās the bar:
- Does it shrink the blast radius when business logic pivots?
- Can a new hire trace ownership in under a minute?
- Will it still read clean at 3 a.m. when prod is on fire?
If the answerās āyes,ā we keep it; if not, we rip it outāeven if it looked elegant in code review.
Thatās the entire playbook. Use what buys you predictability, skip what doesnāt, and stay ruthless about revisiting the call as your team, codebase, and revenue targets grow.
Would love to hear what patterns youāve leaned onāor skippedāand why. Drop a comment if youāve got battle scars or lessons of your own.