Commiq Docs
Usage Patterns

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:

stores/product.ts
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:

stores/product.ts
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

features/product/hooks.ts
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

ConditionWhy arrays work
Under ~50 itemsO(n) lookups are negligible
Items are never accessed individually by idNo random access needed
Append-only (logs, history)No updates or deletions to keep in sync
Insertion order is the only orderingAn ids array would duplicate the array itself
Every render shows the whole listPer-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.

On this page