How to Normalize Nested Redux State with Lodash

Managing deeply nested state in Redux often leads to convoluted reducer logic, accidental state mutations, and unnecessary component re-renders. Structural normalization resolves these issues by flattening nested hierarchies into relational, database-like structures organized by IDs. The Lodash utility library provides a robust, lightweight set of functional tools that streamline this transformation process, enabling developers to decompose nested API responses into normalized state slices without relying on heavier specialized schema-definition libraries.

The Challenge of Nested Redux State

API payloads frequently nest related resources—such as a blog post containing an author object and an array of comment objects, each with their own author. Storing this data hierarchically causes several architectural problems:

Normalization flattens this structure into distinct entity tables indexed by ID, replacing nested objects with relational references (arrays of IDs).

Key Lodash Functions for State Normalization

Lodash provides functional data-manipulation methods that map directly to the requirements of data normalization:

1. _.keyBy

The foundation of a normalized store is the lookup table (a dictionary indexed by entity ID). _.keyBy transforms a flat array of entities into an object keyed by a designated property:

import keyBy from 'lodash/keyBy';

const postsArray = [
  { id: 'p1', title: 'First Post' },
  { id: 'p2', title: 'Second Post' }
];

const postsById = keyBy(postsArray, 'id');
// Result:
// {
//   p1: { id: 'p1', title: 'First Post' },
//   p2: { id: 'p2', title: 'Second Post' }
// }

2. _.mapValues

When normalizing, parent entities must replace nested child objects with ID references. _.mapValues iterates over an indexed object and maps its properties to a new shape:

import mapValues from 'lodash/mapValues';

// Converting nested author objects to author IDs in a posts dictionary
const normalizedPosts = mapValues(postsById, post => ({
  ...post,
  author: post.author.id,
  comments: post.comments.map(c => c.id)
}));

3. _.flatMap and _.uniqBy

Nested entities must be extracted from their parents and collected into their own top-level collections. _.flatMap extracts nested collections into a single array, while _.uniqBy removes duplicates:

import flatMap from 'lodash/flatMap';
import uniqBy from 'lodash/uniqBy';

// Extract all unique comments from an array of posts
const allComments = uniqBy(
  flatMap(postsArray, post => post.comments),
  'id'
);
const commentsById = keyBy(allComments, 'id');

4. _.groupBy

For one-to-many relationships where an entity needs to know which children belong to it, _.groupBy aggregates keys:

import groupBy from 'lodash/groupBy';

// Group comment IDs by their parent post ID
const commentIdsByPostId = mapValues(
  groupBy(allComments, 'postId'),
  comments => comments.map(c => c.id)
);

Practical Implementation: Normalizing a Nested Payload

Consider an incoming API payload containing articles, embedded author profiles, and embedded comments:

import keyBy from 'lodash/keyBy';
import mapValues from 'lodash/mapValues';
import flatMap from 'lodash/flatMap';
import uniqBy from 'lodash/uniqBy';

export function normalizeArticlesPayload(articles) {
  // 1. Extract and normalize authors
  const authors = uniqBy(
    articles.map(article => article.author),
    'id'
  );

  // 2. Extract and normalize comments
  const comments = uniqBy(
    flatMap(articles, article => article.comments || []),
    'id'
  );

  // 3. Normalize articles by replacing child objects with ID references
  const processedArticles = articles.map(article => ({
    id: article.id,
    title: article.title,
    authorId: article.author.id,
    commentIds: (article.comments || []).map(comment => comment.id)
  }));

  // 4. Return the fully flattened slices
  return {
    entities: {
      articles: keyBy(processedArticles, 'id'),
      authors: keyBy(authors, 'id'),
      comments: keyBy(comments, 'id')
    },
    result: articles.map(article => article.id)
  };
}

Safe Updates in Reducers with _.merge and _.omit

Once state is normalized, updating it requires shallow operations. Lodash helps maintain immutability and handle dynamic updates cleanly:

import omit from 'lodash/omit';

function commentsReducer(state = { byId: {}, allIds: [] }, action) {
  switch (action.type) {
    case 'COMMENT_DELETED':
      return {
        byId: omit(state.byId, action.payload.id),
        allIds: state.allIds.filter(id => id !== action.payload.id)
      };
    default:
      return state;
  }
}

Using Lodash for state normalization gives developers granular control over data shaping without the overhead of schema-driven frameworks. Its functional utilities ensure deterministic transformations, reduce boilerplate in reducers, and optimize Redux store performance.