JavaScript
Common JavaScript commands and how to use them
Everyday JavaScript that earns its place: array methods, destructuring, async/await, fetch, JSON, the DOM, and localStorage, with the pitfalls included.
9 min read
Most JavaScript you write in a browser or in Node is a small set of moves used over and over. This note is that set: how to reshape arrays, how to read objects that might be missing, how to talk to an HTTP API, and how to keep a draft on the machine in front of you. The examples are plain language, not a framework. Where a method surprises people, the surprise is called out rather than left as a comment in the code.
The running example is a list of orders. A real program would load these from an API. Here they are written out so the methods have something concrete to work on.
const orders = [
{ id: 1, status: "paid", total: 1200, email: "ada@example.com" },
{ id: 2, status: "draft", total: 0, email: "grace@example.com" },
{ id: 3, status: "paid", total: 800, email: "ada@example.com" },
];Array methods
map, filter, reduce, find, some, every, and includes do not change the array you call them on. They return a new array, a single value, or a boolean. If you need to mutate, you are looking at push, splice, or sort, and you should be deliberate about it, because sort does mutate.
map, filter, and reduce
map walks every element and builds a new array of the same length. The callback is (element, index, array). You usually only need the element.
const labels = orders.map((order) => `${order.id}: ${order.status}`);filter keeps the elements whose callback returns a truthy value. The new array can be shorter. It is not a search for one item.
const paid = orders.filter((order) => order.status === "paid");reduce folds the array into one value. Always pass the starting value. Without it, the first element becomes the accumulator and an empty array throws a TypeError.
const paidTotal = orders.reduce((sum, order) => {
return order.status === "paid" ? sum + order.total : sum;
}, 0);map does not visit holes in a sparse array. Array(3) is three empty slots, not three undefined values. Use Array.from when you meant real entries.
find, some, every, and includes
find returns the first element that matches, or undefined if none do. It does not return an array. The next line should cope with a miss.
const draft = orders.find((order) => order.status === "draft");
const draftId = draft?.id;find uses your callback. includes uses SameValueZero equality, which is strict equality plus a special case: NaN matches NaN. indexOf does not find NaN, which is why includes replaced it for membership tests.
[NaN].includes(NaN); // true
[NaN].indexOf(NaN); // -1
["paid", "refunded"].includes(orders[0].status);includes compares objects by reference, not by shape. [{ id: 1 }].includes({ id: 1 }) is false. For objects, use some or find.
some returns true if any callback returns true, and it stops at the first hit. every returns true only if every callback returns true, and it stops at the first miss. An empty array makes some return false and every return true. The empty every is the one that bites: "are all of these valid?" is vacuously true when the list is empty, so check the length if an empty list should fail.
const hasDraft = orders.some((order) => order.status === "draft");
const allPaid = orders.every((order) => order.status === "paid");
const none = [].every(() => false); // trueDestructuring and spread
Destructuring pulls values out of an object or array in the same place you bind the names. A default applies only when the value is undefined, not when it is null or 0.
const { email, status = "draft" } = orders[0];
const { email: customerEmail } = orders[0];
const [latest, ...older] = orders;Rename with email: customerEmail when the property name would clash. Nested patterns are fine if you give a default to the parent, otherwise a missing address throws:
const city = customer.address?.city;
const { address: { city: nested } = {} } = customer;The second line needs customer to exist. Optional chaining, covered below, is usually clearer for a deep read.
Spread copies enumerable own properties, shallowly. Later keys win. Spreading null or undefined into an object is a no-op. Spreading them into an array throws, because array spread expects an iterable.
const renamed = { ...orders[0], status: "refunded" };
const combined = [...paid, draft].filter(Boolean);
const untouched = { ...null }; // {}Spread is shallow. A nested object is still shared with the original. structuredClone is the deep copy for plain data. JSON is a worse copy: it drops undefined and functions, and it turns dates into strings.
Optional chaining and nullish coalescing
?. stops the chain when the value on its left is null or undefined, and the whole expression becomes undefined. It does not treat 0, "", or false as missing. Use it for reads, not for assignments. obj?.prop = 1 is a syntax error.
const city = customer?.address?.city;
const first = orders?.[0];
const name = customer?.getName?.();?? is the partner for defaults. It replaces only null and undefined. || replaces anything falsy, so a legitimate 0 or "" disappears.
const limit = input ?? 20;
const broken = input || 20; // 0 becomes 20You cannot mix ?? with || or && without parentheses. input ?? fallback || 20 is a SyntaxError. Write (input ?? fallback) || 20 if you truly mean both, and then check that you do. Most of the time ?? alone is the one you wanted.
Template literals
Backticks build a string and interpolate expressions. The line break in the source is part of the string, which is what you want for a multi-line message and what you do not want if you indented the second line for show.
const label = `${orders[0].email} (${paid.length} paid)`;Keep the expression inside ${} boring. If it throws, the whole template throws.
async/await and fetch
An async function always returns a promise. await pauses that function, not the rest of the program, until the promise settles. A rejection becomes a throw, so try/catch works.
fetch rejects when the network fails. It does not reject when the server answers 404 or 500. Check response.ok (status 200–299) yourself, then read the body. response.json() is another promise, and it throws if the body is not JSON, which is what you get when a proxy returns an HTML error page.
async function loadOrder(id) {
const response = await fetch(`/api/orders/${id}`);
if (!response.ok) {
throw new Error(`Order request failed: HTTP ${response.status}`);
}
return response.json();
}A browser will not let a page read a response from another origin unless that origin sends the right CORS headers. fetch("https://example.com") from a page on your site can fail for that reason even when the URL works in the address bar. Same-origin paths such as /api/orders/1 do not have that problem.
await in a loop runs one step at a time. That is right when each call needs the previous result, and slow when the calls are independent. Independent calls belong in Promise.all.
Promise.all
Promise.all takes an iterable of promises and returns one promise for the whole list. The result array keeps the input order, not the order the requests happened to finish. If any promise rejects, the combined promise rejects immediately. The other requests are not cancelled; you just stop waiting for them. Use Promise.allSettled when you need every outcome, including the failures.
async function loadOrderPage(id) {
const [orderResponse, notesResponse] = await Promise.all([
fetch(`/api/orders/${id}`),
fetch(`/api/orders/${id}/notes`),
]);
if (!orderResponse.ok || !notesResponse.ok) {
throw new Error("One of the order requests failed");
}
const [order, notes] = await Promise.all([
orderResponse.json(),
notesResponse.json(),
]);
return { order, notes };
}Do not fire an unbounded Promise.all at thousands of URLs. You will stampede the server. Batch them.
forEach does not wait for async callbacks. The function returns before the saves finish, and a rejection becomes an unhandled rejection.
async function saveAll(list) {
await Promise.all(list.map((order) => saveOrder(order)));
}That is the version that actually waits. list.forEach(async (order) => { await saveOrder(order); }) is the version that does not.
Console tricks
console.log is fine. A few neighbours are better for particular shapes of data.
console.table(paid) prints an array of objects as a table, which is the fastest way to scan id, status, and total. console.dir(order, { depth: 4 }) shows a deep object that log would collapse. console.group("paid") / console.groupEnd() indents a block of lines so a noisy function stays readable. console.time("load") and console.timeEnd("load") measure a stretch of work without pulling in a profiler. console.assert(paidTotal >= 0, "total went negative") logs only when the condition is false. It does not throw.
Do not log tokens or a customer's full record. The console is visible to anyone who can open devtools, and a production log line is easy to forget.
JSON
JSON.stringify turns a value into text. JSON.parse turns text back into a value. JSON is a subset of JavaScript: double quotes, no trailing commas, no comments, no undefined, no functions.
const text = JSON.stringify({ email: "ada@example.com", total: 1200 }, null, 2);
const value = JSON.parse(text);The third argument to stringify is the number of spaces, useful when a human will read the file. A few conversions catch people out:
undefined, functions, and symbols are omitted from objects. In an array,undefinedbecomesnull.NaNandInfinitybecomenull.- A
Datebecomes an ISO string, andJSON.parsedoes not turn it back into aDate. - Keys are strings. A
Mapserialises as{}unless you convert it first.
Object.entries and Object.fromEntries are the usual way to convert a Map of string keys, or to filter an object's fields before you serialise it.
Do not eval JSON. It is text from somewhere else, and eval runs it as a program.
Object.entries and Object.fromEntries
Object.entries returns an array of [key, value] pairs for the object's own enumerable string keys. Symbol keys are not included. Integer-like keys ("10") are listed in numeric order first, then the other string keys in insertion order. That order is part of the language, and it surprises people who expected pure insertion order.
Object.fromEntries builds an object from those pairs. Duplicate keys: the later one wins.
function withoutNulls(record) {
return Object.fromEntries(
Object.entries(record).filter(([, value]) => value != null),
);
}value != null drops both null and undefined, and keeps 0 and "". Use !== undefined if null is a meaningful value you must keep.
querySelector and events
document.querySelector returns the first matching element, or null. querySelectorAll returns a static NodeList. Static means it does not update if you add elements later. It is not an array; spread it or use Array.from if you want array methods. A modern browser will also let you forEach the list directly.
const form = document.querySelector("#order-form");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const email = String(data.get("email") ?? "");
});The optional call is there because the element might not be on this page. Calling addEventListener on null throws.
Prefer addEventListener to an onclick attribute. You can register more than one listener, and you can remove one later. removeEventListener only works if you pass the same function reference, so a fresh arrow function each time cannot be removed. Keep the function in a variable if you need to detach it.
preventDefault cancels the browser's default action, such as submitting a form or following a link. stopPropagation stops the event travelling further up the tree. They are different. Cancelling a click does not by itself stop a parent listener.
Event delegation uses that bubbling. One listener on a parent handles clicks on children, including children added later.
const list = document.querySelector("#orders");
list?.addEventListener("click", (event) => {
const button = event.target.closest("button[data-id]");
if (!button || !list.contains(button)) return;
const id = button.dataset.id;
console.log("open", id);
});closest walks from the click target up through ancestors, so a click on an icon inside the button still finds the button. The contains check ignores clicks that bubbled in from outside the list.
For scroll and touch listeners that do not call preventDefault, pass { passive: true } so the browser can scroll without waiting for your function.
localStorage
localStorage is a key-value store for strings, scoped to the origin (scheme, host, and port). It survives closing the tab. sessionStorage has the same API and is cleared when the tab closes. Both are synchronous, and both can throw: private-mode restrictions, a disabled storage policy, or a full quota (often around 5 MB per origin, but the limit is not a promise).
const KEY = "kw.order-draft";
function saveDraft(draft) {
try {
localStorage.setItem(KEY, JSON.stringify(draft));
} catch (error) {
console.warn("Could not save the draft on this device", error);
}
}
function loadDraft() {
try {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}Store JSON text, not an object. setItem stringifies with ToString, so an object becomes the useless "[object Object]".
The storage event fires in other tabs of the same origin when a key changes. It does not fire in the tab that wrote the value. That is how you can refresh a second window, and why listening in the same window does nothing.
localStorage is readable by any script on the origin. A cross-site scripting bug leaks everything in it. Do not keep access tokens, passwords, or card numbers there. A draft order form is a reasonable use. A session secret is not.
Clearing site data in the browser removes the store. These snippets do not send the draft anywhere.
Common pitfalls
A short list of the mistakes that account for most of the bugs in this set:
reduceon an empty array without an initial value throws.everyon an empty array returns true.||treats0and""as missing. Use??when those are real values.fetchtreats HTTP errors as success until you checkresponse.ok.forEachwith anasynccallback does not wait, and it swallows the moment you needed to catch.JSON.stringifydropsundefinedobject fields and turns dates into strings.localStorage.setItem("k", obj)stores"[object Object]".querySelectorreturnsnull, and the next property access throws.- Array spread of
nullthrows. Object spread ofnulldoes not. includesdoes not deep-compare objects, andindexOfdoes not findNaN.