LEARN · DEBUGGING GUIDE

Debugging Unexpected State Changes from JavaScript Object Mutation

JavaScript objects are passed by reference, not value. Mutations propagate silently. This guide shows you how to track down the culprit and lock down your state.

IntermediateJavaScript9 min read

What this usually means

When you see state change without an explicit assignment, the root cause is almost always shared reference mutation. JavaScript objects (and arrays, functions, dates) are reference types: assigning an object to another variable or passing it to a function copies the reference, not the value. Any mutation via that reference affects all owners. Common culprits: shallow copies that miss nested objects, array methods like splice/push that mutate in-place, and frameworks that cache references (e.g., React's stale closure). The bug is often in a function that 'modifies' its argument as a side effect.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Check if the changed object was passed to a function that might have mutated it. Add `console.log(JSON.parse(JSON.stringify(obj)))` before and after suspect calls.
  • 2Use `Object.is()` or `===` to compare references at different points. If references are the same, mutation is shared.
  • 3In Node.js, use the `--inspect` flag and set breakpoints in Chrome DevTools. Watch the object's properties in the Scope panel.
  • 4Temporarily freeze the object with `Object.freeze(obj)` at the source. If errors appear later, mutation is confirmed.
  • 5Search your codebase for uses of `Array.prototype.sort`, `splice`, `push`, `pop`, `shift`, `unshift`, `reverse`, and `fill`. These mutate the original array.
  • 6For React apps, check if you're spreading state correctly: `setState({...state, key: newVal})` vs mutating `state.key = newVal`.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchReact component: look for direct state mutation like `state.items.push(item)` instead of `setState({items: [...state.items, item]})`
  • searchRedux reducer: ensure reducers are pure and return new objects, not mutated state. Check for `state.items.push` or `state.items[index] = val`.
  • searchUtility functions that accept objects/arrays: check if they call `sort()`, `reverse()`, or assign to properties of the input parameter.
  • searchNode.js modules: if a module exports a mutable object, any import can mutate it. Search for `module.exports =` followed by an object literal.
  • searchThird-party libraries: some libraries mutate inputs. Check if you're passing your objects to functions like `lodash.merge` (which mutates target by default).
  • searchClosures in event handlers or callbacks: if a closure captures a reference, it may mutate the original object later.
  • searchconsole.log statements: in some browsers, logged objects are live references. If you inspect a logged object later, it shows current values, not logged time.
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningShallow copy (Object.assign, spread) missing nested objects: `const copy = {...original}; copy.nested.x = 5;` mutates `original.nested.x`.
  • warningArray methods like `sort()` and `splice()` mutate in-place. `arr.sort()` returns the same array reference, sorted.
  • warningFunction parameters mutated directly: `function update(obj) { obj.key = 'new'; }` mutates the caller's object.
  • warningRedux reducer mutating state: `state.items.push(action.payload)` instead of returning a new array.
  • warningReact setState with same reference: `setState(prev => { prev.items.push(x); return prev; })` — same object, no re-render.
  • warningClosures capturing mutable variables: a callback modifies a variable that was captured by reference.
  • warningUsing `delete` operator on an object property when you intended to create a new object without that property.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildAlways create new objects/arrays for state updates: use spread operator, `Object.assign({}, obj)`, or libraries like Immer.
  • buildDeep clone when needed: `JSON.parse(JSON.stringify(obj))` for simple structures, or structuredClone() in modern environments.
  • buildReplace in-place array methods with non-mutating alternatives: `arr.toSorted()` (ES2023), `arr.filter()`, `arr.map()`, `arr.slice()`.
  • buildUse `Object.freeze()` in development to catch mutations early. (Strict mode will throw in strict-mode code.)
  • buildAdopt immutable patterns: use `const` for references (though it doesn't prevent mutation), use libraries like Immutable.js or Immer.
  • buildIn Redux, use `createReducer` from Redux Toolkit which uses Immer under the hood, allowing 'mutative' syntax that produces immutable updates.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter fix, run the app and confirm the state no longer changes unexpectedly. Use React DevTools to inspect state before/after actions.
  • verifiedWrite a unit test that asserts the original object is unchanged after calling a function: `const original = {a: 1}; const result = myFunc(original); expect(original).toEqual({a: 1});`
  • verifiedUse `Object.is(original, modified)` to confirm references are different after an update operation.
  • verifiedFor React components, add `console.log('render', props)` and verify it only renders when props actually change (not on every mutation).
  • verifiedIn Node.js, add a `Proxy` around the object to log any set operations: `const handler = { set(target, prop, value) { console.trace('set', prop, value); target[prop] = value; return true; } };`
  • verifiedRun stress tests or random sequences of operations to check for race conditions or mutation leaks.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAssuming `const` prevents mutation — it only prevents reassignment, not property changes.
  • warningUsing `Object.assign({}, obj)` for deep copies — it only shallow copies. Nested objects still share references.
  • warningConfusing equality: `==` or `===` on objects checks reference identity, not structural equality. Use lodash's `isEqual` or JSON.stringify for deep comparison (with caveats).
  • warningRelying on `JSON.parse(JSON.stringify(obj))` for objects with functions, Dates, RegExp, or circular references — it will fail silently or lose data.
  • warningMutating state directly in React class components with `this.state.items.push(...)` — React won't re-render.
  • warningForgotten `return` in Redux reducers — if you don't return new state, Redux may use previous state (or undefined).
  • warningOverlooking that `Array.prototype.sort` is in-place and returns the same reference — even experienced developers get bitten.
( 07 )War story

A Redux Cart That Randomly Loses Items

Senior Frontend EngineerReact 17, Redux 4, Node.js 14

Timeline

  1. 09:15User reports that after adding 5 items to cart, sometimes items disappear or duplicate when navigating between product pages.
  2. 09:30I check network requests — no API errors. Cart data is stored in Redux.
  3. 09:45I add console.log in the cart reducer and in the component that dispatches addItem. Logs show correct action payloads.
  4. 10:00I notice that after a successful add, the state array reference is the same (using ===). Redux docs say reducers must be pure.
  5. 10:15I inspect the reducer code: `case ADD_ITEM: state.items.push(action.payload); return state;` — classic mutation bug.
  6. 10:20I fix the reducer: `case ADD_ITEM: return { ...state, items: [...state.items, action.payload] };`
  7. 10:30Deploy the fix. Cart behavior is now consistent. No more random disappearances.
  8. 10:45I write a unit test that asserts the original state is not mutated after dispatching ADD_ITEM.

I was called to a production issue where users' shopping carts were losing items. The bug was intermittent: sometimes after adding a fifth item, the cart would show only three. No pattern I could see. I started by checking the network tab — all API calls returned correct data. The Redux DevTools showed the state after each action, but the state seemed correct at dispatch time. However, when I compared state references across dispatches using `Object.is`, I noticed that after several adds, the state reference didn't change. That was the red flag: Redux reducers must return new state objects.

I dove into the cart reducer. The `ADD_ITEM` case was: `state.items.push(action.payload); return state;`. This mutated the existing state array and returned the same reference. Redux doesn't detect the change because it checks reference equality. So React didn't re-render the cart component with the new items — but sometimes it did re-render for other reasons, causing the component to read the mutated array. That's why the bug was intermittent: it depended on whether a re-render was triggered by something else.

The fix was straightforward: replace the mutation with an immutable update. I changed the reducer to `return { ...state, items: [...state.items, action.payload] };`. I also audited other reducers for similar patterns. After deploying, the cart behavior stabilized. I added a unit test that freezes the state before dispatching and checks it remains unchanged. The lesson: always check for reference identity in state management. A simple `Object.is` can save hours.

Root cause

Redux reducer mutated the state array (`push`) instead of returning a new array, causing React to miss re-renders and the state to become inconsistent.

The fix

Changed `state.items.push(action.payload); return state;` to `return { ...state, items: [...state.items, action.payload] };`.

The lesson

Always verify that state updates produce new references. Use Object.freeze in development to catch mutations early. Immutability isn't optional in Redux.

( 08 )The Reference Trap: Why `const` Doesn't Make Objects Immutable

A common misconception is that declaring an object with `const` prevents changes to its properties. It doesn't. `const` only prevents reassignment of the variable identifier. The object itself remains mutable. This means you can still do `const obj = { a: 1 }; obj.a = 2;` without any error. The reference stored in `obj` hasn't changed — the object it points to has changed.

To prevent mutation, you need `Object.freeze()` which makes the object read-only (shallowly). However, frozen objects in strict mode throw TypeError on assignment. In non-strict mode, assignment silently fails. For deep freeze, you need a recursive freeze function or use a library like `deep-freeze`.

The practical takeaway: when you see `const` and think immutability, you're wrong. Always check if the object's contents are being modified through any reference.

( 09 )Array Methods That Lie: In-Place Mutators Returing Same Reference

JavaScript arrays have several methods that mutate the original array and return the same reference: `sort()`, `reverse()`, `splice()`, `fill()`, `copyWithin()`. Many developers assume `sort()` returns a new sorted array because it returns a value. But it returns the original array (the same reference), sorted. This is a classic trap: `let sorted = arr.sort();` — now `arr` and `sorted` point to the same sorted array.

To safely sort without mutation, use `Array.prototype.toSorted()` (ES2023) or `[...arr].sort()`. Similarly, for reverse, use `toReversed()`. For splice, use `toSpliced()`. These newer methods return a new array and leave the original unchanged. They are available in Node.js 20+ and modern browsers. For older environments, manually create a copy before mutating.

Another common pitfall is `delete array[index]` — it leaves a hole (empty slot) and doesn't change the array length. Prefer `array.splice(index, 1)` but note that splice mutates. So create a copy first or use `filter`.

( 10 )Shallow Copy Surprises: When Spread Isn't Enough

The spread operator `{...original}` creates a shallow copy. Top-level properties are new references, but nested objects and arrays are shared. If you modify `copy.nested.property`, you modify `original.nested.property` too. This is the most common source of unintended mutation in React and Redux code.

Example: `const state = { user: { name: 'Alice', address: { city: 'NYC' } } }; const newState = { ...state }; newState.user.address.city = 'LA';` Now `state.user.address.city` is also 'LA'. The spread only copied the reference to `user` object, not the `address` inside.

Solutions: deep clone using `structuredClone(state)` (available in modern browsers and Node 17+), `JSON.parse(JSON.stringify(state))` (with caveats), or a library like `lodash.cloneDeep`. For state management, consider using Immer which handles immutability automatically with a mutable API.

( 11 )Debugging Mutation with Proxies and Object.observe (Historical)

`Object.observe()` was proposed for ES7 but was withdrawn. However, `Proxy` objects can intercept property access, assignment, and deletion. You can wrap a suspect object in a Proxy that logs or throws on any mutation. This is useful for tracking down where a mutation happens in a complex codebase.

Example: `const handler = { set(target, prop, value) { console.warn('Mutation:', prop, value, new Error().stack); target[prop] = value; return true; } }; const watched = new Proxy(original, handler);` Then pass `watched` instead of `original` to the code you suspect. Any mutation will log a stack trace.

Note that Proxy can have performance overhead, so only use in development. Also, Proxy cannot intercept internal slots like `[[Prototype]]` changes. But for property mutation, it's a powerful tool.

( 12 )Mutation in Asynchronous Code: Closures and Shared References

Asynchronous code often captures variables by reference. A classic bug: a loop that calls an async function with a variable that changes before the callback executes. Example: `for (var i = 0; i < 5; i++) { setTimeout(() => console.log(i), 100); }` prints 5 five times because `i` is shared. Using `let` fixes this because `let` creates a new binding per iteration.

Similarly, objects captured in closures can be mutated asynchronously. If you pass an object to multiple async functions, they may mutate it in unexpected order. To avoid, use immutable patterns: create copies before passing to async functions, or use `Object.freeze` to prevent mutation in the first place.

In React, stale closures often cause mutation bugs: a callback that reads a state value from a closure might read an outdated value. Using functional updates `setState(prev => ...)` avoids this because it always uses the latest state.

Frequently asked questions

Why does `console.log` sometimes show the correct value but later the variable is different?

In browsers, `console.log` often logs a reference to the object, not a snapshot. When you expand the logged object in the console, it shows the current values, not the values at the time of logging. To capture a snapshot, use `console.log(JSON.parse(JSON.stringify(obj)))` or `console.log({...obj})` for shallow objects.

Does `Object.assign({}, obj)` create a deep copy?

No, `Object.assign` only does a shallow copy. Nested objects are still shared. For deep copy, use `structuredClone()`, `JSON.parse(JSON.stringify())`, or a library like `lodash.cloneDeep`.

How can I prevent mutation in Redux reducers without using a library?

Always return new objects. For updating arrays: use `filter`, `map`, `concat`, or spread. For objects: use spread or `Object.assign` with a new target. Use `Object.freeze(state)` in development to catch accidental mutations. Redux Toolkit's `createReducer` uses Immer, which is recommended.

Why does mutating state in React not trigger a re-render?

React uses shallow comparison to detect state changes. If you mutate the same object and call `setState` with it, the reference is the same, so React skips the re-render. Always create a new object or array when setting state.

Is there a way to make JavaScript objects truly immutable?

`Object.freeze()` makes an object shallowly immutable (no property changes, additions, or deletions). For deep immutability, you need to recursively freeze. Libraries like `immutable-js` provide persistent data structures. However, true immutability is not natively enforced — it's a convention aided by tools and practices.