JavaScript Code Review Checklist: ES6+ Best Practices

Published on
Written byChristoffer Artmann
JavaScript Code Review Checklist: ES6+ Best Practices

The this keyword behaves unexpectedly in callbacks, breaking code that works in simple tests. Unhandled promise rejections crash production silently. Type coercion makes == comparisons return surprising results. Callback hell creates unmaintainable code that's impossible to debug. These issues run without errors until specific conditions trigger failures.

This checklist helps catch what matters: this binding errors, async handling mistakes, type coercion bugs, and code that fights modern JavaScript instead of leveraging ES6+ features effectively.

Quick Reference Checklist

Use this checklist as a quick reference during code review. Each item links to detailed explanations below.

Modern Syntax & Features

  • const and let used instead of var
  • Arrow functions => for callbacks and functional operations
  • Template literals for string interpolation: `Hello ${name}`
  • Destructuring extracts values: const { name, age } = user
  • Spread operator copies arrays/objects: { ...obj, newProp: value }
  • Default parameters instead of || fallbacks: function greet(name = 'Guest')
  • Rest parameters for variable arguments: function sum(...numbers)
  • Object property shorthand: { name } instead of { name: name }

Async Patterns

  • Promises used instead of callbacks for async operations
  • async/await preferred over raw promises for readability
  • Promise rejections handled with .catch() or try-catch
  • Promise.all() for parallel operations, not sequential awaits
  • Promise.allSettled() when all results needed regardless of failures
  • No async functions without await (misleading)
  • Error handling wraps async calls: try { await fetch() } catch (e) {}
  • Unhandled rejections logged or monitored

Common Pitfalls

  • === used instead of == for comparisons
  • !== used instead of != for inequality
  • Array methods return new arrays (immutable): map(), filter() vs push(), splice()
  • this binding explicit: arrow functions or bind()
  • Closures used intentionally, not accidentally capturing variables
  • typeof checks before operations: typeof value === 'string'
  • null vs undefined understood and handled correctly
  • Numbers validated before arithmetic: Number.isNaN(), Number.isFinite()

Functions & Scope

  • Pure functions without side effects when possible
  • Function parameters limited (prefer objects for many parameters)
  • Single responsibility: functions do one thing well
  • Early returns reduce nesting
  • No modifying parameters (creates hidden side effects)
  • Closures clean up resources when needed
  • IIFE used only when actually needed (rare in modern code)
  • No function hoisting relied upon for control flow

Arrays & Objects

  • Array methods used functionally: map(), filter(), reduce()
  • Mutation avoided: prefer new arrays/objects
  • Optional chaining for nested access: user?.profile?.email
  • Nullish coalescing for defaults: value ?? fallback
  • Object spread preserves immutability: { ...obj, updated: true }
  • Array destructuring for multiple returns: const [first, second] = array
  • Object.keys(), Object.values(), Object.entries() for iteration
  • Array.from() for array-like objects, not manual loops

Modules & Organization

  • ES6 modules used: import/export
  • Named exports for multiple exports, default for single export
  • No mixing default and named exports from same module
  • Import statements at top of file
  • No circular dependencies between modules
  • Side effect imports explicit: import './styles.css'
  • Dynamic imports for code splitting: import('./module').then()
  • Module names clear and descriptive

Modern JavaScript Syntax

const and let replaced var for good reasons. const prevents reassignment, making code easier to reason about—when we see const, we know the binding won't change. let provides block scope, eliminating the confusing function scope behavior of var. When we see var in code review, we suggest const for values that don't change and let for those that do. The only remaining use for var is intentionally creating function-scoped variables, which is rarely needed.

Arrow functions provide concise syntax and lexical this binding. For callbacks and functional operations, array.map(x => x * 2) reads clearer than traditional function syntax. More importantly, arrow functions inherit this from the surrounding scope, eliminating the common mistake of losing this in callbacks. When we see .bind(this) or storing this in variables, arrow functions often eliminate the need.

Template literals make string interpolation readable. Instead of concatenation like 'Hello ' + name + '!', we write `Hello ${name}!`. This improves readability for complex strings and automatically calls toString() on values. Template literals also support multi-line strings without escape sequences, useful for HTML or other formatted text.

Destructuring extracts values from arrays and objects concisely. The pattern const { name, age } = user extracts properties into variables without repetitive user.name access. Array destructuring const [first, second] = array provides clean syntax for multiple return values. When we see code manually extracting properties one by one, destructuring improves clarity.

Spread operator ... copies and merges arrays and objects. { ...obj, updated: true } creates a new object with obj's properties plus the update, leaving obj unchanged. This immutable pattern prevents bugs from unexpected mutations. Array spread [...array1, ...array2] concatenates without modifying originals. When we see manual property copying or array concatenation, spread syntax simplifies the code.

Default parameters handle optional arguments better than || checks. The pattern function greet(name = 'Guest') provides a default without the problematic behavior of name = name || 'Guest' which treats empty string as falsy. Default parameters work only for undefined, not for falsy values, which matches typical intent.

Async Programming Patterns

Promises represent asynchronous operations more compositionally than callbacks. Instead of callback hell where each async operation nests deeper, promises chain with .then(): fetch(url).then(response => response.json()).then(data => process(data)). This flattens code structure and makes error handling consistent with .catch(). When we see heavily nested callbacks, promises improve readability.

async/await makes asynchronous code read like synchronous code. The pattern const data = await fetch(url).then(r => r.json()) looks synchronous but doesn't block. This dramatically improves readability compared to promise chains. When reviewing async code, we verify await appears in async functions and that errors are handled with try-catch.

Unhandled promise rejections silently fail unless caught. Every promise chain needs .catch() or the await must be in a try-catch. Code that creates promises without error handling creates bugs that are hard to diagnose because failures don't surface. When we see promises without error handling, that's a critical issue requiring immediate attention.

Promise.all() runs operations in parallel, while sequential awaits run serially. The pattern await Promise.all([fetch(url1), fetch(url2)]) starts both fetches simultaneously and waits for both. Sequential await fetch(url1); await fetch(url2) waits for each to complete before starting the next, taking twice as long. When operations don't depend on each other, Promise.all improves performance.

Promise.allSettled() waits for all promises to settle, whether fulfilled or rejected. Unlike Promise.all which rejects if any promise rejects, allSettled returns all results. This proves useful when we need outcomes from all operations regardless of failures. When partial results matter, allSettled prevents losing successful results due to one failure.

Async functions without await mislead readers. Declaring async function but never awaiting suggests misunderstanding. The async keyword makes the function return a promise even without await, which might not be intended. When we see async functions without await, we verify the promise return is intentional.

Common JavaScript Pitfalls

Strict equality === prevents type coercion surprises. The comparison '5' == 5 evaluates to true because == coerces types. This creates hard-to-find bugs when mixed types appear unexpectedly. === compares without coercion, making comparisons predictable. When we see == in code review, we suggest === unless there's specific reason for type coercion.

Array methods like map() and filter() return new arrays and don't mutate the original. This immutable pattern prevents bugs from unexpected mutations. Mutation methods like push(), splice(), and sort() modify the original array, which can cause problems when the array is shared or used elsewhere. When reviewing array manipulation, we verify whether mutation or immutability is intended and that the chosen method matches.

this binding confuses developers because it depends on how functions are called, not where they're defined. Arrow functions solve this by using lexical this. Traditional functions need explicit binding with bind() or calling with specific context. When we see this in non-arrow functions, we verify the binding is correct for the intended usage.

Closures capture variables from outer scopes, which can be intentional or accidental. Loop variables often cause problems when closures are created in loops. The classic mistake is for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100) } which logs 3 three times because all closures share the same i. Using let or proper closure patterns fixes this.

typeof checks prevent operations on wrong types. Before calling string methods, checking typeof value === 'string' prevents runtime errors. This defensive programming catches issues early. When we see operations on values that might be wrong types, typeof checks improve reliability.

null and undefined both represent absence but have different meanings. undefined means a value hasn't been assigned, while null represents intentional absence. Functions return undefined by default, while null typically indicates a deliberate choice to return nothing. Code that confuses these or treats them identically might have subtle bugs.

Function Design and Scope

Pure functions produce the same output for the same input without side effects. This makes functions predictable and testable. When reviewing functions, we verify they don't modify parameters, global state, or external resources unless that's their explicit purpose. Pure functions enable reasoning about code locally without tracking global state.

Function parameters should be limited—more than three suggests the function does too much or parameters should be grouped into an object. The pattern function createUser(name, email, age, role, department) becomes unwieldy. function createUser({ name, email, age, role, department }) uses an object parameter that's easier to call and extend.

Single responsibility means functions do one thing well. Long functions typically do multiple things and should be decomposed. When reviewing functions longer than a screen, we consider whether they should be split. Clear function names and small, focused implementations make code self-documenting.

Early returns reduce nesting and improve readability. Instead of if (condition) { // long code } else { return }, we write if (!condition) return; // long code. This eliminates else blocks and reduces indentation. When we see deep nesting, early returns often flatten the structure.

Modifying parameters creates hidden side effects that make functions harder to understand and test. When a function receives an object and modifies it, callers might not expect this. Returning new values instead of mutating parameters makes side effects explicit and preserves input data.

Array and Object Operations

Array methods like map(), filter(), and reduce() express transformations functionally. Instead of manual loops building new arrays, these methods communicate intent clearly. array.filter(x => x > 10).map(x => x * 2) reads as "filter then transform," making the operation's purpose obvious.

Mutation creates bugs when multiple parts of code reference the same object or array. Immutable operations that return new values prevent these bugs. When we see code pushing to arrays or modifying object properties, we consider whether returning new values would be safer. The slight performance cost of creating new objects rarely matters compared to the reliability benefit.

Optional chaining ?. safely accesses nested properties. The expression user?.profile?.email returns undefined if any step is null or undefined, preventing TypeErrors. This should be our default for accessing properties that might not exist. When we see manual existence checks before each property access, optional chaining simplifies the code.

Nullish coalescing ?? provides defaults for null or undefined. Unlike || which treats any falsy value as absent, ?? only replaces null and undefined. count ?? 0 preserves 0 as a valid count while providing a default for absent values. When we see || default for values where 0 or empty string are valid, ?? expresses intent correctly.

Object spread creates shallow copies efficiently. { ...original, modified: true } creates a new object with original's properties plus the modification. This immutable pattern makes changes explicit and prevents modifying shared objects. When we see manual property copying, spread syntax improves clarity.

Module Organization

ES6 modules provide standard import/export syntax across JavaScript environments. import { function } from './module' and export function function() {} create explicit dependencies that build tools can analyze. When we see CommonJS require() in code targeting modern environments, ES6 modules provide better tooling support.

Named exports work well for modules exposing multiple values. Default exports suit modules with a single primary export. Mixing both from the same module confuses consumers about how to import. When reviewing modules, consistent export style makes the API predictable.

Import statements at file tops make dependencies clear. Scattered imports or conditional imports complicate understanding what the module needs. Bundlers also expect top-level imports for optimization. When we see imports inside functions, we verify the dynamic import is necessary.

Circular dependencies create initialization problems and suggest design issues. When module A imports B and B imports A, the cycle prevents clean initialization. When we encounter import errors or undefined values from imports, circular dependencies are common culprits. Restructuring to extract shared code into a third module resolves the cycle.

Dynamic imports enable code splitting for better load performance. The pattern import('./module').then(module => module.function()) loads code on demand rather than upfront. This proves valuable for large applications where initial load time matters. When modules are only needed conditionally, dynamic imports reduce bundle size.

Bringing It Together

Effective JavaScript code review requires understanding both the language's quirks and modern best practices. JavaScript's flexibility creates numerous ways to accomplish the same goal, some better than others. Code review helps teams converge on patterns that work reliably and read clearly.

Not every issue demands fixing before merge. A missing const that could be let might not matter. Missing error handling on a promise that could crash production absolutely matters. The key is distinguishing issues that affect reliability from those that represent style preferences or minor polish.

Modern JavaScript continues evolving with yearly ECMAScript releases. Features like optional chaining, nullish coalescing, and top-level await improve the language significantly. Teams benefit from adopting new features that solve real problems, and code review creates opportunities to share knowledge about these capabilities and discuss when to use them effectively.