React Architecture10-06-202615 min read

JavaScript & React Gotchas Cheatsheet

A scannable reference of the JavaScript quirks and React gotchas that trip up real codebases. Each entry pairs a minimal code example with a one-line takeaway so you can refresh your mental model in seconds.


JavaScript & React Gotchas Cheatsheet


This is the cheatsheet I wish I had during code reviews and 1 AM debugging sessions. Every entry is built the same way: a tiny code example, then a one-line takeaway. Scan it top to bottom when you need a refresher, or jump to whichever section is biting you today.


JavaScript Concepts


Scope & Variables


var vs let vs const

function f() {
    if (true) { var a = 1; let b = 2; }
    console.log(a); // 1  (var leaks)
    console.log(b); // ReferenceError (let is block-scoped)
}

Takeaway: var is function-scoped; let/const are block-scoped.


Hoisting

console.log(x); // undefined (declaration hoisted, not assignment)
var x = 5;

console.log(y); // ReferenceError (temporal dead zone)
let y = 5;

Takeaway: var hoists as undefined; let/const exist but are unreachable until declared (TDZ).


Function vs Function Expression Hoisting

foo(); // works
function foo() {}

bar(); // TypeError: bar is not a function
var bar = function() {};

Takeaway: Function declarations hoist fully; function expressions only hoist the variable.


Variable Shadowing

const x = 1;
function f() {
    const x = 2;  // shadows outer x
    console.log(x); // 2
}

Takeaway: Inner scope hides outer name. Useful for parameter renaming, dangerous when accidental.


Temporal Dead Zone (TDZ)

{
    console.log(a); // ReferenceError
    let a = 1;
}

Takeaway: Between block entry and let/const declaration, the variable exists but throws on access.




Closures


Basic Closure

function counter() {
    let n = 0;
    return () => ++n;
}
const c = counter();
c(); c(); // 2

Takeaway: Inner function "remembers" outer variables even after outer function returns.


Loop Closure Classic

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 3 3 3

for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 0 1 2

Takeaway: var shares one binding; let creates a new binding per iteration.


Data Privacy

function makeAcct(bal) {
    return { deposit: n => bal += n, get: () => bal };
}

Takeaway: Closures give private state without classes.




this Binding


Implicit vs Explicit

const obj = { x: 1, f() { return this.x; } };
obj.f();           // 1 (implicit)
const g = obj.f;
g();               // undefined (lost context)
g.call(obj);       // 1 (explicit)

Takeaway: this is determined by how a function is called, not where defined.


Arrow Functions

const obj = {
    x: 1,
    f: () => this.x,           // wrong: `this` is outer
    g() { return () => this.x; } // right: arrow inherits g's this
};

Takeaway: Arrow functions don't have their own this; they inherit from enclosing scope.


Class Method Loss

class C { constructor() { this.x = 1; } f() { return this.x; } }
const c = new C();
const f = c.f;
f(); // TypeError

Takeaway: Detaching a method loses this. Use .bind, class fields, or arrow methods.




Equality & Coercion


== vs ===

0 == '';     // true
0 == false;  // true
null == undefined; // true
[] == false; // true
0 === '';    // false

Takeaway: Always use === unless you specifically want coercion.


NaN

NaN === NaN;      // false
Number.isNaN(NaN); // true

Takeaway: NaN is never equal to itself. Use Number.isNaN.


Object Equality

{} === {};   // false
[1] === [1]; // false

Takeaway: Objects compared by reference, not structure.


Truthy / Falsy

// Falsy: false, 0, -0, 0n, '', null, undefined, NaN
// Everything else is truthy, including [] and {}
if ([]) console.log('truthy'); // logs



Reference vs Value


Primitives vs Objects

let a = 1, b = a; b = 2; console.log(a); // 1
let x = {n:1}, y = x; y.n = 2; console.log(x.n); // 2

Takeaway: Primitives copied by value; objects copied by reference.


Function Argument Passing

function f(o) { o.n = 99; }
const obj = {n: 1};
f(obj); // obj.n is now 99

Takeaway: "Pass by value of the reference". Mutating contents affects the caller; reassigning doesn't.


Shallow vs Deep Copy

const a = { nested: { x: 1 } };
const b = { ...a };          // shallow
b.nested.x = 99;             // also changes a.nested.x
const c = structuredClone(a); // deep



Array & Object Quirks


Mutating vs Non-Mutating Array Methods

// Mutate: push, pop, shift, unshift, splice, sort, reverse
// Return new: map, filter, slice, concat, toSorted, toReversed
[3,1,2].sort();        // mutates
[3,1,2].toSorted();    // returns new

Spread Pitfall

const a = [{x:1}], b = [...a];
b[0].x = 99;          // a[0].x also 99 (shallow)

Destructuring Defaults

const { a = 1 } = { a: undefined }; // 1
const { a = 1 } = { a: null };      // null (default only on undefined)

Object Key Order

Object.keys({2:'a', 1:'b', x:'c'}); // ['1','2','x']

Takeaway: Integer-like keys sort numerically first, then insertion order.




Async & Event Loop


Microtasks vs Macrotasks

setTimeout(() => console.log('macro'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');
// sync → micro → macro

Takeaway: Microtasks (promises) drain fully between each macrotask.


Promise Chaining

Promise.resolve(1)
    .then(x => x + 1)
    .then(x => Promise.resolve(x + 1))
    .then(console.log); // 3

Async/Await Error Handling

async function f() {
    try { await mayFail(); }
    catch (e) { /* handles both sync throws and rejections */ }
}

Sequential vs Parallel

// Sequential: slow
const a = await fetchA();
const b = await fetchB();

// Parallel: fast
const [a, b] = await Promise.all([fetchA(), fetchB()]);

forEach + async ≠ awaited

[1,2,3].forEach(async x => await save(x)); // does NOT wait
for (const x of [1,2,3]) await save(x);     // does

Unhandled Rejections

Promise.reject('x'); // unhandled if no .catch

Takeaway: Every promise chain needs terminal error handling.




Prototypes & Classes


Prototype Chain

const a = {};
a.toString;           // inherited from Object.prototype
Object.getPrototypeOf(a) === Object.prototype; // true

Class Field vs Method

class C {
    arrow = () => this;   // bound per instance
    method() { return this; } // not bound
}



Other Sharp Edges


typeof Quirks

typeof null;       // 'object'  (historical bug)
typeof [];         // 'object'
typeof NaN;        // 'number'
typeof undefined;  // 'undefined'
typeof (() => {}); // 'function'

Array.length Mutation

const a = [1,2,3]; a.length = 1; // a is now [1]

Number Precision

0.1 + 0.2 === 0.3; // false (0.30000000000000004)

Default Parameters Evaluated Each Call

function f(x = []) { x.push(1); return x; }
f(); f(); // each call gets new []

IIFE

(function() { /* private scope */ })();



React Gotchas


Rendering Model


Renders Are Snapshots

function App() {
    const [n, setN] = useState(0);
    const handle = () => {
        setN(n + 1);
        setN(n + 1); // both see n=0; final n=1, not 2
    };
}

Takeaway: Within one render, state values are frozen. Use updater form for sequential updates.


Updater Form

setN(prev => prev + 1);
setN(prev => prev + 1); // final n=2

Takeaway: Functional updater always sees latest pending state.


State Updates Are Batched

setA(1); setB(2); // one re-render, not two

Takeaway: React batches updates within event handlers (and in React 18+, everywhere).




Stale Closures


Stale State in Timeout

function App() {
    const [n, setN] = useState(0);
    const delayed = () => setTimeout(() => console.log(n), 3000);
    // logs n from the render when delayed was created
}

Takeaway: Closures capture render-time values. Use refs or updater form for "latest."


Stale Effect

useEffect(() => {
    const id = setInterval(() => setN(n + 1), 1000); // always n=0+1
    return () => clearInterval(id);
}, []); // missing dep

Fix: setN(prev => prev + 1) or include n in deps.




useState


Initializer Runs Every Render

useState(expensiveCalc());     // runs every render
useState(() => expensiveCalc()); // runs once

Direct Mutation Doesn't Trigger Re-render

const [arr, setArr] = useState([1,2]);
arr.push(3); setArr(arr); // same reference → no re-render
setArr([...arr, 3]);      // new reference → re-render

Object State Replace, Not Merge

const [s, setS] = useState({a:1, b:2});
setS({a: 99}); // b is gone (unlike class setState)
setS(prev => ({...prev, a: 99})); // correct



useEffect


Missing Dependencies

useEffect(() => { fetch(`/api/${id}`); }, []); // stale id
useEffect(() => { fetch(`/api/${id}`); }, [id]); // correct

Infinite Loop

useEffect(() => { setN(n + 1); }); // no deps → re-renders forever

Object/Function in Deps

useEffect(() => {}, [{x:1}]); // new ref every render → fires every render

Fix: Memoize with useMemo/useCallback or use primitive deps.


Cleanup Order

useEffect(() => {
    const id = setInterval(tick, 1000);
    return () => clearInterval(id); // runs before next effect AND on unmount
}, [tick]);

Async in Effect

useEffect(() => {
    let cancelled = false;
    (async () => {
        const data = await fetchData();
        if (!cancelled) setData(data);
    })();
    return () => { cancelled = true; };
}, []);

Takeaway: Can't make the effect callback itself async; wrap an inner function. Handle race conditions with a cancel flag.


Strict Mode Double Invocation

// In dev with StrictMode, effects run → cleanup → run again
// to surface bugs. Make effects idempotent.



Rules of Hooks


Conditional Hooks

if (x) useState(0); // ERROR. Hooks must run in same order every render

Hooks in Loops

items.forEach(() => useState(0)); // ERROR

Takeaway: Always call hooks at the top level, in the same order.




Refs


useRef vs useState

const r = useRef(0); r.current = 5; // no re-render
const [n, setN] = useState(0); setN(5); // re-render

Takeaway: Use refs for values that shouldn't trigger render (DOM nodes, mutable instance vars).


Reading Ref During Render

function App() {
    const r = useRef(0);
    return <div>{r.current}</div>; // generally don't. Value isn't a render signal
}



Memoization


useCallback Without Stable Deps

const fn = useCallback(() => {}, [obj]); // obj is new each render → useless

useMemo Doesn't Guarantee Caching

// React may discard memo cache; don't rely on it for correctness, only perf.

React.memo + New Props Every Render

<Child style={{color:'red'}} /> // new object → memo bypassed



Lists & Keys


Index as Key

items.map((x, i) => <Item key={i} />); // breaks on reorder/insert
items.map(x => <Item key={x.id} />);   // stable identity

Missing Key

items.map(x => <li>{x}</li>); // warning + reconciliation bugs



Events & Handlers


Call vs Reference

<button onClick={handle}>    // correct: pass function
<button onClick={handle()}> // wrong: invokes on render
<button onClick={() => handle(id)}> // wrap when passing args

Synthetic Event Pooling (pre-React 17)

// Old React reused event objects; don't access async. Fixed in React 17+.



Forms


Controlled vs Uncontrolled

<input value={v} onChange={e => setV(e.target.value)} /> // controlled
<input defaultValue="x" ref={r} />                       // uncontrolled

Takeaway: Don't mix; set value once or always.


Controlled with Undefined

<input value={undefined} /> // becomes uncontrolled (warning)
<input value={v ?? ''} />   // safe



Context


Re-renders on Provider Value Change

<Ctx.Provider value={{x:1}}>  // new object each render → all consumers re-render
<Ctx.Provider value={memoVal}> // memoize

Splitting Contexts

Takeaway: Separate frequently-changing values from stable ones into different contexts.




Common Patterns


Derived State Anti-pattern

// Don't store derived data in state
const [items, setItems] = useState([]);
const [count, setCount] = useState(0); // bad. Derive instead: items.length

Lifting State Up

// When two siblings need the same data, move it to their common parent.

Key to Reset Component

<Form key={userId} /> // changing key remounts; resets internal state

Don't Set State in Render

function App() {
    setN(1); // infinite loop
    return <div />;
}



Performance Pitfalls


Anonymous Functions in Props

<Child onClick={() => doX()} /> // new ref each render

Takeaway: Only matters if Child is memo'd or it's in deps somewhere.


Wrapping Everything in useMemo

Takeaway: Memoization has cost. Profile before optimizing.




Master Mental Models


The Three Big Ideas


1. Renders Are Snapshots

Every render produces a fresh set of variables and a fresh JSX tree. Your event handler belongs to one specific snapshot.


2. Functions Carry Backpacks (Closures)

A function "remembers" the variables in scope at the moment it was created, not when it's called.


3. State Updates Are Scheduled

setState does not change state immediately; it queues a new render with the new value.


Diagnostic Questions


For Any Weird React Bug

  • Which render created this function?
  • Do I need the latest state or the snapshot value?
  • Are my dep array values referentially stable?

For Any Weird JS Bug

  • What scope is this variable in?
  • Is this primitive (copy) or reference (shared)?
  • When does this code actually run (sync vs micro vs macro)?



Final Thoughts


Most "weird" bugs in JavaScript and React are not weird. They are predictable consequences of the language's scoping rules, the event loop, and React's rendering model. Once those three feel intuitive, the gotchas above stop being traps and start being signals that point straight at the cause.


Bookmark this page, scan it before a tricky review, and keep building.