Free lesson · JavaScript Basic to Advance
Putting It Together: State, Errors, Performance
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:
- Cancel the in-flight API request via the AbortController
- Unsubscribe all listeners from the store
- Remove all DOM event listeners (or use AbortController for them too — Topic 12.4)
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:
- ES Modules (Module 11)
- Classes with private fields (Module 8)
- Closures for private state in factory functions (Module 6)
- Promises, async/await, error handling (Module 9)
- AbortController for cancellation (Modules 9, 12)
- DOM manipulation with event delegation (Module 12)
- Debouncing (Module 5)
- Immutable state updates (Module 10)
- Composition over inheritance (Module 8)
- Performance considerations (Module 13)
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:
- Add a
favoritesfield to the store's initial state — a Set of item IDs- Add a
toggleFavorite(id)method to the store that adds or removes from the set- Update
renderListto show a star icon next to favorited items- Wire up clicking the star to call
toggleFavorite- Persist favorites to
localStorageso they survive page reloadsBy the time this works, you've extended every layer — state, data persistence, UI, events. That's the experience a real feature implementation gives you.