Normalized Entity CacheLoading saved progress…

Normalized Entity Cache

A normalized entity cache stores records in a flat table keyed by type and then by id, so every record lives in exactly one place and relationships are held as id references instead of nested copies. APIs love to hand you deeply nested JSON — a post with its author embedded, and that same author copied into ten other posts — which means one edit leaves ten stale duplicates behind. Flattening the data into per-type tables, the way Redux, Apollo, and RTK Query do under the hood, gives every record a single source of truth.

Build a factory normalizedEntityCache() that returns five methods over a flat store shaped like { [type]: { [id]: entity } }. Every write is immutable: it returns a new store object and a new bucket for the type it touches, while records you did not change keep their exact reference.

Signature

type Store = Record<string, Record<string, object>>;

function normalizedEntityCache(): {
  setEntity(type: string, entity: { id: string | number }): Store;
  getEntity(type: string, id: string | number): object | undefined;
  updateEntity(type: string, id: string | number, patch: object): Store;
  getAll(type: string): object[];
  normalize(type: string, items: object[], relations: Record<string, string>): Store;
};

Examples

const cache = normalizedEntityCache();

cache.setEntity('users', { id: 1, name: 'Ada' });
cache.getEntity('users', 1); // { id: 1, name: 'Ada' }

cache.updateEntity('users', 1, { name: 'Ada L.' });
cache.getEntity('users', 1); // { id: 1, name: 'Ada L.' }
cache.getAll('users'); // [{ id: 1, name: 'Ada L.' }]
const post = { id: 10, title: 'Hello', author: { id: 1, name: 'Ada' } };

// Pull the nested author into its own bucket, leaving an id behind.
cache.normalize('posts', [post], { author: 'users' });

cache.getEntity('posts', 10); // { id: 10, title: 'Hello', author: 1 }
cache.getEntity('users', 1); // { id: 1, name: 'Ada' }

Notes

  • Flat by type then id — the store is { [type]: { [id]: entity } }; every entity carries an id.
  • Immutable writessetEntity and updateEntity return a new store and a new bucket for the type they touch; entities you did not change keep their reference.
  • Shallow mergeupdateEntity spreads patch over the existing entity; a missing id is a no-op that returns the same store.
  • Relations become ids — for each field in relations mapped to a related type, normalize moves the nested object into that type's bucket and leaves only its id on the parent.
  • Do not worry about — relations nested more than one level deep, array-valued relations, deletion, or freezing; single-level relations and shallow merges are enough here.

FAQ

What does it mean to normalize data in a cache?
Normalizing means storing each entity exactly once in a flat table keyed by its id, and replacing every nested copy of it with a reference to that id. It is the same shape Redux, Apollo, and RTK Query use so a record has a single source of truth.
Why return a new store on each write instead of mutating it?
Consumers like React.memo, Redux selectors, and useMemo decide whether something changed by comparing references. A new store object per write lets them detect the change, while entities you did not touch keep their old reference so unrelated views can skip re-rendering.
How is this different from deep-cloning the store on every write?
A deep clone gives every entity a new reference, so consumers re-render everything and you lose the identity of the nested objects you extracted. A normalized cache copies only the store, the one bucket, and the one entity you touched, sharing the rest by reference.
Loading editor…