JavaScript Best Practices in 2026: 15 Rules for Clean JS
Modern JavaScript best practices — const over var, strict equality, optional chaining, immutable updates, async/await, Intl formatting, Web Workers and more — each with why it matters and a code example.
JavaScript keeps evolving — optional chaining, nullish coalescing, immutable array methods, structuredClone and AbortController are all standard now — and writing clean, robust JS in 2026 means using them well. Here are 15 modern JavaScript best practices, each with the reasoning and a code example.
1. Use const by default, let when reassigning — never var
const and let are block-scoped and are not usable before declaration, eliminating the leakage and closure bugs that var’s function scoping causes. const also signals a binding won’t be reassigned. Note const prevents *reassignment*, not mutation — const arr = []; arr.push(1) is legal.
// ❌ var leaks past the block; all callbacks see 3
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3,3,3
// ✅ let is per-iteration block-scoped
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0,1,2
2. Use strict equality (===); avoid == coercion
== applies surprising coercion rules — 0 == "", null == undefined and [] == false are all true — while === compares without coercion, so results are predictable. The one idiomatic use of == is the deliberate x == null to test "null or undefined" in a single check. Enforce with ESLint eqeqeq.
// ❌ coercion traps
0 == ""; // true
"" == false; // true
null == undefined; // true
// ✅ strict
0 === ""; // false
3. Use optional chaining (?.) with nullish coalescing (??)
?. short-circuits access when an intermediate is null/undefined instead of throwing, and ?? supplies a fallback *only* for null/undefined — unlike ||, it preserves valid falsy values like 0 and "". Use ?. for genuinely optional paths, not to paper over unmodeled data.
// ❌ throws if address is missing; || eats a valid 0
const city = user.address.city || "N/A";
const qty = cart.count || 1; // wrong when count is 0
// ✅ safe access + correct fallback
const city = user?.address?.city ?? "N/A";
const qty = cart?.count ?? 1; // keeps 0
4. Update data immutably — don't mutate shared state
Mutating objects or arrays that other code (state, caches, props) references causes hard-to-trace bugs and defeats change detection. Produce new values with spreads and the ES2023 non-mutating array methods (toSorted, toReversed, toSpliced, with). Spread is shallow — for deep updates use structuredClone or Immer.
// ❌ mutates the original in place
list.sort(cmp);
user.roles.push("admin");
// ✅ new copies, originals untouched
const sorted = list.toSorted(cmp);
const updated = { ...user, roles: [...user.roles, "admin"] };
5. Prefer async/await over Promise chains, with try/catch
await reads like synchronous code and keeps error handling in one try/catch, versus .then().catch() chains that fragment logic and easily swallow errors. A rejected promise you never await or catch becomes an unhandled rejection.
// ❌ nested chains
function load() {
return fetchUser().then(u => fetchPosts(u.id)).then(render).catch(handle);
}
// ✅ linear + centralized errors
async function load() {
try {
const u = await fetchUser();
render(await fetchPosts(u.id));
} catch (err) {
handle(err);
}
}
6. Run independent async work in parallel with Promise.all
Sequential awaits waste time when the calls do not depend on each other; Promise.all runs them concurrently so total time is about the slowest, not the sum. Use Promise.allSettled when you need every result regardless of individual failures, and Promise.any for "first success".
// ❌ sequential: a + b + c
const a = await getA(); const b = await getB(); const c = await getC();
// ✅ parallel: max(a, b, c)
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);
7. Transform data with map/filter/reduce — appropriately
Declarative array methods express intent ("map each", "keep matching") more clearly than manual index loops and avoid off-by-one and mutation bugs. Don’t force everything into reduce, though — a reduce that rebuilds an object each iteration is often slower and less readable than a plain for...of. Use the tool that reads clearest.
// ❌ imperative accumulation
const names = [];
for (let i = 0; i < users.length; i++)
if (users[i].active) names.push(users[i].name);
// ✅ declarative pipeline
const names = users.filter(u => u.active).map(u => u.name);
8. Use ES modules and named exports; avoid globals
Modules give explicit imports/exports and scope isolation instead of polluting window. Named exports keep import names consistent, enable reliable tree-shaking and make refactors safe. Reserve a default export for a module’s single obvious main thing (e.g. a React component).
// ❌ global namespace pollution
window.formatDate = (d) => { /* ... */ };
// ✅ named exports
// date.js
export function formatDate(d) { /* ... */ }
// consumer.js
import { formatDate } from "./date.js";
9. Use guard clauses and early returns
Handling edge cases up front and returning early keeps the happy path unindented and readable, avoiding deep if/else pyramids.
// ❌ nested arrow of doom
function pay(user) {
if (user) { if (user.active) { if (user.balance > 0) { /* charge */ } } }
}
// ✅ flat with guards
function pay(user) {
if (!user) return;
if (!user.active) return;
if (user.balance <= 0) return;
/* charge */
}
10. Format dates, numbers and currency with Intl
Intl handles locale rules, time zones, currency symbols and pluralization correctly — hand-rolled string formatting breaks across locales and is a common source of bugs. It is built in and needs no dependency. Constructing an Intl.*Format is relatively expensive, so create it once and reuse it.
// ❌ hand-rolled, locale-blind
const price = "$" + (amount / 100).toFixed(2);
// ✅ locale + currency aware
const price = new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
}).format(amount / 100);
new Intl.RelativeTimeFormat("en").format(-3, "day"); // "3 days ago"
11. Debounce or throttle expensive event handlers
High-frequency events (input, scroll, resize) fire dozens of times a second; running expensive work on each janks the UI. Debounce for "only the final value" (search, autosave); throttle for "steady updates during motion" (scroll, drag). Prefer a maintained implementation over hand-rolled versions that leak timers.
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
searchInput.addEventListener("input", debounce(runSearch, 300));
12. Keep heavy computation off the main thread with Web Workers
JavaScript is single-threaded on the main thread, so a long CPU task (parsing, image processing, big sorts) freezes rendering and input. Web Workers run that work on another thread, keeping the UI responsive. Use them for CPU-bound work; for I/O-bound waiting, plain async/await is the right tool.
// main.js
const worker = new Worker(new URL("./sort.worker.js", import.meta.url), { type: "module" });
worker.postMessage(bigArray);
worker.onmessage = (e) => render(e.data);
// sort.worker.js
onmessage = (e) => postMessage(e.data.toSorted((a, b) => a - b));
13. Deep-copy with structuredClone
structuredClone is the built-in way to deep-copy nested objects, arrays, Maps, Sets, Dates and typed arrays — replacing the lossy JSON.parse(JSON.stringify(x)) hack. It cannot clone functions, DOM nodes or class prototypes, so for those clone manually.
// ❌ loses Dates, Maps, undefined; throws on cycles
const copy = JSON.parse(JSON.stringify(state));
// ✅ handles nested structures, Dates, Maps, and cycles
const copy = structuredClone(state);
14. Prevent memory leaks — remove listeners, timers, observers
Event listeners, intervals and observers hold references that keep objects alive after they are no longer needed, growing memory over a session. Every subscription needs a matching teardown. The AbortController signal is the cleanest 2026 pattern — one abort() can remove many listeners and cancel in-flight fetches sharing the signal.
const controller = new AbortController();
element.addEventListener("scroll", onScroll, { signal: controller.signal });
// later:
controller.abort(); // removes the listener and cancels fetches on this signal
15. Enforce quality with ESLint + Prettier (flat config)
A linter catches real bugs (unused vars, missing await, hook violations) and a formatter ends style debates. In 2026 the standard is ESLint’s flat config (eslint.config.js), which replaced the legacy .eslintrc cascade. Let ESLint handle correctness and Prettier handle formatting — add eslint-config-prettier last so they do not fight.
// eslint.config.js — flat config
import js from "@eslint/js";
import prettier from "eslint-plugin-prettier/recommended";
export default [
js.configs.recommended,
prettier, // MUST be last: disables rules that conflict with Prettier
];
JavaScript best practices checklist
constby default,letto reassign, nevervar; use===(exceptx == null).- Reach for
?.and??; update data immutably with spreads andtoSorted/with. - Prefer
async/awaitwithtry/catch; parallelize independent work withPromise.all. - Use
map/filter/reducewhere they read clearest; guard clauses over nested ifs. - Format with
Intl; debounce/throttle hot handlers; offload CPU work to Web Workers. - Deep-copy with
structuredClone; clean up listeners/timers (AbortController signal). - Use ES modules with named exports; enforce with ESLint (flat config) + Prettier.
Frequently asked questions
Why use === instead of == in JavaScript?
== coerces types before comparing, producing surprises like 0 == "" and [] == false both being true. === compares without coercion, so results are predictable. The only idiomatic use of == is x == null to check for null-or-undefined in one go.What is the difference between ?? and ||?
|| returns the right side for any falsy left value, including 0, "" and false. ?? (nullish coalescing) only falls back when the left side is null or undefined, so it preserves valid falsy values — use ?? for defaults like count ?? 1.How do I deep-copy an object in modern JavaScript?
structuredClone(obj). It deep-copies nested objects, arrays, Maps, Sets, Dates and typed arrays and handles cycles — unlike JSON.parse(JSON.stringify(obj)), which loses Dates/Maps/undefined and throws on circular references. It cannot copy functions or DOM nodes.When should I use a Web Worker?
async/await is the correct tool, not a worker.Practice these in the browser: open XCODX Studio and run modern JavaScript with a live console — try structuredClone, Promise.all, Intl and immutable array methods instantly. Deciding between JS and TS? See JavaScript vs TypeScript.