rws8.tech
  • Home
  • Tutorials
  • About
Home > Tutorials > JavaScript > Immutability > Lesson 1

Understanding JavaScript Immutability

Write Predictable, Bug-Free Code

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
← Back to Overview More JavaScript Tutorials →

© 2024 rws8.tech. All rights reserved.

GitHub LinkedIn