đ 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<Date[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Ctx | null>(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 (
<TabsCtx.Provider value={{ index, set: update }}>
{children}
</TabsCtx.Provider>
);
}
Tabs.List = ({ children }: { children: ReactNode }) => <div>{children}</div>;
Tabs.Tab = ({
i,
children,
}: {
i: number;
children: ReactNode;
}) => {
const ctx = useContext(TabsCtx)!;
return (
<button
aria-selected={ctx.index === i}
onClick={() => ctx.set(i)}
>
{children}
</button>
);
};
Tabs.Panel = ({ i, children }: { i: number; children: ReactNode }) => {
const ctx = useContext(TabsCtx)!;
return ctx.index === i ? <div>{children}</div> : 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<string, number>;
type Ctx = {
currency: string;
setCurrency: (c: string) => void;
convert: (val: number, to?: string) => number;
};
const CurrencyCtx = createContext<Ctx | null>(null);
export function CurrencyProvider({ children }: { children: ReactNode }) {
const [currency, setCurrency] = useState('USD');
const [rates, setRates] = useState<Rates>({});
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 (
<CurrencyCtx.Provider value={{ currency, setCurrency, convert }}>
{children}
</CurrencyCtx.Provider>
);
}
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 promo={promo} />;
}
-----------------------
// PromoTile.view.tsx
export function PromoTile({ promo }: { promo: { title: string } | null }) {
if (!promo) return null;
return <div className="promo-tile">{promo.title}</div>;
}
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.
Top comments (0)