Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

27 chapters
⌕
Beginner9›
B1Setup & mental modelB2Types & valuesB3Operators & flowB4Functions (first half)B5Objects & arrays (first half)B6DOM & eventsB7Basic asyncB8Errors & tools★Cheat page
Intermediate10›
I1Scope & functions, properlyI2Objects deeplyI3Prototypes & OOPI4Async, properlyI5Modules & toolingI6Regex, dates & APIsI7Error handlingI8Real-time connectionsI9Offline & storage★Cheat page
Advanced10›
A1Engine & memoryA2Advanced asyncA3MetaprogrammingA4Types & dataA5Patterns & architectureA6PerformanceA7SecurityA8EcosystemA9Testing★Cheat page
/ search[ ] chaptert top

JavaScript levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
A5

Patterns & architecture

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

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.

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.

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.

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 three chapters back are all the identical idea at different layers of the stack — don't redo work whose answer hasn't changed.

Practice this layer

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

An order that can't skip states3 tests · advancedCompose functions, right to left2 tests · advanced
←previousTypes & data↑ CovernextPerformance→