How to Build Immutable Reducers with Lodash FP

This article explores how developers achieve strictly immutable, pure state transformations in reducers using the functional programming variant of Lodash (lodash/fp). You will learn the specific architectural mechanics of lodash/fp, why standard Lodash falls short of immutability, and how functions like fp.set and fp.update cleanly enforce functional purity when managing complex application state.


The Mechanism: Lodash/fp

Vanilla Lodash (lodash) is inherently mutable for path-based operations. Methods like _.set or _.merge mutate the source object directly, making them unsuitable for pure state management patterns such as Redux reducers.

To strictly enforce immutability using only Lodash, you must use the functional programming submodule: lodash/fp.

lodash/fp alters the core behavior of the library in three fundamental ways:

  1. Immutable by Default: Methods create structural shallow-to-deep clones of altered paths instead of modifying the target object.
  2. Data-Last Architecture: The target data object is always the final argument, enabling effortless functional composition.
  3. Auto-Curried: Every method is curried automatically, allowing path definitions and transformation logic to be pre-configured.

Key Functions for Immutable Reducers

The primary functions in lodash/fp that enforce pure updates are:


Implementing an Immutable Reducer

Below is an example of a reducer utilizing lodash/fp to guarantee that all updates remain purely functional and dynamically mapped.

import fp from 'lodash/fp';

const initialState = {
  users: {
    byId: {
      'user-1': { name: 'Alice', active: false }
    },
    allIds: ['user-1']
  },
  meta: {
    lastUpdated: null
  }
};

export function userReducer(state = initialState, action) {
  switch (action.type) {
    case 'ACTIVATE_USER':
      // Dynamically paths into nested properties and returns a fresh object reference
      return fp.flow(
        fp.set(['users', 'byId', action.payload.id, 'active'], true),
        fp.set(['meta', 'lastUpdated'], action.payload.timestamp)
      )(state);

    case 'UPDATE_USER_NAME':
      // Dynamically updates an existing value cleanly via transformation logic
      return fp.update(
        ['users', 'byId', action.payload.id, 'name'],
        () => action.payload.newName,
        state
      );

    case 'REMOVE_USER':
      return fp.flow(
        fp.unset(['users', 'byId', action.payload.id]),
        fp.update(['users', 'allIds'], (ids) => ids.filter(id => id !== action.payload.id))
      )(state);

    default:
      return state;
  }
}

Why This Enforces Strict Purity

  1. Structural Sharing: lodash/fp copies only the nodes along the path being altered while retaining unmodified object references, satisfying the performance requirements of pure components and change-detection systems.
  2. Zero In-Place Side Effects: The original state passed to the reducer is never touched. Calling Object.is(state, newState) evaluates to false when an action produces a mutation, and true when the default branch returns the previous state.
  3. Pointfree Composition: By pairing fp.flow with curried path functions, actions apply multiple deep transformations without maintaining intermediary local variables, eliminating risks of accidental in-place reassignments.