AlgoInsight

Free lesson · JavaScript Basic to Advance

Putting It Together: State, Errors, Performance

Easy5 min read

The Glue Between Layers

We have the data layer (Topic 15.3) and the UI layer (Topic 15.4). They don't know about each other yet. This Topic builds the state store that connects them — and the integration in main.js that brings everything to life.

// What we build in this Topic:
//
// state/store.js  — a class-based state container with subscribe/notify pattern
// main.js         — the integration point, wiring everything together
//
// Concepts demonstrated:
// - Classes with private fields (Topic 8.5)
// - Observer pattern (a common interview design pattern)
// - Immutable state updates with spread (Topic 10.2)
// - Async error handling (Topic 9.7)
// - Performance considerations (Module 13)

By the end of this Topic, the dashboard works end-to-end: type in the search box, see filtered results, with caching, cancellation, error handling, and a retry button.

The State Store

The store holds application state and lets subscribers react to changes. This is the pattern Redux, Zustand, and most state-management libraries implement.

// state/store.js

export class Store {
  #state;
  #subscribers = new Set();

  constructor(initialState = {}) {
    this.#state = { ...initialState };
  }

  getState() {
    return { ...this.#state };   // shallow copy for safety
  }

  update(partial) {
    const prevState = this.#state;
    this.#state = { ...this.#state, ...partial };
    this.#notify(prevState);
  }

  subscribe(listener) {
    this.#subscribers.add(listener);
    // Return unsubscribe function
    return () => this.#subscribers.delete(listener);
  }

  #notify(prevState) {
    for (const listener of this.#subscribers) {
      listener(this.#state, prevState);
    }
  }
}

// Demo
const store = new Store({ count: 0, name: "initial" });

const unsubscribe = store.subscribe((newState, prevState) => {
  console.log("State changed:", { newState, prevState });
});

store.update({ count: 1 });
store.update({ name: "updated" });
store.update({ count: 2, name: "both" });

unsubscribe();
store.update({ count: 999 });   // no log — subscriber removed

Walk through:

Private fields with #. Topic 8.5 covered this. #state and #subscribers are truly inaccessible from outside the class. External code can only interact through getState, update, and subscribe. This isn't convention — the language enforces it. Trying to access store.#state from outside is a syntax error.

Immutable updates via spread. Topic 10.2 covered the pattern. { ...this.#state, ...partial } produces a new object with the previous state's properties plus the new ones. We never mutate #state in place. This is important for two reasons: subscribers can compare old and new state (prevState !== newState is always true for changes), and code that captures a state reference doesn't see mysteries when the state updates.

Subscribe returning an unsubscribe function. This is the standard pattern for event-listener APIs. Calling subscribe registers a listener; the return value lets you unregister. Compare to addEventListener/removeEventListener where you have to keep the original function reference around — this is more ergonomic.

Set for subscriber storage. Topic 10.4 covered Set. We use it because it gives us automatic uniqueness (the same listener can't accidentally be added twice) and easy removal. A plain array would work but with more code.

Calling listeners on every update. Simple but works. A more sophisticated store would only call listeners for relevant changes (selector-based subscriptions). For our scope, calling everyone on every change is fine — listeners can check whether the parts of state they care about actually changed.

Wiring It All in main.js

Now the integration. This is the file that actually runs when the page loads.

// main.js — the integration

import { Store } from "./state/store.js";
import { Api } from "./data/api.js";
import { createCache } from "./data/cache.js";
import { renderList } from "./ui/list.js";
import { setupFilters } from "./ui/filters.js";
import { showError } from "./ui/errors.js";
import { debounce } from "./lib/utils.js";

// (For demo purposes, we inline minimal versions instead of importing)
// In real code, the imports above would resolve to the modules from earlier Topics.

class MockStore {
  constructor(s) { this.s = s; this.subs = new Set(); }
  getState() { return { ...this.s }; }
  update(p) { this.s = { ...this.s, ...p }; this.subs.forEach(f => f(this.s)); }
  subscribe(f) { this.subs.add(f); return () => this.subs.delete(f); }
}

// 1. Create the store with initial state
const store = new MockStore({
  query: "",
  sort: "name",
  items: [],
  loading: false,
  error: null
});

// 2. Subscribe the UI to state changes
store.subscribe(state => {
  console.log("Rendering with state:", state);
  // In real app: renderList(...), showError(...), etc.
});

// 3. Define how to perform a fetch
async function performFetch(query, sort) {
  store.update({ loading: true, error: null });

  try {
    // Simulate API call
    await new Promise(r => setTimeout(r, 100));
    const items = [
      { id: 1, name: `Result for "${query}"`, price: 10 }
    ];
    store.update({ items, loading: false });
  } catch (error) {
    store.update({ error, loading: false });
  }
}

// 4. Wire up debounced search
const debouncedSearch = debounce(() => {
  const { query, sort } = store.getState();
  performFetch(query, sort);
}, 200);

// 5. Demo: simulate user actions
store.update({ query: "shoes" });
debouncedSearch();

setTimeout(() => {
  store.update({ query: "shirts" });
  debouncedSearch();
}, 50);

function debounce(fn, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}

The structure:

Steps 1-2 — create state, subscribe UI. Store is created with sensible defaults. The UI subscribes; whenever state changes, the UI re-renders. The renderer just reads state and produces DOM.

Step 3 — define the action. performFetch is the orchestrator. It sets loading state, makes the API call, handles success or error. Errors are caught and stored in state (not thrown out) so the UI can display them.

Step 4 — wire up the user input. The debounced wrapper means typing fast doesn't fire many requests. Topic 5.8 covered the debounce pattern.

Step 5 — demo of how user actions flow. Update state → debounced trigger → API call → state update → re-render.

This is the unidirectional data flow that's become the industry standard. State is the source of truth. User actions update state. State changes drive UI updates. The components don't talk to each other directly — they all flow through state.

Error Handling Across Layers

A subtle but important pattern: each layer handles the errors it has context for, and propagates the rest.

graph TD
    A[fetchOnce<br/>HTTP layer] -->|throws on bad status| B[fetchWithRetry<br/>resilience layer]
    B -->|throws after retries exhausted| C[performFetch<br/>action layer]
    C -->|catches and stores in state| D[UI layer<br/>renders error from state]

    style A fill:#dc2626,color:#fff
    style B fill:#f59e0b,color:#fff
    style C fill:#10b981,color:#fff
    style D fill:#7c3aed,color:#fff
This diagram shows how errors flow through the application's layers. The lowest layer, fetchOnce, handles HTTP errors by checking response.ok and throwing on bad status. The retry layer above it catches those errors and decides whether to retry, eventually throwing after exhausting attempts. The action layer above catches the final error and stores it in state. The UI layer renders the error from state, including a retry button that triggers another action. Each layer has a specific job around errors, and errors flow up through them rather than being caught and silently swallowed at the wrong level.

The principle: never swallow an error at a layer that doesn't have enough context to handle it meaningfully. The HTTP layer doesn't know what to do with a 500 — that's an app-level decision. The retry layer doesn't know whether to show a UI message — that's a UI-level decision. By the time the error reaches the UI layer (via state), the right decisions have been made and the UI just needs to render.

Interactive animation: The last-resort net, for the catch you did not write: window listeners for error and unhandledrejection.

Performance Concerns

A few performance considerations from Module 13 that apply here:

The store update calls every subscriber on every change. For our scope, this is fine. With many subscribers and frequent updates, more sophisticated stores use selectors so listeners only fire when relevant state changes. We don't need that here.

renderList clears and rebuilds the DOM. Topic 12.3 covered the cost. For lists of dozens of items, this is fine. For lists of thousands, you'd want virtualization (only rendering the visible items) or diffing (only updating changed items). Both are major topics — frameworks like React implement them, and libraries like react-window provide virtualization.

The debounce on search prevents request flooding. Without debouncing, every keystroke would fire a request. With a 200ms debounce, only a "settled" query triggers the request. This is performance for the user (fewer wasted requests) and for the server (less load).

The AbortController cancels superseded requests. If you type fast and the second request supersedes the first, the first's response never updates the UI — and importantly, the network resource is freed. This matters for slow networks.

The cache avoids duplicate work. Same query within the TTL returns from cache. No network call, no re-render with the same data, no wasted CPU.

These are the performance patterns I'd expect a senior candidate to articulate when asked "what performance considerations does your dashboard have?"

Interactive animation: Module 13's workflow run on our app: the profiling session's findings, each paired with the action it called for.

Memory Lifecycle

A note on cleanup. In a single-page application, the dashboard might be navigated away from. We should handle cleanup:

// Conceptually — full cleanup
function teardown() {
  api.cancel();              // cancel any in-flight requests
  store.subscribe = null;    // (not actually how unsubscribe works — see below)
  // Remove event listeners — using AbortController for the listeners would help
}

For real cleanup, you'd:

In SPA frameworks, this happens automatically when components unmount. In vanilla code, you have to do it explicitly. Topic 13.5 covered why this matters — listeners and timers that don't get cleaned up are common memory leak sources.

Interactive animation: Flat, and explainable: why nothing leaks by design, from replaced state objects to cache entries that age out by TTL.

What We've Built

After five Topics, we have a working application. Roughly 300 lines of real code. It demonstrates:

Every major topic from Modules 6 through 13 is represented somewhere. The pieces work together, and the boundaries between them are where the real engineering happened.

Common Pitfalls Avoided

What we deliberately did NOT do:

Make the store a singleton via a module-level variable. Tempting but limits flexibility. The store is created once in main.js and injected wherever needed. This is dependency injection by composition.

Have the API class call document.getElementById. Mixing data and DOM concerns is a junior mistake. Keeping them separate makes both testable.

Catch errors at the lowest level and silently swallow them. Errors flow up to where they can be handled meaningfully. Silent failures are worse than visible ones.

Use var anywhere. Topic 3.1 made this case strongly. const by default, let when reassignment is needed, never var.

Try It

Exercise: Add a "favorites" feature to the dashboard:

  1. Add a favorites field to the store's initial state — a Set of item IDs
  2. Add a toggleFavorite(id) method to the store that adds or removes from the set
  3. Update renderList to show a star icon next to favorited items
  4. Wire up clicking the star to call toggleFavorite
  5. Persist favorites to localStorage so they survive page reloads

By the time this works, you've extended every layer — state, data persistence, UI, events. That's the experience a real feature implementation gives you.

Back to JavaScript Basic to Advance