Today’s goal
Move beyond syntax toward the language model needed for modern front-end work.
We will connect:
- scope and closures;
- objects and composition;
- data transformation and immutability;
- modules and dynamic loading;
- promises and asynchronous execution;
- concurrency, races, and cancellation;
- locale-aware output.
By the end of today you can
- explain where a JavaScript binding lives;
- use closures deliberately without confusing them with copied values;
- describe prototype delegation and why classes do not remove it;
- choose data transformation methods for clarity;
- update nested state without accidental mutation;
- design module boundaries and dynamic imports;
- distinguish sequential work from concurrent work;
- handle rejected promises and cancellation explicitly;
- prevent stale results from winning a race;
- format numbers, dates, and plurals for a locale.
Lexical scope: where a name can be used
const applicationName = "Citizen Portal";
function showName() {
console.log(applicationName);
}
showName();The function can look outward to the scope that surrounds it.
An outer scope cannot look inward into a function’s private bindings.
Scope follows the source-code structure. It is lexical, not based on which function happened to call another function at runtime.
Block scope is a lifetime boundary
if (loggedIn) {
const message = "Welcome back";
console.log(message);
}
console.log(message); // ReferenceError
Practical rule:
- prefer
constby default; - use
letwhen reassignment is required; - understand that both respect block scope;
- treat a block as a useful lifetime boundary.
Closures retain bindings
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
createCounter() finished, but increment retains access to its lexical
environment.
The closure does not copy count. It retains access to the binding.
Closures appear everywhere in front-end code
function attachFilter(input, applyFilter) {
input.addEventListener("input", () => {
applyFilter(input.value);
});
}The callback remembers input and applyFilter from the surrounding scope.
Common uses include:
- event handlers;
- callback configuration;
- private state factories;
- debounced functions;
- memoized computations;
- request controllers.
Closures can keep data alive
function createLogger(records) {
return (message) => {
records.push({ message, at: Date.now() });
};
}As long as the returned logger is reachable, records remains reachable too.
Closures provide privacy and state, but they can also retain large objects, DOM nodes, or subscriptions longer than intended.
Release listeners, timers, and references when the feature is destroyed.
Objects have a prototype model
An object can delegate property lookup to another object through its prototype.
const service = {
describe() {
return "public service";
}
};
const request = Object.create(service);
request.id = 1042;
request.describe(); // delegated lookup
The object does not need to own every method directly.
Spread is not a deep copy
const original = { preferences: { theme: "dark" } };
const copy = { ...original };
copy.preferences.theme = "light";
console.log(original.preferences.theme); // light
The outer object is new. The nested object is still shared.
Know which references remain shared before calling something an immutable update.
Optional chaining is a guarded lookup
const phone = applicant?.contacts?.primaryPhone;It is useful when a value is legitimately absent.
It does not:
- validate the data shape;
- supply a meaningful fallback;
- prove that a missing value is acceptable;
- repair a broken API contract.
Use it where absence is part of the model, not everywhere as a blanket guard.
Transform collections by intent
const labels = requests.map(request => request.statusLabel);
const open = requests.filter(request => request.status === "open");
const selected = requests.find(request => request.id === id);
const hasUrgent = requests.some(request => request.priority === "urgent");
const allValid = fields.every(field => field.valid);Each method communicates a different question. Prefer the method whose name matches the operation.
Use reduce() when it clarifies
const totals = requests.reduce((summary, request) => {
summary[request.status] = (summary[request.status] ?? 0) + 1;
return summary;
}, {});reduce() can express aggregation, but it can also hide a complicated
algorithm inside one callback.
Use a loop or named helper when the steps matter more than compactness.
Identity and change detection
const same = state.filters === nextState.filters; // false
const sameList = state.requests === nextState.requests; // true
Identity can communicate which part of a state tree changed.
That does not mean every object must be recreated on every update. Preserve references when the value did not change.
Do not turn immutability into dogma
Mutation can be reasonable when:
- the object is local to one operation;
- no other consumer observes it;
- the mutation improves clarity or performance;
- ownership is explicit.
The architectural question is not “mutation or no mutation?”
It is:
Who can observe this value, and what change signal do they rely on?
ES Modules create boundaries
// format-status.js
export function formatStatus(status) {
return status.replaceAll("-", " ");
}
// screen.js
import { formatStatus } from "./format-status.js";Modules provide:
- explicit dependencies;
- module scope;
- reusable exports;
- a graph the build tool can inspect.
Modules are architecture, not only file organization.
Named and default exports
Named exports make the public vocabulary explicit:
export function parseRequest() {}
export function validateRequest() {}Default exports can represent one primary value:
export default class RequestClient {}Choose a consistent convention. Avoid making consumers guess whether a module has one canonical thing or a set of named capabilities.
Dynamic imports load on demand
button.addEventListener("click", async () => {
const { openReport } = await import("./report.js");
openReport();
});Dynamic imports can reduce initial work and defer rarely used features.
They add asynchronous failure, loading state, and chunk-boundary concerns. A dynamic import is not free merely because it is written in one line.
Iteration is more than for loops
An iterable provides a protocol for producing values over time.
for (const request of requests) {
renderRequest(request);
}Arrays, strings, Maps, Sets, DOM collections, and custom objects can participate in iteration.
The protocol separates “how values are produced” from “how they are consumed.”
Iterators produce one value at a time
const iterator = ["queued", "open"].values();
iterator.next(); // { value: "queued", done: false }
iterator.next(); // { value: "open", done: false }
iterator.next(); // { value: undefined, done: true }
The done flag is part of the contract. Iterators can represent sequences that
are calculated lazily rather than stored as a complete array.
From callbacks to promises
Callbacks can express completion and failure, but nested workflows become hard to compose:
loadUser(id, user => {
loadRequests(user, requests => {
render(requests);
}, handleError);
}, handleError);Promises represent a future result and provide composable success and failure paths.
Creating promises is an ownership decision
function wait(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}Create a promise when adapting a callback or exposing an asynchronous contract.
Do not wrap an API in a new promise without a reason; unnecessary wrappers can hide errors and complicate cancellation.
Promise chaining carries values forward
fetch("/api/requests")
.then(response => response.json())
.then(requests => requests.filter(isVisible))
.then(renderRequests);Each handler returns the value or promise for the next step.
Returning nothing intentionally passes undefined; forgetting to return is a
common source of a broken chain.
Handle errors at the right boundary
async function loadScreen() {
try {
const response = await fetch("/api/requests");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
showRecoverableError(error);
throw error;
}
}Do not catch an error merely to log it and then pretend the operation succeeded.
Not every failure is a network error
An async operation can fail because:
- the request was blocked or offline;
- the server returned a non-2xx response;
- the body was invalid JSON;
- validation rejected the data;
- rendering code threw;
- the user cancelled the work;
- a stale result was deliberately ignored.
Your error model should preserve the difference when the UI needs it.
Promise.all() expresses all-or-nothing coordination
const [categories, featured] = await Promise.all([
loadCategories(),
loadFeatured()
]);The operations start without waiting for one another. The combined promise fulfils when all fulfil and rejects when one rejects.
Use it when one failure should prevent the combined result from being used.
Promise.allSettled() preserves every outcome
const results = await Promise.allSettled([
loadRecommendations(),
loadAnnouncements(),
loadOptionalMetrics()
]);Use it when partial success is meaningful and each result needs its own status.
The choice communicates product behaviour, not only JavaScript preference.
Concurrency is not parallelism
Several asynchronous operations can be in flight at once while JavaScript continues on its event loop.
That does not mean JavaScript is running those callbacks on several CPU cores.
Concurrency is about overlapping waiting and coordinating completion.
Parallelism is about simultaneous execution.
Strategy 1: ignore stale results
let latestQuery = "";
async function search(query) {
latestQuery = query;
const result = await fetchResults(query);
if (query !== latestQuery) return;
renderResults(result);
}This is simple and safe when the old work is harmless. It still spends the resources needed to finish the old request.
Strategy 2: cancel outdated work
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
});
return response.json();
}Cancellation can save work and communicate that the previous intent is no longer relevant.
The UI must distinguish cancellation from an unexpected failure.
Cancellation is an application design problem
Ask:
- What work belongs to the cancelled intent?
- Who owns the controller?
- What resources need cleanup?
- Should cancellation be silent or visible?
- Can a new operation replace the old one safely?
- What happens if cancellation occurs after the result arrives?
Adding AbortController without defining ownership only moves the ambiguity.
Debouncing delays the start of work
function debounce(callback, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => callback(...args), delay);
};
}Debouncing waits for a pause in input before starting work.
It reduces the number of operations. It does not cancel a request that already started.
A cancelable search: step 2
Cancel the previous request when a newer query becomes authoritative:
let activeController;
async function runSearch(query) {
activeController?.abort();
activeController = new AbortController();
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: activeController.signal
});
return response.json();
}The feature owns the controller because it owns the current search intent.
A cancelable search: step 3
Handle cancellation separately from failure:
try {
const results = await runSearch(query);
renderResults(results);
} catch (error) {
if (error.name === "AbortError") return;
showSearchError(error);
}Cancellation is expected control flow. It should not flash an error message for the user.
Try it yourself
Choose one asynchronous feature in an application you know.
Answer:
- Can two operations be in flight together?
- What makes an old result stale?
- Who owns cancellation?
- Which failures are expected user flow?
- Where should locale formatting happen?
Write one explicit contract before changing the code.
Troubleshooting questions (Part 1)
| Symptom | First question |
|---|---|
| A variable is unavailable | Which lexical scope owns it? |
| A callback uses old state | Which binding did the closure retain? |
A promise chain returns undefined | Did each handler return its next value? |
| Independent requests are slow | Are they accidentally awaited sequentially? |
Completion check
- I can explain lexical scope and closures.
- I understand prototype delegation beneath class syntax.
- I can choose clear collection transformations.
- I can update nested state while preserving useful identity boundaries.
- I can design a module graph with explicit dependencies.
- I can distinguish sequential work from concurrent work.
- I can explain promise rejection and error ownership.
- I can identify and prevent stale asynchronous results.
- I can use cancellation and debouncing for different purposes.
- I can format numbers, dates, plurals, and text for a locale.
- I completed the cancelable locale-aware search practical.