normalizr, but tiny, typed, and framework-agnostic — flatten nested API responses into an entity store, resolve references, and denormalize back for rendering.
Server responses arrive as deeply nested trees. Caches want flat, id-keyed tables. cache-normalize is the small, zero-dependency layer between the two: define your entities once, then normalize any payload into { result, entities }, deep-merge partial updates from different endpoints into a shared store, and denormalize back into the nested object graph your components render. It works the same whether you drive React Query, SWR, Apollo, RTK Query, or a plain object.
Deep-dive guides on the patterns this tool implements live at frontendcache.com.
A GET /posts/10 gives you this:
{
"id": 10,
"title": "Hello",
"author": { "id": 1, "name": "Ada" },
"comments": [
{ "id": 100, "text": "nice", "author": { "id": 2, "name": "Alan" } },
{ "id": 101, "text": "great", "author": { "id": 1, "name": "Ada" } }
]
}Store that tree as-is and you inherit three bugs:
- Duplication. Ada appears twice in this one response, and again in every other response that mentions her. Update her name in one place and the others go stale.
- No single source of truth. "Which cached copy of user 1 is correct?" has no answer when the same entity is embedded under five different query keys.
- Awkward merges.
GET /posts/10returns a rich author object;GET /users/1returns a fuller one with an email. Overwrite either with the other and you lose fields.
Normalization fixes this by lifting every entity into a flat, id-keyed table and replacing nested objects with their ids — so each entity is stored exactly once and every view references it. cache-normalize does that lifting (and the reverse) from a schema you declare, and nothing else.
Not published to any registry — clone and build it:
git clone https://github.com/frontendcache/cache-normalize.git
cd cache-normalize
npm install
npm run buildThen import from the built output:
import { entity, normalize, denormalize, createStore } from "./dist/index.js";Or vendor src/ directly into your project — it's a handful of dependency-free .ts files.
Define your entities, wiring up relationships. Wrap a schema in an array ([comment]) for a to-many link; a bare schema is to-one.
import { entity, normalize, denormalize } from "./dist/index.js";
const user = entity("users");
const comment = entity("comments", { relationships: { author: user } });
const post = entity("posts", {
relationships: { author: user, comments: [comment] },
});Normalize the nested response from above:
const { result, entities } = normalize(response, post);
// result === "10"
// entities === {
// posts: { "10": { id: 10, title: "Hello", author: "1", comments: ["100", "101"] } },
// users: { "1": { id: 1, name: "Ada" }, "2": { id: 2, name: "Alan" } },
// comments: {
// "100": { id: 100, text: "nice", author: "2" },
// "101": { id: 101, text: "great", author: "1" },
// },
// }Every entity is stored once; nested objects became ids. Ada ("1") is referenced by the post and by a comment, but exists in exactly one place.
Rebuild the tree whenever you need to render it:
const rebuilt = denormalize(result, post, entities);
// deep-equal to the original response — author objects and comment authors
// fully re-inflated, with shared entities returned as the same instance.For an app you want a store that accumulates entities across many requests and hands back denormalized views:
import { entity, createStore } from "./dist/index.js";
const user = entity("users");
const post = entity("posts", { relationships: { author: user } });
const store = createStore(post);
// Merge a list endpoint...
store.merge([
{ id: 10, title: "Hello", author: { id: 1, name: "Ada" } },
{ id: 11, title: "World", author: { id: 2, name: "Alan" } },
], [post]);
// ...then a detail endpoint that knows more about Ada. The extra field
// accumulates onto the existing entity instead of replacing it.
store.merge({ id: 1, name: "Ada", email: "ada@example.com" }, user);
store.getEntity("users", 1); // { id: 1, name: "Ada", email: "ada@example.com" }
store.select("posts", 10); // { id: 10, title: "Hello", author: { id: 1, name: "Ada", email: ... } }
store.query("posts", (p) => p.author.name === "Ada"); // denormalized matchesBecause merges are deep, partial entities from different endpoints stack up into one complete record — the entity-mapping strategy that keeps a single source of truth as your data arrives piecemeal.
Define an entity schema.
name— the entity type; also the key it occupies in the store.options.idAttribute— a field name (default"id") or a function(obj) => string | number. Ids are coerced to strings, so42and"42"address the same slot.options.relationships— a map of field → related schema.schemais to-one;[schema]is to-many. May also be a thunk() => ({...})for circular graphs.options.mergeStrategy—(existing, incoming) => merged, overriding the default deep merge for this entity.
Returns an EntitySchema. Use schema.define({...}) to attach relationships after construction (the escape hatch for two schemas that reference each other).
Flatten data into entities ({ [type]: { [id]: entity } }), replacing nested entities with their id (or array of ids). result mirrors the top level of data — an id for a single-entity schema, an array of ids for an array schema ([schema]). Safe on circular input via object-identity tracking.
The inverse of normalize. Rebuilds the nested object graph. Within a single call, shared entities resolve to the same object instance, so cycles close instead of looping, and referential equality holds. Dangling references (ids not in entities) come back as undefined.
A mutable entity store. schema may be a single schema, an array schema, a list of schemas, or a { name: schema } map; the store indexes every schema reachable through relationships.
store.merge(data, schema?)— normalize and deep-merge into the store; returns theresult. Schema is optional when the store has a single root.store.getEntity(name, id)— the raw normalized record (ids not resolved), orundefined.store.select(name, id)— denormalize a single entity.store.selectAll(name)— denormalize every entity of a type.store.query(name, predicate)— denormalized entities matchingpredicate.store.remove(name, id)— delete an entity; returns whether it existed.store.getState()— the live normalizedentitiesmap.
The lower-level primitive createStore is built on: normalize data and merge it into an existing entities map in place, returning the result. Reach for it when you manage the entity map yourself.
normalizr pioneered this pattern and is excellent, but it's a larger surface with its own schema.Entity / schema.Array / schema.Object / schema.Union class hierarchy, and denormalization historically needed a separate memoization wrapper. cache-normalize collapses that to one entity() factory and a single array wrapper for arity, ships as a few dependency-free files you can vendor, and makes denormalization circular-safe by default.
Redux Toolkit's createEntityAdapter gives you { ids, entities } for one slice, but it doesn't walk relationships — nested entities are your problem. cache-normalize is about the graph: it flattens across entity types and stitches references back together. The two compose fine; you can feed a normalized table straight into an adapter.
Apollo / RTK Query normalize internally, but only for data that flows through their client. If you're on React Query, SWR, or fetch, you have no normalization layer at all — this is that layer, and nothing more. It has no opinion about how you fetch.
The design leans on a few principles worth reading up on: keep entities flat and referenced rather than embedded (reference vs value storage), and treat denormalization as a cheap, on-demand read rather than something you persist.
Circular-safe by construction. Normalization keys a visited-set by object identity, so a User → posts → Post → author → User cycle in the input resolves without infinite recursion. Denormalization caches each name:id instance before resolving its relationships, so the cycle closes back onto the in-progress object. This is the crux of handling circular references in cache — get it wrong and you either stack-overflow or duplicate entities.
Deep merge, not overwrite. store.merge accumulates: a partial entity that omits a field leaves the previously known field intact, and nested objects merge recursively. Arrays and primitives are replaced by the newest value. Override per entity with mergeStrategy when you need union semantics (e.g. appending paginated id lists).
Ids are strings. Every id is coerced with String() on the way in, so mixed numeric/string ids from different endpoints never fragment an entity across two slots.
Deep-dive guides on the normalization and cache patterns this tool implements:
- Data normalization & query key design — the pillar overview.
- Normalizing entities with normalizr — the schema-driven approach this library follows.
- Flattening deeply nested GraphQL responses — nested-data flattening techniques in practice.
- Denormalizing cache data for rendering — the reverse trip, done cheaply.
- How to design a normalized state tree — normalization principles for UI state.
- Resolving one-to-many relationships in cache — relationship stitching, the to-many case.
npm install
npm test # node:test under tsx
npm run typecheck
npm run buildThe core is four small modules — schema.ts, normalize.ts, denormalize.ts, store.ts. If you hit a normalization edge case that isn't covered by the tests, open an issue with the shape of the payload.
MIT