DEV Community

Tihomir Ivanov
Tihomir Ivanov

Posted on

Is JS pass-by-value or pass-by-reference? Let's clear the confusion once and for all, from memory basics to modern Immutability.

JavaScript: Is it Pass-by-Value or Pass-by-Reference?

One of the most common questions in JavaScript interviews is: "How are variables passed in JS?". The answer often leads to confusion. Let's break it down simply and accurately.


The Golden Rule: Everything is Pass-by-Value

In JavaScript, everything is passed by value. However, the "value" depends on what type of data you are dealing with.


1. Primitives (Number, String, Boolean, Symbol, BigInt, null, undefined)

When you pass a primitive, JS makes a complete copy of the data. The original and the copy are totally independent.

let a = 10;
function change(x) {
  x = 20;
}
change(a);
console.log(a); // Result: 10 (Original stays the same)
Enter fullscreen mode Exit fullscreen mode

Why? Primitives are stored directly in the variable. When you pass them, JavaScript copies the actual value.

let name = "Alice";
let copyName = name;
copyName = "Bob";

console.log(name);     // "Alice" - unchanged
console.log(copyName); // "Bob"
Enter fullscreen mode Exit fullscreen mode

2. Objects and Arrays (The "Copy of a Reference")

Objects are also passed by value, but the value being copied is the reference (address) to where the object lives in memory. This is often called "Pass-by-sharing".

  • You aren't copying the whole object.
  • You are copying the "remote control" to that object.
let user = { name: "Neo" };

function rename(obj) {
  obj.name = "Smith"; // Both variables point to the same object
}

rename(user);
console.log(user.name); // Result: "Smith"
Enter fullscreen mode Exit fullscreen mode

What Happens in Memory?

let obj1 = { value: 42 };
let obj2 = obj1; // Copying the reference, not the object

obj2.value = 100;

console.log(obj1.value); // 100 - They both point to the same object!
console.log(obj1 === obj2); // true - Same reference
Enter fullscreen mode Exit fullscreen mode

Reassignment vs Mutation

Important distinction: Reassigning the variable does NOT affect the original, but mutating the object does.

let original = { count: 5 };

function reassign(obj) {
  obj = { count: 10 }; // Creates new object, breaks the link
}

function mutate(obj) {
  obj.count = 10; // Modifies the existing object
}

reassign(original);
console.log(original.count); // 5 - Reassignment didn't affect original

mutate(original);
console.log(original.count); // 10 - Mutation changed the original
Enter fullscreen mode Exit fullscreen mode

Shallow Copy vs. Deep Copy

Because objects are shared, modifying a copy often accidentally modifies the original. This is where cloning comes in.

Shallow Copy

Copies only the first level. Nested objects are still shared.

How: { ...original } or Object.assign({}, original) or [...array]

Danger: Changing copy.address.city will change original.address.city.

const user = {
  name: "Alice",
  address: {
    city: "NYC"
  }
};

const shallowCopy = { ...user };
shallowCopy.name = "Bob";           // Safe: Creates new string
shallowCopy.address.city = "LA";    // Danger: Modifies shared object

console.log(user.name);             // "Alice" - unchanged
console.log(user.address.city);     // "LA" - changed! ⚠️
Enter fullscreen mode Exit fullscreen mode

Array example:

const nested = [[1, 2], [3, 4]];
const copy = [...nested];

copy[0][0] = 99; // Modifies the original!

console.log(nested[0][0]); // 99 ⚠️
Enter fullscreen mode Exit fullscreen mode

Deep Copy

Creates a completely independent clone of the entire tree.

Modern way: structuredClone(original)

const user = {
  name: "Alice",
  address: { city: "NYC" },
  hobbies: ["reading"]
};

const deepCopy = structuredClone(user);
deepCopy.address.city = "LA";
deepCopy.hobbies.push("coding");

console.log(user.address.city);  // "NYC" - unchanged ✅
console.log(user.hobbies);       // ["reading"] - unchanged ✅
Enter fullscreen mode Exit fullscreen mode

Legacy way: JSON.parse(JSON.stringify(original))

Limitations:

  • Loses functions
  • Loses undefined values
  • Loses Symbol keys
  • Can't handle circular references
  • Loses Date objects (converts to string)
const obj = {
  date: new Date(),
  fn: () => console.log("hi"),
  undef: undefined
};

const copy = JSON.parse(JSON.stringify(obj));

console.log(copy.date);   // String, not Date object
console.log(copy.fn);     // undefined - lost!
console.log(copy.undef);  // undefined - lost!
Enter fullscreen mode Exit fullscreen mode

The Power of Immutability

In modern development (especially with React), we follow the principle of Immutability: Never change the original data. Instead, always return a new version.

Why?

  1. Predictability: You always know what your data looks like.
  2. Performance: Frameworks can check if data changed by simply comparing references (oldState === newState) instead of checking every property.
  3. Time-travel debugging: Tools like Redux DevTools can track state changes.
  4. Avoid bugs: No unexpected side effects from distant parts of your code.

How to Stay Immutable

Arrays

Mutation (Bad):

const todos = ["task1", "task2"];
todos.push("task3"); // Mutates original
Enter fullscreen mode Exit fullscreen mode

Immutable (Good):

const todos = ["task1", "task2"];
const newTodos = [...todos, "task3"]; // Creates new array
Enter fullscreen mode Exit fullscreen mode

Common immutable array operations:

const arr = [1, 2, 3, 4, 5];

// Add item
const added = [...arr, 6];

// Remove item by index
const removed = arr.filter((_, i) => i !== 2); // Removes index 2

// Update item by index
const updated = arr.map((item, i) => i === 2 ? 99 : item);

// Insert at specific position
const inserted = [...arr.slice(0, 2), 99, ...arr.slice(2)];
Enter fullscreen mode Exit fullscreen mode

Objects

Mutation (Bad):

const user = { name: "Alice", age: 25 };
user.age = 26; // Mutates original
Enter fullscreen mode Exit fullscreen mode

Immutable (Good):

const user = { name: "Alice", age: 25 };
const updatedUser = { ...user, age: 26 }; // Creates new object
Enter fullscreen mode Exit fullscreen mode

Nested updates:

const state = {
  user: {
    name: "Alice",
    address: {
      city: "NYC"
    }
  }
};

// Update nested property immutably
const newState = {
  ...state,
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: "LA"
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

React-Specific Patterns

React relies heavily on immutability for efficient rendering.

State Updates

Wrong way:

const [user, setUser] = useState({ name: "Alice", age: 25 });

// This won't trigger re-render!
const updateAge = () => {
  user.age = 26;
  setUser(user); // Same reference, React ignores it
};
Enter fullscreen mode Exit fullscreen mode

Correct way:

const [user, setUser] = useState({ name: "Alice", age: 25 });

const updateAge = () => {
  setUser({ ...user, age: 26 }); // New reference, React re-renders
};
Enter fullscreen mode Exit fullscreen mode

Array State Updates

const [items, setItems] = useState([1, 2, 3]);

// Add item
setItems([...items, 4]);

// Remove item
setItems(items.filter(item => item !== 2));

// Update item
setItems(items.map(item => item === 2 ? 99 : item));
Enter fullscreen mode Exit fullscreen mode

Why React Cares About References

// React's simplified internal check
if (prevState === newState) {
  // Skip rendering, nothing changed
} else {
  // Re-render component
}
Enter fullscreen mode Exit fullscreen mode

This is why mutating state directly breaks React:

const [todos, setTodos] = useState([]);

// Wrong
const addTodo = (text) => {
  todos.push(text);
  setTodos(todos); // Same reference! React won't re-render
};

// Correct
const addTodo = (text) => {
  setTodos([...todos, text]); // New reference, React re-renders
};
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and Solutions

Pitfall 1: Accidental Mutation in Callbacks

const handleClick = () => {
  const items = [...currentItems];
  items.sort(); // sort() mutates the array!
  setItems(items);
};

// Better
const handleClick = () => {
  const items = [...currentItems];
  const sorted = items.sort(); // Still mutates, but we have a copy
  setItems(sorted);
};

// Best
const handleClick = () => {
  const sorted = [...currentItems].sort();
  setItems(sorted);
};
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Forgetting About Nested Objects

// Shallow copy doesn't protect nested data
const newState = { ...state };
newState.user.name = "Bob"; // Mutates original state.user!

// Deep copy or nested spread
const newState = {
  ...state,
  user: {
    ...state.user,
    name: "Bob"
  }
};
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Reference Equality in Dependencies

// Creates new object on every render
const MyComponent = () => {
  const config = { theme: "dark" }; // New reference each render

  useEffect(() => {
    applyConfig(config);
  }, [config]); // Runs on every render!
};

// Stable reference
const MyComponent = () => {
  const config = useMemo(() => ({ theme: "dark" }), []);

  useEffect(() => {
    applyConfig(config);
  }, [config]); // Only runs when needed
};
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

When to Use Each Approach

Scenario Recommended Approach
Small objects Shallow copy { ...obj }
Nested structures structuredClone() or nested spreads
Large objects updated frequently Consider Immer library
Arrays with primitives Spread [...arr]
Arrays with objects structuredClone() or map with spreads

Using Libraries for Complex Immutability

For deeply nested state, consider Immer:

import produce from 'immer';

const newState = produce(state, draft => {
  // Mutate draft freely, Immer makes it immutable
  draft.user.address.city = "LA";
  draft.todos.push("New task");
});
Enter fullscreen mode Exit fullscreen mode

Summary Table

Data Type Passed by... Result of Modification Copy Method
Primitives Value Original is safe Automatic
Objects Value (Reference) Original property changes { ...obj } (shallow)
structuredClone(obj) (deep)
Arrays Value (Reference) Original item changes [...arr] (shallow)
structuredClone(arr) (deep)
Cloned Object New Reference Original is safe Depends on copy type

Quick Reference Cheat Sheet

// ========== PRIMITIVES ==========
let a = 5;
let b = a; // Copy
b = 10;
// a is still 5

// ========== OBJECTS ==========
let obj1 = { x: 1 };
let obj2 = obj1; // Reference copy
obj2.x = 2;
// obj1.x is now 2!

// Shallow copy
let obj3 = { ...obj1 };

// Deep copy
let obj4 = structuredClone(obj1);

// ========== ARRAYS ==========
let arr1 = [1, 2, 3];
let arr2 = arr1; // Reference copy
arr2.push(4);
// arr1 is now [1, 2, 3, 4]!

// Shallow copy
let arr3 = [...arr1];

// Deep copy
let arr4 = structuredClone(arr1);

// ========== IMMUTABLE UPDATES ==========
// Object
const newObj = { ...oldObj, prop: newValue };

// Array - add
const newArr = [...oldArr, newItem];

// Array - remove
const filtered = oldArr.filter(item => item.id !== idToRemove);

// Array - update
const updated = oldArr.map(item =>
  item.id === targetId ? { ...item, prop: newValue } : item
);
Enter fullscreen mode Exit fullscreen mode

Conclusion

Understanding that JavaScript passes a copy of the reference for objects is a "lightbulb moment" for many developers. By mastering Shallow vs. Deep copying and embracing Immutability, you will write cleaner, bug-free code—especially in modern frameworks like React.

Key Takeaways:

  • JS is always pass-by-value (but the value might be a reference)
  • Primitives are safe from external changes
  • Objects and arrays share references—be careful!
  • Use spread or structuredClone() to create copies
  • Embrace immutability for predictable, performant code
  • React relies on reference equality for optimization

Now go forth and never mutate state directly again! 🚀


Further Reading

Top comments (0)