Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A5
33 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA5 · Part 3 · Advanced

Patterns & architecture

Shapes that show up again and again once code has to scale past one file.

7 min read8 sections2 exercises
33 of 41

In this chapter

  • Functional programming basics
  • Design patterns, in JS terms
  • Middleware — a chain of functions, each deciding to continue or stop
  • Dependency injection
  • State machines

and 3 more

about 7 min
08On this page
  1. Functional programming basics
  2. Design patterns, in JS terms
  3. Middleware — a chain of functions, each deciding to continue or stop
  4. Dependency injection
  5. State machines
  6. Signals — reactivity without a virtual DOM diff
  7. Error boundaries and resilience
  8. API design

Functional programming basics

A pure function's output depends only on its inputs, and it touches nothing outside itself — no network call, no mutating an argument, no reading a global. The upside isn't philosophical: a pure function is trivially testable (call it, check the return value, no setup), safely memoizable (already covered), and safe to run in any order or in parallel, since it can't step on anything else's state.

// impure — depends on and mutates something outside itself
let discount = 0.1;
function applyDiscount(price) { return price - price * discount; }

// pure — same inputs, same output, forever, no matter what else is happening
function applyDiscountPure(price, rate) { return price - price * rate; }

Immutability — building new values instead of changing existing ones — is what keeps a codebase full of pure functions actually pure; it's the same idea already covered for why React checks === instead of deep-comparing.

A transducer is a composable transformation that's independent of the collection it eventually runs against — instead of arr.map(f).filter(p) building one throwaway intermediate array between the two steps, a transducer combines map and filter into a single combined step function, run once per element, zero intermediate arrays:

const mapping = (fn) => (reducer) => (acc, val) => reducer(acc, fn(val));
const filtering = (pred) => (reducer) => (acc, val) => (pred(val) ? reducer(acc, val) : acc);
const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)));

const push = (acc, val) => (acc.push(val), acc);
const transform = compose(mapping((x) => x * 2), filtering((x) => x > 5));

console.log([1, 2, 3, 4, 5].reduce(transform(push), []));   // what happens?

[6, 8, 10] — every element is doubled, then kept only if the doubled value clears 5, all inside one reduce pass with no intermediate array built between the two steps. This is a genuinely deep rabbit hole (it's the core idea behind libraries like transducers-js) — the takeaway at this level is what problem it solves: composing transformations without paying for an intermediate array at every step.

Dry run: the transducer pipeline over [1, 2, 3, 4, 5]

xdoubled (mapping step)doubled > 5? (filtering step)pushed to acc?
12nono
24nono
36yesyes
48yesyes
510yesyes

Final acc: [6, 8, 10] — and the whole point of a transducer is visible in this table: every element goes through the mapping step, then straight into the filtering step, in a single combined pass, with no intermediate array of doubled values ever built between the two steps.

Design patterns, in JS terms

Pattern Shape Already seen it
Modulea closure exposing a small public surface, hiding the restclosures chapter, use #2
Observer / Pub-Subsubscribers register a callback; a publisher calls every one when something happensaddEventListener IS this pattern, built into the platform
Strategyswap the algorithm at runtime by passing a different function/object with the same interfacethe comparator argument to .sort()
Factorya function that builds and returns objects, hiding the construction detailsdocument.createElement
Singletonexactly one instance, created lazily on first requestan ES module itself — importing it twice gives the same instance, module caching does this for free
class EventBus {
  #listeners = new Map();
  on(event, fn) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event).push(fn);
    return () => this.off(event, fn);   // returns its own unsubscribe function
  }
  off(event, fn) {
    const fns = this.#listeners.get(event);
    if (fns) this.#listeners.set(event, fns.filter((f) => f !== fn));
  }
  emit(event, ...args) {
    (this.#listeners.get(event) || []).forEach((fn) => fn(...args));
  }
}

const bus = new EventBus();
const unsubscribe = bus.on("greet", (name) => console.log("hello", name));
bus.emit("greet", "Ana");
unsubscribe();
bus.emit("greet", "Ravi");   // what happens?

Only "hello Ana" prints — the second emit finds no listeners left, because calling the function on() returned removed it. This exact shape, hand-rolled, is what every pub/sub library and every framework's event system is doing underneath, whether it's 20 lines like this one or a much larger implementation.

Middleware — a chain of functions, each deciding to continue or stop

function compose(middlewares) {
  return function (context) {
    let index = -1;
    function dispatch(i) {
      if (i <= index) throw new Error("next() called multiple times");
      index = i;
      const fn = middlewares[i];
      if (!fn) return;
      return fn(context, () => dispatch(i + 1));   // "next" — hands control to the next middleware
    }
    return dispatch(0);
  };
}

const logger = (ctx, next) => { console.log("->", ctx.path); next(); console.log("<-", ctx.path); };
const auth = (ctx, next) => { if (!ctx.user) return console.log("blocked"); next(); };
const handler = (ctx) => console.log("handled", ctx.path);

const app = compose([logger, auth, handler]);
app({ path: "/orders", user: { id: 1 } });

This exact shape — an ordered list of functions, each one receiving a next it chooses whether and when to call — is what Express, Koa, and Redux middleware all are underneath, whether it's handling an HTTP request or an action dispatched to a store. Skipping next() (as auth does when there's no user) short-circuits everything after it in the chain; calling it lets execution continue, and code written after the next() call runs on the way back out — which is why logger can log both entry and exit around everything nested inside it.

Dependency injection

A function or class that receives what it depends on instead of reaching out and constructing or importing it directly.

// tightly coupled — this function can ONLY ever hit the real API
async function loadUser(id) {
  return fetch("/api/users/" + id).then((r) => r.json());
}

// injected — the caller decides what "fetch a user" actually means
async function loadUserWith(fetchImpl, id) {
  return fetchImpl(id);
}
loadUserWith(realApiFetch, 1);       // production
loadUserWith(fakeFetchForTests, 1);  // tests — no real network needed

Inversion of control is the broader principle this is one instance of: instead of a piece of code deciding and calling its own dependencies, something outside it decides and hands them in. A framework calling your component function, instead of your code calling into the framework, is the same inversion at a larger scale.

State machines

function createTrafficLight() {
  const transitions = { red: "green", green: "yellow", yellow: "red" };
  let state = "red";
  return {
    next() { state = transitions[state]; return state; },
    current() { return state; },
  };
}
const light = createTrafficLight();
console.log(light.current());   // what happens?
console.log(light.next(), light.next(), light.next());   // what happens?

"red", then "green" "yellow" "red" — the entire idea of a state machine in one small table: a fixed set of named states, and one function per state that says exactly what the next state is allowed to be. The value over a scattering of booleans (isLoading, isError, isSuccess, all mutable independently) is that an impossible combination — loading AND error AND success all true at once — simply can't be represented at all, instead of being a bug waiting to happen.

Signals — reactivity without a virtual DOM diff

function createSignal(initial) {
  let value = initial;
  const subscribers = new Set();
  return {
    get() { return value; },
    set(next) {
      value = next;
      subscribers.forEach((fn) => fn(value));
    },
    subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); },
  };
}

const count = createSignal(0);
count.subscribe((v) => console.log("count is now", v));

count.set(1);
count.set(2);

A signal is a value plus its own list of subscribers, bundled together — reading it is a normal function call, writing it directly notifies whoever's listening, with no framework re-render or virtual-DOM diff involved at all. This is the primitive underneath Solid, Preact Signals, Angular's newer reactivity model, and Vue's ref() — the pitch over React's re-render-the-whole-component model is fine-grained updates: a signal changing can update exactly the one DOM node that reads it, skipping every component in between that never actually used that value.

A real implementation adds one piece this sketch leaves out: automatic dependency tracking — a computed value that reads count.get() inside its own function body subscribes itself automatically, with no explicit .subscribe() call written anywhere. That's the part that makes signals feel declarative rather than manually wired — this hand-rolled version is the mental model, not the full implementation.

Error boundaries and resilience

function withFallback(fn, fallback) {
  return async (...args) => {
    try {
      return await fn(...args);
    } catch (error) {
      console.error("recovered from:", error);
      return fallback;
    }
  };
}
const safeLoad = withFallback(loadUserProfile, { name: "Guest" });

React's actual ErrorBoundary component is this same idea at the UI layer — catch a failure from a whole subtree of components, render a fallback UI instead of taking down the entire page. The general architectural principle underneath both: contain a failure at the smallest boundary that can meaningfully recover from it, instead of letting it propagate and take out something much bigger that didn't need to fail too.

API design

A request is idempotent if making it twice has the exact same effect as making it once. PUT /users/1 { name: "Ana" } is idempotent — running it five times still leaves the name "Ana". POST /users to create a new one usually isn't — five identical calls create five accounts.

Rule Idempotency is exactly what makes a retry-with-backoff safe to write blindly. Retrying an idempotent request after a timeout is harmless — it might have already succeeded, and running it again changes nothing. Retrying a non-idempotent one risks a real duplicate, usually solved with a client-generated idempotency key the server deduplicates by.

Caching closes the loop: the same request, made again, doesn't even need to reach the server. An HTTP Cache-Control header, an in-memory Map keyed by request, or the memoize pattern from the closures chapter are all the identical idea at different layers of the stack — don't redo work whose answer hasn't changed.

Interview answer, one sentence

"Patterns are named shapes for recurring problems — middleware as a chain of functions, dependency injection to keep code testable, state machines for modes, signals for fine-grained reactivity — and each is worth reaching for only once the plainer version has started to hurt."

Before you move on
  • Explain what makes a function "pure" and why that makes it trivially testable and safely memoizable.
  • Trace why bus.emit("greet", "Ravi") after unsubscribe() logs nothing, using the EventBus's own #listeners map.
  • Explain what next() does in the middleware chain, and why code written after next() runs on the way back out.
  • Say why an ES module behaves like a Singleton with no extra code, purely from how imports are cached.
  • Explain why PUT is idempotent and POST usually isn't, and how that decides whether a retry after a timeout is safe.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 10 testsAn order that can't skip statesadvanced
  • 8 testsCompose functions, right to leftadvanced
All 31 for this level →
A5

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A4Previous · A4Types & data
  2. A5you are here
  3. Next · A6PerformanceMaking a page feel fast is a different skill than making code run fast.9 min read

01On this page

8 sections · 0%

  1. 01Functional programming basics
  2. 02Design patterns, in JS terms
  3. 03Middleware — a chain of functions, each deciding to continue or stop
  4. 04Dependency injection
  5. 05State machines
  6. 06Signals — reactivity without a virtual DOM diff
  7. 07Error boundaries and resilience
  8. 08API design

[] chapters · t top