This page looks best with JavaScript enabled

Data Normalization in State Management

 ·  ☕ 10 min read

Why Data Normalization is Essential in State Management

If you’ve ever worked with complex application state, you’ve likely encountered the pain of deeply nested data structures. Perhaps you’ve updated a user’s name in one place, only to realize it’s duplicated in three other locations throughout your state tree. Or maybe you’ve struggled to efficiently update a single item buried within multiple arrays. This is where data normalization becomes invaluable.

What is Data Normalization?

Data normalization is the process of structuring your state in a flat, relational-database-like format where each entity type has its own collection, and relationships between entities are expressed through IDs rather than nested objects.

Think of it like organizing a library. Instead of storing complete book information with each author’s biography every time they’re referenced, you maintain a separate catalog of authors and reference them by ID. This approach eliminates redundancy and creates a single source of truth for each piece of data.

The Problem: Nested, Denormalized Data

Let’s look at a typical scenario where denormalized data causes issues. Imagine you’re building a blog platform and receive this data from your API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const blogPosts = [
  {
    id: 'post1',
    title: 'Introduction to React',
    author: {
      id: 'user1',
      name: 'Sarah Johnson',
      email: 'sarah@example.com'
    },
    comments: [
      {
        id: 'comment1',
        text: 'Great article!',
        author: {
          id: 'user2',
          name: 'Mike Chen',
          email: 'mike@example.com'
        }
      },
      {
        id: 'comment2',
        text: 'Thanks for sharing',
        author: {
          id: 'user1',
          name: 'Sarah Johnson',
          email: 'sarah@example.com'
        }
      }
    ]
  },
  {
    id: 'post2',
    title: 'Advanced Redux Patterns',
    author: {
      id: 'user1',
      name: 'Sarah Johnson',
      email: 'sarah@example.com'
    },
    comments: [
      {
        id: 'comment3',
        text: 'Very helpful!',
        author: {
          id: 'user2',
          name: 'Mike Chen',
          email: 'mike@example.com'
        }
      }
    ]
  }
]

Problems with This Structure

  1. Data Duplication: Sarah Johnson’s information appears three times, Mike Chen’s twice. If Sarah updates her name, you’d need to find and update every occurrence.

  2. Inconsistent Updates: It’s easy to update some instances and miss others, leading to data inconsistencies.

  3. Complex Update Logic: Updating a specific comment requires traversing the entire nested structure:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    // Updating comment1's text - deeply nested access
    const updatedPosts = posts.map(post => 
      post.id === 'post1' 
        ? {
            ...post,
            comments: post.comments.map(comment =>
              comment.id === 'comment1'
                ? { ...comment, text: 'Updated text' }
                : comment
            )
          }
        : post
    )
    
  4. Performance Issues: Components re-render unnecessarily because changes to one part of the tree affect the entire nested structure.

  5. Difficult Queries: Finding all posts by a specific author or all comments by a user requires searching through nested arrays.

The Solution: Normalized Data

After normalization, the same data looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const normalizedState = {
  posts: {
    byId: {
      'post1': {
        id: 'post1',
        title: 'Introduction to React',
        author: 'user1',
        comments: ['comment1', 'comment2']
      },
      'post2': {
        id: 'post2',
        title: 'Advanced Redux Patterns',
        author: 'user1',
        comments: ['comment3']
      }
    },
    allIds: ['post1', 'post2']
  },
  users: {
    byId: {
      'user1': {
        id: 'user1',
        name: 'Sarah Johnson',
        email: 'sarah@example.com'
      },
      'user2': {
        id: 'user2',
        name: 'Mike Chen',
        email: 'mike@example.com'
      }
    },
    allIds: ['user1', 'user2']
  },
  comments: {
    byId: {
      'comment1': {
        id: 'comment1',
        text: 'Great article!',
        author: 'user2',
        post: 'post1'
      },
      'comment2': {
        id: 'comment2',
        text: 'Thanks for sharing',
        author: 'user1',
        post: 'post1'
      },
      'comment3': {
        id: 'comment3',
        text: 'Very helpful!',
        author: 'user2',
        post: 'post2'
      }
    },
    allIds: ['comment1', 'comment2', 'comment3']
  }
}

How Normalization Helps

1. Single Source of Truth

Each entity exists in exactly one place. When Sarah updates her name, you update it once:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Simple, direct update
const updateUserName = (state, userId, newName) => ({
  ...state,
  users: {
    ...state.users,
    byId: {
      ...state.users.byId,
      [userId]: {
        ...state.users.byId[userId],
        name: newName
      }
    }
  }
})

2. Simplified Updates

Updating entities becomes straightforward and predictable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Update a comment - no traversal needed
const updateComment = (state, commentId, newText) => ({
  ...state,
  comments: {
    ...state.comments,
    byId: {
      ...state.comments.byId,
      [commentId]: {
        ...state.comments.byId[commentId],
        text: newText
      }
    }
  }
})

3. Efficient Lookups

Finding entities by ID becomes O(1) instead of O(n):

1
2
3
// Direct access instead of array search
const getUser = (state, userId) => state.users.byId[userId]
const getComment = (state, commentId) => state.comments.byId[commentId]

4. Better Performance

Components can subscribe to specific slices of state:

1
2
3
4
5
// Component only re-renders when this specific user changes
const UserProfile = ({ userId }) => {
  const user = useSelector(state => state.users.byId[userId])
  return <div>{user.name}</div>
}

5. Easier Queries

Relational queries become simpler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Get all posts by a user
const getPostsByUser = (state, userId) => 
  state.posts.allIds
    .map(id => state.posts.byId[id])
    .filter(post => post.author === userId)

// Get all comments for a post with author details
const getPostComments = (state, postId) => {
  const post = state.posts.byId[postId]
  return post.comments.map(commentId => ({
    ...state.comments.byId[commentId],
    authorName: state.users.byId[state.comments.byId[commentId].author].name
  }))
}

Official Documentation and Best Practices

Redux Documentation

The Redux team strongly advocates for normalized state structure. The official Redux documentation has an excellent guide on normalization:

Structuring Reducers: Normalizing State Shape

This guide covers:

  • The benefits of normalized state
  • Recommended state shape structure
  • Organizing normalized data
  • Relationships and tables

Redux Essentials: Normalizing Data

This tutorial demonstrates:

  • Real-world normalization examples
  • How to use createEntityAdapter from Redux Toolkit
  • Performance optimization through normalization

Redux Toolkit’s createEntityAdapter

Redux Toolkit provides built-in utilities for managing normalized state:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

const postsAdapter = createEntityAdapter({
  selectId: (post) => post.id,
  sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const postsSlice = createSlice({
  name: 'posts',
  initialState: postsAdapter.getInitialState(),
  reducers: {
    postAdded: postsAdapter.addOne,
    postsReceived: postsAdapter.setAll,
    postUpdated: postsAdapter.updateOne,
    postDeleted: postsAdapter.removeOne
  }
})

// Auto-generated selectors
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById,
  selectIds: selectPostIds
} = postsAdapter.getSelectors(state => state.posts)

Other State Management Libraries

Flux
While Facebook’s official Flux documentation doesn’t explicitly prescribe normalization, the pattern emerged as a community best practice through practical experience. Dan Abramov (creator of Redux and Normalizr) notably advised:

“Flux is only hard if your data isn’t normalized. Treat your Stores as database tables, use global unique IDs to reference related entities.”

This approach—separating concerns by entity type and using ID references—became widely adopted in the Flux community, with stores managing state for specific domains (UserStore, PostStore, CommentStore) rather than nested hierarchies.

MobX
While MobX uses observable objects rather than immutable updates, normalization is still beneficial:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { makeObservable, observable, action } from 'mobx'

class Store {
  users = new Map()
  posts = new Map()
  comments = new Map()

  constructor() {
    makeObservable(this, {
      users: observable,
      posts: observable,
      comments: observable,
      addUser: action,
      updateUser: action
    })
  }

  addUser(user) {
    this.users.set(user.id, user)
  }

  updateUser(userId, updates) {
    const user = this.users.get(userId)
    Object.assign(user, updates)
  }
}

Zustand
Zustand works well with normalized patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import create from 'zustand'

const useStore = create((set) => ({
  users: { byId: {}, allIds: [] },
  posts: { byId: {}, allIds: [] },
  
  addUser: (user) => set((state) => ({
    users: {
      byId: { ...state.users.byId, [user.id]: user },
      allIds: [...state.users.allIds, user.id]
    }
  })),
  
  updateUser: (userId, updates) => set((state) => ({
    users: {
      ...state.users,
      byId: {
        ...state.users.byId,
        [userId]: { ...state.users.byId[userId], ...updates }
      }
    }
  }))
}))

Tools for Normalization: Normalizr

Manually normalizing data is tedious and error-prone. The normalizr library automates this process.

Installation

1
2
3
npm install normalizr
# or
yarn add normalizr

Basic Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { normalize, schema } from 'normalizr'

// Define schemas
const user = new schema.Entity('users')
const comment = new schema.Entity('comments', {
  author: user
})
const post = new schema.Entity('posts', {
  author: user,
  comments: [comment]
})

// Normalize the data
const normalizedData = normalize(blogPosts, [post])

console.log(normalizedData)
/* Output:
{
  entities: {
    users: {
      'user1': { id: 'user1', name: 'Sarah Johnson', email: 'sarah@example.com' },
      'user2': { id: 'user2', name: 'Mike Chen', email: 'mike@example.com' }
    },
    comments: {
      'comment1': { id: 'comment1', text: 'Great article!', author: 'user2' },
      'comment2': { id: 'comment2', text: 'Thanks for sharing', author: 'user1' },
      'comment3': { id: 'comment3', text: 'Very helpful!', author: 'user2' }
    },
    posts: {
      'post1': { id: 'post1', title: 'Introduction to React', author: 'user1', comments: ['comment1', 'comment2'] },
      'post2': { id: 'post2', title: 'Advanced Redux Patterns', author: 'user1', comments: ['comment3'] }
    }
  },
  result: ['post1', 'post2']
}
*/

Integration with Redux

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { normalize, schema } from 'normalizr'

// Define schemas
const user = new schema.Entity('users')
const comment = new schema.Entity('comments', { author: user })
const post = new schema.Entity('posts', {
  author: user,
  comments: [comment]
})

// Action creator
const fetchPostsSuccess = (posts) => {
  const normalized = normalize(posts, [post])
  
  return {
    type: 'POSTS_FETCHED',
    payload: normalized
  }
}

// Reducer
const rootReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'POSTS_FETCHED':
      return {
        ...state,
        users: {
          ...state.users,
          byId: {
            ...state.users.byId,
            ...action.payload.entities.users
          },
          allIds: [
            ...state.users.allIds,
            ...Object.keys(action.payload.entities.users || {})
          ]
        },
        posts: {
          ...state.posts,
          byId: {
            ...state.posts.byId,
            ...action.payload.entities.posts
          },
          allIds: action.payload.result
        },
        comments: {
          ...state.comments,
          byId: {
            ...state.comments.byId,
            ...action.payload.entities.comments
          },
          allIds: [
            ...state.comments.allIds,
            ...Object.keys(action.payload.entities.comments || {})
          ]
        }
      }
    default:
      return state
  }
}

Denormalization with Normalizr

Normalizr also provides a denormalize function to reconstruct nested objects when needed for display:

1
2
3
4
5
6
7
8
9
import { denormalize } from 'normalizr'

const denormalizedPost = denormalize(
  'post1',
  post,
  normalizedData.entities
)

// Returns the original nested structure for rendering

When to Normalize

Normalization isn’t always necessary. Consider it when:

  • Multiple components need access to the same data
  • Entities have relationships (users, posts, comments, etc.)
  • Data is duplicated across your state tree
  • You need to update entities independently of their context
  • Performance is a concern with deeply nested data

For simple, isolated data structures, normalization might be overkill.

Best Practices

  1. Use byId and allIds pattern: This is the most common and recommended structure
  2. Keep relationships through IDs: Store references, not nested objects
  3. Use normalization libraries: Don’t manually normalize complex data
  4. Create selectors: Use reselect or similar libraries to denormalize data for components
  5. Normalize at the boundaries: Normalize when data enters your application, denormalize for display
  6. Consider Redux Toolkit: createEntityAdapter handles most normalization concerns automatically

Conclusion

Data normalization transforms state management from a source of complexity into a predictable, maintainable system. By treating your application state like a database with tables and IDs, you eliminate duplication, simplify updates, and improve performance.

The pattern is well-supported across the ecosystem, with Redux officially recommending it, Redux Toolkit providing built-in utilities, and libraries like normalizr making implementation straightforward. While it requires upfront investment in understanding and setup, the long-term benefits in maintainability and performance make normalization an essential pattern for complex applications.

Start small—normalize your most problematic data first, and gradually expand the pattern as you see the benefits. Your future self (and your teammates) will thank you.

References

Share on