Blog
icon
Read time : 10 min
icon
Published on 24-04-2025
icon
Blog
icon
Read time : 10 min
icon
Published on 24-04-2025
icon

The React and JS Patterns That Keep Us Sane (and Shipping)

Sarwajit Kumar
Sarwajit Kumar
Sr. Shopify Expert
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:

  1. 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.

  2. 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.

  3. 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.

  4. 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}&region=${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

  1. Will a junior dev grok ownership boundaries in < 30 seconds?
  2. Can we unit‑test the business logic without rendering React?
  3. 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.