The React and JS Patterns That Keep Us Sane (and Shipping)
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.