Normalized State Structure

Understanding State Normalization

State normalization is a data structure pattern where we store entities in a flat structure with references between them, similar to a relational database. This prevents data duplication and makes updates more efficient.

📚 The Library Catalog Analogy

Think of state normalization like a library catalog system:

  • Denormalized: Like having complete book information duplicated on every shelf where the book appears
  • Normalized: Like having a central catalog with book IDs, and shelves only store the book IDs
  • Entities: The books, authors, and categories in the catalog
  • IDs: The unique catalog numbers for each item
  • Relationships: References between books and their authors

Just as libraries use catalog systems to avoid duplicating information, normalized state uses IDs to reference data stored in one place.

The Problem with Denormalized State

graph TD A[Denormalized State] --> B[Data Duplication] A --> C[Update Complexity] A --> D[Inconsistent Data] A --> E[Large Memory Usage] B --> F[Same User in Multiple Places] C --> G[Update User in Every Location] D --> H[Some Updates Missed] E --> I[Redundant Data Storage] style A fill:#f96 style B fill:#fcc style C fill:#fcc style D fill:#fcc style E fill:#fcc

Example of Denormalized State


// Denormalized state - data is duplicated
const denormalizedState = {
  posts: [
    {
      id: "post1",
      title: "First Post",
      author: {
        id: "user1",
        name: "John Doe",
        email: "john@example.com"
      },
      comments: [
        {
          id: "comment1",
          text: "Great post!",
          author: {
            id: "user2",
            name: "Jane Smith",
            email: "jane@example.com"
          }
        },
        {
          id: "comment2",
          text: "Thanks for sharing!",
          author: {
            id: "user1",            // Same user as post author
            name: "John Doe",       // Duplicated data
            email: "john@example.com" // Duplicated data
          }
        }
      ]
    },
    {
      id: "post2",
      title: "Second Post",
      author: {
        id: "user2",
        name: "Jane Smith",      // Duplicated from comment above
        email: "jane@example.com" // Duplicated from comment above
      },
      comments: []
    }
  ]
};

// Problems with denormalized state:
// 1. User data is duplicated multiple times
// 2. Updating a user requires finding all instances
// 3. Easy to miss updates and create inconsistencies
// 4. Takes more memory
// 5. Complex update logic

// Example: Update user's email
function updateUserEmail(state, userId, newEmail) {
  return {
    posts: state.posts.map(post => ({
      ...post,
      author: post.author.id === userId
        ? { ...post.author, email: newEmail }
        : post.author,
      comments: post.comments.map(comment => ({
        ...comment,
        author: comment.author.id === userId
          ? { ...comment.author, email: newEmail }
          : comment.author
      }))
    }))
  };
}
            

Benefits of Normalized State

graph TD A[Normalized State] --> B[Single Source of Truth] A --> C[Efficient Updates] A --> D[Consistent Data] A --> E[Less Memory Usage] B --> F[Each Entity Stored Once] C --> G[Update in One Place] D --> H[No Sync Issues] E --> I[No Redundant Data] style A fill:#9f9 style B fill:#cfc style C fill:#cfc style D fill:#cfc style E fill:#cfc

Example of Normalized State


// Normalized state - each entity stored once
const normalizedState = {
  entities: {
    posts: {
      "post1": {
        id: "post1",
        title: "First Post",
        author: "user1",        // Reference to user ID
        comments: ["comment1", "comment2"]  // References to comment IDs
      },
      "post2": {
        id: "post2",
        title: "Second Post",
        author: "user2",
        comments: []
      }
    },
    users: {
      "user1": {
        id: "user1",
        name: "John Doe",
        email: "john@example.com"
      },
      "user2": {
        id: "user2",
        name: "Jane Smith",
        email: "jane@example.com"
      }
    },
    comments: {
      "comment1": {
        id: "comment1",
        text: "Great post!",
        author: "user2",        // Reference to user ID
        post: "post1"          // Reference to post ID
      },
      "comment2": {
        id: "comment2",
        text: "Thanks for sharing!",
        author: "user1",
        post: "post1"
      }
    }
  },
  // Optional: Track ordered lists of IDs
  result: {
    posts: ["post1", "post2"]
  }
};

// Benefits:
// 1. Each entity stored exactly once
// 2. Updates only need to happen in one place
// 3. No data inconsistency
// 4. Less memory usage
// 5. Simple update logic

// Example: Update user's email
function updateUserEmail(state, userId, newEmail) {
  return {
    ...state,
    entities: {
      ...state.entities,
      users: {
        ...state.entities.users,
        [userId]: {
          ...state.entities.users[userId],
          email: newEmail
        }
      }
    }
  };
}
            

Normalization Principles

1. Each Entity Type Gets Its Own Table


// Before normalization
const denormalized = {
  articles: [
    {
      id: 1,
      title: "Redux Tutorial",
      author: { id: 1, name: "Dan Abramov" },
      category: { id: 1, name: "Programming" }
    }
  ]
};

// After normalization
const normalized = {
  entities: {
    articles: {
      1: { id: 1, title: "Redux Tutorial", author: 1, category: 1 }
    },
    authors: {
      1: { id: 1, name: "Dan Abramov" }
    },
    categories: {
      1: { id: 1, name: "Programming" }
    }
  }
};
            

2. Each Entity Is Keyed By Its ID


// Good: Objects keyed by ID for O(1) lookup
const users = {
  "user1": { id: "user1", name: "John" },
  "user2": { id: "user2", name: "Jane" }
};

// Bad: Arrays require O(n) lookup
const users = [
  { id: "user1", name: "John" },
  { id: "user2", name: "Jane" }
];

// Finding a user by ID
const user = users["user1"];  // O(1) with objects
const user = users.find(u => u.id === "user1");  // O(n) with arrays
            

3. Relationships Are Represented By IDs


// Good: Store IDs as references
const post = {
  id: "post1",
  author: "user1",  // ID reference
  comments: ["comment1", "comment2"]  // Array of ID references
};

// Bad: Store entire objects
const post = {
  id: "post1",
  author: { id: "user1", name: "John", email: "john@example.com" },
  comments: [
    { id: "comment1", text: "Great!", author: {...} },
    { id: "comment2", text: "Thanks!", author: {...} }
  ]
};
            

4. Arrays of IDs for Ordered Collections


// When order matters, maintain arrays of IDs
const normalizedState = {
  entities: {
    posts: {
      "post1": { id: "post1", title: "First" },
      "post2": { id: "post2", title: "Second" },
      "post3": { id: "post3", title: "Third" }
    }
  },
  // Maintain order with ID arrays
  ui: {
    postList: {
      ids: ["post2", "post1", "post3"],  // Custom order
      loading: false
    }
  }
};
            

Using Normalizr Library


// Install normalizr
// npm install normalizr

import { normalize, schema } from 'normalizr';

// Define your 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]
});

// Original denormalized data
const originalData = {
  id: "post1",
  title: "My Post",
  author: {
    id: "user1",
    name: "John Doe"
  },
  comments: [
    {
      id: "comment1",
      text: "Nice post!",
      author: {
        id: "user2",
        name: "Jane Smith"
      }
    }
  ]
};

// Normalize the data
const normalizedData = normalize(originalData, post);

console.log(normalizedData);
/* Output:
{
  result: "post1",
  entities: {
    users: {
      "user1": { id: "user1", name: "John Doe" },
      "user2": { id: "user2", name: "Jane Smith" }
    },
    comments: {
      "comment1": { 
        id: "comment1", 
        text: "Nice post!", 
        author: "user2" 
      }
    },
    posts: {
      "post1": { 
        id: "post1", 
        title: "My Post", 
        author: "user1", 
        comments: ["comment1"] 
      }
    }
  }
}
*/

// Normalizing arrays of data
const posts = [
  { id: "1", title: "First", author: { id: "1", name: "John" } },
  { id: "2", title: "Second", author: { id: "1", name: "John" } },
  { id: "3", title: "Third", author: { id: "2", name: "Jane" } }
];

const normalizedPosts = normalize(posts, [post]);
/* Output:
{
  result: ["1", "2", "3"],
  entities: {
    users: {
      "1": { id: "1", name: "John" },
      "2": { id: "2", name: "Jane" }
    },
    posts: {
      "1": { id: "1", title: "First", author: "1" },
      "2": { id: "2", title: "Second", author: "1" },
      "3": { id: "3", title: "Third", author: "2" }
    }
  }
}
*/
            

Implementing Normalized State in Redux


// Redux slice with normalized state
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
import { normalize, schema } from 'normalizr';

// Define schemas
const userEntity = new schema.Entity('users');
const postEntity = new schema.Entity('posts', {
  author: userEntity
});

// Create entity adapters
const postsAdapter = createEntityAdapter();
const usersAdapter = createEntityAdapter();

// Initial state
const initialState = {
  posts: postsAdapter.getInitialState(),
  users: usersAdapter.getInitialState()
};

// Slice
const blogSlice = createSlice({
  name: 'blog',
  initialState,
  reducers: {
    postsReceived(state, action) {
      // Normalize the data
      const normalized = normalize(action.payload, [postEntity]);
      
      // Update posts
      postsAdapter.setAll(state.posts, normalized.entities.posts || {});
      
      // Update users
      if (normalized.entities.users) {
        usersAdapter.upsertMany(state.users, normalized.entities.users);
      }
    },
    postAdded(state, action) {
      const normalized = normalize(action.payload, postEntity);
      
      postsAdapter.addOne(state.posts, normalized.entities.posts[normalized.result]);
      
      if (normalized.entities.users) {
        usersAdapter.upsertMany(state.users, normalized.entities.users);
      }
    },
    postUpdated(state, action) {
      const { id, changes } = action.payload;
      postsAdapter.updateOne(state.posts, { id, changes });
    },
    userUpdated(state, action) {
      const { id, changes } = action.payload;
      usersAdapter.updateOne(state.users, { id, changes });
    }
  }
});

// Selectors
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById
} = postsAdapter.getSelectors(state => state.blog.posts);

export const {
  selectAll: selectAllUsers,
  selectById: selectUserById
} = usersAdapter.getSelectors(state => state.blog.users);

// Denormalized selector
export const selectPostWithAuthor = (state, postId) => {
  const post = selectPostById(state, postId);
  if (!post) return null;
  
  return {
    ...post,
    author: selectUserById(state, post.author)
  };
};

// Action creators
export const { postsReceived, postAdded, postUpdated, userUpdated } = blogSlice.actions;
export default blogSlice.reducer;
            

Manual Normalization Techniques


// Manual normalization helper functions
function normalizeData(data, schema) {
  const entities = {};
  const result = [];
  
  data.forEach(item => {
    const { id, ...rest } = item;
    
    // Process relationships
    Object.keys(schema).forEach(key => {
      if (rest[key]) {
        // If it's an object, normalize it
        if (typeof rest[key] === 'object' && !Array.isArray(rest[key])) {
          const relatedEntity = rest[key];
          if (!entities[key]) entities[key] = {};
          entities[key][relatedEntity.id] = relatedEntity;
          rest[key] = relatedEntity.id;
        }
        // If it's an array of objects, normalize them
        else if (Array.isArray(rest[key])) {
          const ids = rest[key].map(relatedItem => {
            if (typeof relatedItem === 'object') {
              if (!entities[key]) entities[key] = {};
              entities[key][relatedItem.id] = relatedItem;
              return relatedItem.id;
            }
            return relatedItem;
          });
          rest[key] = ids;
        }
      }
    });
    
    // Store the main entity
    if (!entities[schema._name]) entities[schema._name] = {};
    entities[schema._name][id] = { id, ...rest };
    result.push(id);
  });
  
  return { entities, result };
}

// Usage example
const schema = {
  _name: 'posts',
  author: 'users',
  comments: 'comments'
};

const denormalizedData = [
  {
    id: 1,
    title: "Post 1",
    author: { id: 1, name: "John" },
    comments: [
      { id: 1, text: "Comment 1", author: { id: 2, name: "Jane" } }
    ]
  }
];

const normalized = normalizeData(denormalizedData, schema);

// Denormalization helper
function denormalizeData(normalizedData, id, schema) {
  const mainEntity = { ...normalizedData.entities[schema._name][id] };
  
  Object.keys(schema).forEach(key => {
    if (key !== '_name' && mainEntity[key]) {
      // Single relationship
      if (typeof mainEntity[key] === 'string' || typeof mainEntity[key] === 'number') {
        mainEntity[key] = normalizedData.entities[schema[key]][mainEntity[key]];
      }
      // Array relationship
      else if (Array.isArray(mainEntity[key])) {
        mainEntity[key] = mainEntity[key].map(relatedId =>
          normalizedData.entities[schema[key]][relatedId]
        );
      }
    }
  });
  
  return mainEntity;
}
            

Real-World Example: Blog Application


// store/slices/blogSlice.js
import { createSlice, createEntityAdapter, createSelector } from '@reduxjs/toolkit';
import { normalize, schema } from 'normalizr';

// Define schemas
const userSchema = new schema.Entity('users');
const commentSchema = new schema.Entity('comments', {
  author: userSchema
});
const postSchema = new schema.Entity('posts', {
  author: userSchema,
  comments: [commentSchema]
});

// Create adapters
const postsAdapter = createEntityAdapter({
  sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt)
});
const usersAdapter = createEntityAdapter();
const commentsAdapter = createEntityAdapter();

// Initial state
const initialState = {
  posts: postsAdapter.getInitialState({
    loading: false,
    error: null
  }),
  users: usersAdapter.getInitialState(),
  comments: commentsAdapter.getInitialState()
};

// Slice
const blogSlice = createSlice({
  name: 'blog',
  initialState,
  reducers: {
    fetchPostsStart(state) {
      state.posts.loading = true;
      state.posts.error = null;
    },
    fetchPostsSuccess(state, action) {
      const normalized = normalize(action.payload, [postSchema]);
      
      postsAdapter.setAll(state.posts, normalized.entities.posts || {});
      usersAdapter.setAll(state.users, normalized.entities.users || {});
      commentsAdapter.setAll(state.comments, normalized.entities.comments || {});
      
      state.posts.loading = false;
    },
    fetchPostsFailure(state, action) {
      state.posts.loading = false;
      state.posts.error = action.payload;
    },
    addPost(state, action) {
      const normalized = normalize(action.payload, postSchema);
      
      postsAdapter.addOne(state.posts, normalized.entities.posts[normalized.result]);
      if (normalized.entities.users) {
        usersAdapter.upsertMany(state.users, normalized.entities.users);
      }
    },
    addComment(state, action) {
      const { postId, comment } = action.payload;
      const normalized = normalize(comment, commentSchema);
      
      // Add comment
      commentsAdapter.addOne(state.comments, normalized.entities.comments[normalized.result]);
      
      // Update post's comments array
      const post = state.posts.entities[postId];
      if (post) {
        postsAdapter.updateOne(state.posts, {
          id: postId,
          changes: {
            comments: [...(post.comments || []), normalized.result]
          }
        });
      }
      
      // Add/update user if necessary
      if (normalized.entities.users) {
        usersAdapter.upsertMany(state.users, normalized.entities.users);
      }
    },
    updatePost(state, action) {
      postsAdapter.updateOne(state.posts, action.payload);
    },
    deletePost(state, action) {
      const postId = action.payload;
      const post = state.posts.entities[postId];
      
      // Delete associated comments
      if (post && post.comments) {
        commentsAdapter.removeMany(state.comments, post.comments);
      }
      
      // Delete the post
      postsAdapter.removeOne(state.posts, postId);
    }
  }
});

// Export actions
export const {
  fetchPostsStart,
  fetchPostsSuccess,
  fetchPostsFailure,
  addPost,
  addComment,
  updatePost,
  deletePost
} = blogSlice.actions;

// Selectors
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById
} = postsAdapter.getSelectors(state => state.blog.posts);

export const {
  selectAll: selectAllUsers,
  selectById: selectUserById
} = usersAdapter.getSelectors(state => state.blog.users);

export const {
  selectAll: selectAllComments,
  selectById: selectCommentById
} = commentsAdapter.getSelectors(state => state.blog.comments);

// Denormalized selectors
export const selectDenormalizedPost = createSelector(
  [
    (state, postId) => selectPostById(state, postId),
    state => state.blog.users.entities,
    state => state.blog.comments.entities
  ],
  (post, users, comments) => {
    if (!post) return null;
    
    return {
      ...post,
      author: users[post.author],
      comments: post.comments?.map(commentId => ({
        ...comments[commentId],
        author: users[comments[commentId]?.author]
      })) || []
    };
  }
);

export const selectDenormalizedPosts = createSelector(
  [
    selectAllPosts,
    state => state.blog.users.entities,
    state => state.blog.comments.entities
  ],
  (posts, users, comments) => {
    return posts.map(post => ({
      ...post,
      author: users[post.author],
      commentCount: post.comments?.length || 0,
      // Only include comment count, not full comments for list view
    }));
  }
);

// Thunks
export const fetchPosts = () => async dispatch => {
  dispatch(fetchPostsStart());
  
  try {
    const response = await fetch('/api/posts?_embed=comments&_expand=author');
    const data = await response.json();
    dispatch(fetchPostsSuccess(data));
  } catch (error) {
    dispatch(fetchPostsFailure(error.message));
  }
};

export const createPost = (postData) => async dispatch => {
  try {
    const response = await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(postData)
    });
    const data = await response.json();
    dispatch(addPost(data));
  } catch (error) {
    console.error('Failed to create post:', error);
  }
};

export default blogSlice.reducer;

// components/PostList.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectDenormalizedPosts, fetchPosts } from '../store/slices/blogSlice';

function PostList() {
  const dispatch = useDispatch();
  const posts = useSelector(selectDenormalizedPosts);
  const loading = useSelector(state => state.blog.posts.loading);
  
  useEffect(() => {
    dispatch(fetchPosts());
  }, [dispatch]);
  
  if (loading) return 
Loading...
; return (
{posts.map(post => (

{post.title}

By {post.author.name}

{post.commentCount} comments

))}
); } // components/PostDetail.js import React from 'react'; import { useParams } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { selectDenormalizedPost } from '../store/slices/blogSlice'; function PostDetail() { const { postId } = useParams(); const post = useSelector(state => selectDenormalizedPost(state, postId)); if (!post) return
Post not found
; return (

{post.title}

By {post.author.name}

{post.content}

Comments

{post.comments.map(comment => (

{comment.text}

By {comment.author.name}

))}
); }

Best Practices for Normalized State

Do's

  • Store each entity once in a dedicated table
  • Use IDs to reference related entities
  • Use entity adapters for CRUD operations
  • Create denormalized selectors for components
  • Normalize data at the API boundary
  • Keep UI state separate from entities

Don'ts

  • Don't store duplicate data
  • Don't nest entities within other entities
  • Don't denormalize in reducers
  • Don't store derived data in state
  • Don't mix normalized and denormalized data

Performance Tips

  • Use memoized selectors for denormalization
  • Only denormalize data when needed for display
  • Consider using libraries like normalizr
  • Use entity adapters for optimized CRUD operations

Practice Exercise

Task: Create a Social Media Feed

Build a normalized state structure for a social media feed with:

  • Users with profiles
  • Posts with authors
  • Comments on posts
  • Likes on posts and comments
  • User relationships (followers/following)

// TODO: Define the normalized state structure
const initialState = {
  entities: {
    users: {},
    posts: {},
    comments: {},
    likes: {}
  },
  relationships: {
    followers: {}, // userId -> [followerIds]
    following: {}  // userId -> [followingIds]
  },
  ui: {
    feed: {
      postIds: [],
      loading: false,
      error: null
    }
  }
};

// TODO: Create normalizr schemas
const userSchema = new schema.Entity('users');
const likeSchema = new schema.Entity('likes');
const commentSchema = new schema.Entity('comments', {
  author: userSchema,
  likes: [likeSchema]
});
const postSchema = new schema.Entity('posts', {
  author: userSchema,
  comments: [commentSchema],
  likes: [likeSchema]
});

// TODO: Create Redux slice with normalized state
const socialSlice = createSlice({
  name: 'social',
  initialState,
  reducers: {
    // Add your reducers here
  }
});

// TODO: Create selectors for denormalized data
export const selectFeedPosts = createSelector(
  // Implement selector
);

export const selectPostWithDetails = createSelector(
  // Implement selector
);

// TODO: Create component that uses normalized data
function FeedPost({ postId }) {
  const post = useSelector(state => selectPostWithDetails(state, postId));
  // Implement component
}
            

Additional Resources