KeyboardWarrior.co.ukclient-side utility arcade

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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
[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.

JavaScript
const hasDraft = orders.some((order) => order.status === "draft");
const allPaid = orders.every((order) => order.status === "paid");
const none = [].every(() => false); // true

Destructuring 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.

JavaScript
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:

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
const limit = input ?? 20;
const broken = input || 20; // 0 becomes 20

You 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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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, undefined becomes null.
  • NaN and Infinity become null.
  • A Date becomes an ISO string, and JSON.parse does not turn it back into a Date.
  • Keys are strings. A Map serialises 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.

JavaScript
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.

JavaScript
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.

JavaScript
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).

JavaScript
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:

  • reduce on an empty array without an initial value throws.
  • every on an empty array returns true.
  • || treats 0 and "" as missing. Use ?? when those are real values.
  • fetch treats HTTP errors as success until you check response.ok.
  • forEach with an async callback does not wait, and it swallows the moment you needed to catch.
  • JSON.stringify drops undefined object fields and turns dates into strings.
  • localStorage.setItem("k", obj) stores "[object Object]".
  • querySelector returns null, and the next property access throws.
  • Array spread of null throws. Object spread of null does not.
  • includes does not deep-compare objects, and indexOf does not find NaN.