Learning Objectives
- Understand immutability and pure functions
- Write pure functions without side effects
- Use immutable array and object operations
- Apply Object.freeze and deep cloning
- Implement immutable state management patterns
What is Immutability?
Immutability means that once data is created, it cannot be changed. Instead of modifying existing data, you create new data with the desired changes.
Mutable vs Immutable
// Mutable - modifies original array
const numbers = [1, 2, 3];
numbers.push(4);
console.log(numbers); // [1, 2, 3, 4] - original changed!
// Immutable - creates new array
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4];
console.log(numbers); // [1, 2, 3] - original unchanged
console.log(newNumbers); // [1, 2, 3, 4] - new array
What are Pure Functions?
A pure function is a function that:
- Always returns the same output for the same input
- Has no side effects (doesn't modify external state)
Impure vs Pure Functions
// Impure - modifies external state
let total = 0;
function addToTotal(value) {
total += value; // Side effect!
return total;
}
// Pure - no side effects
function add(a, b) {
return a + b; // Always returns same result for same inputs
}
console.log(add(5, 3)); // 8
console.log(add(5, 3)); // 8 - always the same!
Immutable Array Operations
JavaScript provides many non-mutating array methods:
Adding Elements
const arr = [1, 2, 3];
// Mutable (BAD)
arr.push(4);
// Immutable (GOOD)
const newArr = [...arr, 4];
const newArr2 = arr.concat(4);
// Add to beginning
const withStart = [0, ...arr];
// Add in middle
const withMiddle = [...arr.slice(0, 2), 2.5, ...arr.slice(2)];
console.log(withMiddle); // [1, 2, 2.5, 3]
Removing Elements
const arr = [1, 2, 3, 4, 5];
// Mutable (BAD)
arr.pop();
arr.shift();
arr.splice(1, 1);
// Immutable (GOOD)
const withoutLast = arr.slice(0, -1);
const withoutFirst = arr.slice(1);
const withoutIndex = arr.filter((_, i) => i !== 2);
console.log(withoutLast); // [1, 2, 3, 4]
console.log(withoutFirst); // [2, 3, 4, 5]
console.log(withoutIndex); // [1, 2, 4, 5]
Updating Elements
const arr = [1, 2, 3, 4, 5];
// Mutable (BAD)
arr[2] = 99;
// Immutable (GOOD)
const updated = arr.map((val, i) => i === 2 ? 99 : val);
const updated2 = [...arr.slice(0, 2), 99, ...arr.slice(3)];
console.log(updated); // [1, 2, 99, 4, 5]
Transforming Arrays
const numbers = [1, 2, 3, 4, 5];
// map, filter, reduce are all immutable
const doubled = numbers.map(x => x * 2);
const evens = numbers.filter(x => x % 2 === 0);
const sum = numbers.reduce((acc, x) => acc + x, 0);
console.log(numbers); // [1, 2, 3, 4, 5] - unchanged
console.log(doubled); // [2, 4, 6, 8, 10]
console.log(evens); // [2, 4]
console.log(sum); // 15
Immutable Object Operations
Updating Object Properties
const user = {
name: 'John',
age: 30,
email: 'john@example.com'
};
// Mutable (BAD)
user.age = 31;
// Immutable (GOOD)
const updatedUser = {
...user,
age: 31
};
// Update multiple properties
const updatedUser2 = {
...user,
age: 31,
email: 'newemail@example.com'
};
console.log(user); // { name: 'John', age: 30, ... }
console.log(updatedUser); // { name: 'John', age: 31, ... }
Adding and Removing Properties
const user = { name: 'John', age: 30 };
// Add property
const withPhone = {
...user,
phone: '555-1234'
};
// Remove property
const { age, ...withoutAge } = user;
console.log(withPhone); // { name: 'John', age: 30, phone: '555-1234' }
console.log(withoutAge); // { name: 'John' }
console.log(user); // { name: 'John', age: 30 } - unchanged
Nested Object Updates
const user = {
name: 'John',
address: {
street: '123 Main St',
city: 'New York'
}
};
// Update nested property
const updated = {
...user,
address: {
...user.address,
city: 'Boston'
}
};
console.log(user.address.city); // 'New York' - unchanged
console.log(updated.address.city); // 'Boston'
Object.freeze()
Object.freeze() makes an object immutable (shallow freeze only):
const user = Object.freeze({
name: 'John',
age: 30
});
// These will fail silently (or throw in strict mode)
user.age = 31;
user.email = 'test@example.com';
delete user.name;
console.log(user); // { name: 'John', age: 30 } - unchanged
// Nested objects are NOT frozen
const data = Object.freeze({
user: { name: 'John' }
});
data.user.name = 'Jane'; // This works! (shallow freeze)
console.log(data.user.name); // 'Jane'
Deep Freeze
function deepFreeze(obj) {
Object.freeze(obj);
Object.values(obj).forEach(value => {
if (typeof value === 'object' && value !== null) {
deepFreeze(value);
}
});
return obj;
}
const data = deepFreeze({
user: { name: 'John', address: { city: 'NYC' } }
});
// All levels are frozen
data.user.name = 'Jane'; // Fails
data.user.address.city = 'Boston'; // Fails
console.log(data.user.name); // 'John'
Real-World Example: Redux Reducer
// Initial state
const initialState = {
todos: [],
filter: 'all'
};
// Pure reducer function
function todosReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.text,
completed: false
}
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.id)
};
case 'SET_FILTER':
return {
...state,
filter: action.filter
};
default:
return state;
}
}
// Usage
let state = initialState;
state = todosReducer(state, { type: 'ADD_TODO', text: 'Learn Redux' });
state = todosReducer(state, { type: 'ADD_TODO', text: 'Build app' });
state = todosReducer(state, { type: 'TOGGLE_TODO', id: state.todos[0].id });
console.log(state);
// {
// todos: [
// { id: ..., text: 'Learn Redux', completed: true },
// { id: ..., text: 'Build app', completed: false }
// ],
// filter: 'all'
// }
Real-World Example: React State Updates
// React component with immutable state updates
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
// Immutable - create new array
setTodos([...todos, { id: Date.now(), text, done: false }]);
};
const toggleTodo = (id) => {
// Immutable - map to new array
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, done: !todo.done }
: todo
));
};
const deleteTodo = (id) => {
// Immutable - filter to new array
setTodos(todos.filter(todo => todo.id !== id));
};
return (
// JSX here
);
}
Immutable Update Patterns
Update Array of Objects
const users = [
{ id: 1, name: 'John', active: true },
{ id: 2, name: 'Jane', active: false },
{ id: 3, name: 'Bob', active: true }
];
// Update one user
const updated = users.map(user =>
user.id === 2
? { ...user, active: true }
: user
);
// Update multiple users
const allActive = users.map(user => ({ ...user, active: true }));
Merge Objects
const defaults = { theme: 'light', language: 'en' };
const userPrefs = { theme: 'dark' };
// Merge with spread
const settings = { ...defaults, ...userPrefs };
console.log(settings); // { theme: 'dark', language: 'en' }
// Merge with Object.assign
const settings2 = Object.assign({}, defaults, userPrefs);
Deep Clone
// Shallow clone (only top level)
const shallow = { ...original };
// Deep clone (all levels) - simple approach
const deep = JSON.parse(JSON.stringify(original));
// Deep clone - better approach (handles dates, functions, etc.)
function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj);
if (obj instanceof Array) return obj.map(item => deepClone(item));
const cloned = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key]);
}
}
return cloned;
}
Performance Considerations
When Immutability Helps Performance:
- React's shouldComponentUpdate and React.memo
- Redux's shallow equality checks
- Easier change detection
When Immutability May Hurt Performance:
- Very large arrays/objects (lots of copying)
- Frequent updates to large data structures
- Deep cloning complex nested structures
Solution: Use libraries like Immer or Immutable.js for better performance with large data.
Using Immer for Easier Immutability
import produce from 'immer';
const state = {
todos: [
{ id: 1, text: 'Learn Immer', done: false }
]
};
// With Immer, write "mutable" code that produces immutable results
const nextState = produce(state, draft => {
draft.todos.push({ id: 2, text: 'Use Immer', done: false });
draft.todos[0].done = true;
});
console.log(state === nextState); // false (new object)
console.log(state.todos === nextState.todos); // false (new array)
console.log(state.todos[0]); // { id: 1, text: '...', done: false }
console.log(nextState.todos[0]); // { id: 1, text: '...', done: true }
Common Pitfalls
Pitfall 1: Shallow copying nested objects
// Wrong - nested objects are still shared
const copy = { ...original };
copy.nested.value = 'changed'; // Mutates original!
// Right - deep copy nested objects
const copy = {
...original,
nested: { ...original.nested }
};
Pitfall 2: Forgetting to return new state
// Wrong - modifies and returns original
function reducer(state, action) {
state.count++;
return state;
}
// Right - returns new state
function reducer(state, action) {
return { ...state, count: state.count + 1 };
}
Pitfall 3: Using mutating array methods
// Mutating methods (avoid):
push, pop, shift, unshift, splice, sort, reverse
// Non-mutating alternatives:
concat, slice, map, filter, reduce, spread operator
Key Takeaways
- Immutability means data cannot be changed after creation
- Pure functions have no side effects and always return the same output for the same input
- Use spread operator (...) for immutable updates
- Array methods like map, filter, reduce are immutable
- Avoid push, pop, splice, and direct property assignment
- Object.freeze() provides shallow immutability
- Deep updates require spreading at each level
- Immutability makes code more predictable and easier to debug
- Use Immer for complex immutable updates