State Normalization
Structure store state for collections to simplify lookups, selectors, and updates.
State Normalization
Stores managing collections — users, products, messages — choose between arrays and maps. The wrong choice means O(n) lookups on every render and update logic that is hard to keep correct. Normalizing fixes both, at the cost of two fields instead of one.
Declare collections readonly
Whichever shape you pick, declare it readonly. ctx.state is DeepReadonly<S>, so a mutable Product[] comes back as readonly Product[] and is no longer assignable to S — spreading ctx.state and keeping that field will not compile:
type Bad = { products: Product[] };
type Good = { readonly products: readonly Product[] };
type BadMap = { byId: Record<string, Product>; ids: string[] };
type GoodMap = {
readonly byId: Readonly<Record<string, Product>>;
readonly ids: readonly string[];
};Declare the entity type readonly too, or a map read out of state cannot be written back:
type Product = {
readonly id: string;
readonly name: string;
readonly price: number;
readonly stock: number;
};Arrays vs. maps
type ProductState = {
readonly products: readonly Product[];
};
// Lookup: O(n) every time
const product = state.products.find((candidate) => candidate.id === id);
// Update: rebuild the whole array
ctx.setState((prev) => ({
products: prev.products.map((candidate) =>
candidate.id === id ? { ...candidate, stock: candidate.stock - 1 } : candidate,
),
}));type ProductState = {
readonly byId: Readonly<Record<string, Product>>;
readonly ids: readonly string[];
};
// Lookup: O(1)
const product = state.byId[id];
// Update: replace one entry
ctx.setState((prev) => ({
...prev,
byId: { ...prev.byId, [id]: { ...prev.byId[id], stock: prev.byId[id].stock - 1 } },
}));Arrays are simpler to initialize and iterate. Maps are faster for lookups and targeted updates, and they let a component subscribe to one entity rather than to the whole collection. The tradeoff matters as the collection grows.
Normalized shape
An entity map keyed by id, plus an ordered array of ids:
type ProductState = {
readonly byId: Readonly<Record<string, Product>>;
readonly ids: readonly string[];
};
export const initialState: ProductState = { byId: {}, ids: [] };ids preserves order and drives list rendering. byId gives instant lookup for detail views and updates.
Command handlers
Adding and removing touch both fields:
import { createCommandDef } from "@naikidev/commiq";
export const ProductCommand = {
add: createCommandDef<Product>("product:add"),
remove: createCommandDef<{ id: string }>("product:remove"),
updateStock: createCommandDef<{ id: string; stock: number }>("product:updateStock"),
};
_store
.addCommandHandler(ProductCommand.add, (ctx, cmd) => {
const product = cmd.data;
ctx.setState((prev) => ({
byId: { ...prev.byId, [product.id]: product },
ids: prev.ids.includes(product.id) ? prev.ids : [...prev.ids, product.id],
}));
})
.addCommandHandler(ProductCommand.remove, (ctx, cmd) => {
ctx.setState((prev) => {
const { [cmd.data.id]: removed, ...byId } = prev.byId;
return { byId, ids: prev.ids.filter((id) => id !== cmd.data.id) };
});
})
.addCommandHandler(ProductCommand.updateStock, (ctx, cmd) => {
const product = ctx.state.byId[cmd.data.id];
if (!product) return;
ctx.setState((prev) => ({
...prev,
byId: { ...prev.byId, [cmd.data.id]: { ...product, stock: cmd.data.stock } },
}));
});The updater form is worth preferring in normalized handlers. Two fields must stay consistent, and computing both from the same prev makes it impossible to read one before an earlier setState and the other after.
Keeping byId and ids in step is the cost of normalizing. An add that forgets ids produces an entity that is unreachable through the list; a remove that forgets byId leaks. Do both writes in one setState, as above — never in two, which would also publish two stateChanged events for one logical change.
Guarding add against a duplicate id is not cosmetic: byId would overwrite silently while ids gained a second entry, so the item would render twice.
Selectors
import { useSelector, shallowEqual } from "@naikidev/commiq-react";
import type { DeepReadonly } from "@naikidev/commiq";
import { productStore } from "./store";
import type { ProductState } from "./store";
// Single entity — re-renders only when this product changes
export function useProduct(id: string) {
return useSelector(productStore, (state: DeepReadonly<ProductState>) => state.byId[id]);
}
// Count — re-renders only when the number changes
export function useProductCount() {
return useSelector(productStore, (state: DeepReadonly<ProductState>) => state.ids.length);
}useProduct is the payoff. Because byId[id] is a stable reference until that entity is replaced, a row component re-renders only when its own product changes — with a plain array, every row re-renders whenever any product does.
For the ordered list, the selector has to build a new array:
function selectOrderedProducts(state: DeepReadonly<ProductState>) {
return state.ids.map((id) => state.byId[id]);
}
export function useProducts() {
return useSelector(productStore, selectOrderedProducts, shallowEqual);
}Object- and array-returning selectors are safe: useSelector memoizes the snapshot by state identity, so the selector is not re-run on every render and the returned reference is stable while state is unchanged.
Pass shallowEqual anyway. The default comparison is Object.is, so without it a new array is produced on every stateChanged — including changes to unrelated fields — and the component re-renders even though the visible list is identical. shallowEqual compares element-wise and keeps the previous reference when nothing moved.
shallowEqual is one level deep. It compares the product references in the array, which is exactly right here because entities are replaced rather than mutated. It would not detect a change inside an entity if you mutated one in place — which the store's dev-mode freezing prevents anyway.
Keeping state flat
Deep nesting makes immutable updates verbose and selectors brittle. Prefer adjacent fields:
// Avoid
type State = {
readonly order: {
readonly customer: { readonly address: { readonly city: string } };
};
};
// Prefer
type State = {
readonly orderId: string;
readonly customerName: string;
readonly shippingCity: string;
};Updating city in the first shape means rebuilding three objects and publishing one stateChanged whose prev/next differ at four levels — hard to read in devtools. The second is one field.
When entities reference other entities, store the id:
type Order = {
readonly id: string;
readonly customerId: string; // reference, not an embedded Customer
readonly itemIds: readonly string[];
};This avoids stale copies when the referenced entity is updated elsewhere. The cost is that rendering an order with its customer needs two lookups, and if the customer lives in a different store, a component reading both re-renders on either one's changes.
When arrays are fine
| Condition | Why arrays work |
|---|---|
| Under ~50 items | O(n) lookups are negligible |
| Items are never accessed individually by id | No random access needed |
| Append-only (logs, history) | No updates or deletions to keep in sync |
| Insertion order is the only ordering | An ids array would duplicate the array itself |
| Every render shows the whole list | Per-entity subscriptions buy nothing |
Start with an array. Normalize when you see rows re-rendering unnecessarily or when update logic gets unwieldy — not before. Normalizing also loses something: state.products is directly inspectable in devtools, while byId + ids requires cross-referencing.