{post.title}
By {post.author.name}
{post.commentCount} comments
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.
Think of state normalization like a library catalog system:
Just as libraries use catalog systems to avoid duplicating information, normalized state uses IDs to reference data stored in one place.
// 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
}))
}))
};
}
// 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
}
}
}
};
}
// 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" }
}
}
};
// 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
// 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: {...} }
]
};
// 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
}
}
};
// 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" }
}
}
}
*/
// 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 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;
}
// 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}
))}
);
}
Build a normalized state structure for a social media feed with:
// 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
}