npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pinia-colada-plugin-normalizer

v0.1.7

Published

Normalized entity caching plugin for Pinia Colada

Readme

pinia-colada-plugin-normalizer

npm version npm downloads license

npm · GitHub

Normalized entity caching plugin for Pinia Colada. Apollo-style normalization with zero configuration and Vue-native performance.

Store each entity once. Update it in one place, every query sees the change. No more stale data from missed cache invalidations.

  • Transparent — uses Vue's customRef to intercept reads/writes. Your app code doesn't know normalization exists.
  • Minimal — ~2,400 LOC, zero runtime dependencies. Just Vue + Pinia Colada.
  • Type-safe — optional EntityRegistry for end-to-end typed entity access across the entire API.
  • Extensible — swappable EntityStore interface for custom backends (IndexedDB, SQLite+WASM).

Installation

pnpm add pinia-colada-plugin-normalizer

Requires @pinia/colada >= 1.0.0, pinia >= 2.1.0, vue >= 3.3.0.

Quick Start

import { PiniaColada } from "@pinia/colada";
import { PiniaColadaNormalizer, defineEntity } from "pinia-colada-plugin-normalizer";

app.use(PiniaColada, {
  plugins: [
    PiniaColadaNormalizer({
      entities: {
        contact: defineEntity<Contact>({ idField: "contactId" }),
        order: defineEntity<Order>({ idField: "orderId" }),
      },
    }),
  ],
});

Opt in per query:

const { data } = useQuery({
  key: ["contacts"],
  query: () => fetchContacts(),
  normalize: true,
});

Type Safety

Augment the EntityRegistry interface for end-to-end typed access:

declare module "pinia-colada-plugin-normalizer" {
  interface EntityRegistry {
    contact: Contact;
    order: Order;
  }
}

// Now fully typed everywhere:
entityStore.get("contact", "1"); // ShallowRef<Contact | undefined>
entityStore.set("contact", "1", data); // data must match Contact
useEntityQuery("contact", (c) => c.name); // c is Contact
onEntityAdded("contact", (e) => e.data); // data is Contact | undefined

Without the registry, everything defaults to EntityRecord — fully backwards compatible.

The Problem

Pinia Colada stores data per query key. When the same entity appears in multiple queries, it lives as independent copies that can diverge:

const { data: contacts } = useQuery({ key: ["contacts"], query: fetchContacts });
const { data: contact } = useQuery({ key: ["contacts", 5], query: () => fetchContact(5) });

// A mutation updates contact 5's name.
// Only one cache entry gets the update. The other is stale.

With normalization, contact 5 is stored once. Both queries read from the same entity. One write, all views update.

Entity Store Writes

Write directly to the entity store — no invalidation needed:

import { useEntityStore } from "pinia-colada-plugin-normalizer";

const entityStore = useEntityStore();

// From a WebSocket event:
ws.on("CONTACT_UPDATED", (data) => {
  entityStore.set("contact", data.contactId, data);
});

// From a mutation response:
const { mutate } = useMutation({
  mutation: (data) => api.updateContact(data),
  onSuccess: (response) => {
    entityStore.set("contact", response.contactId, response);
    // All queries referencing this contact update instantly. No refetch.
  },
});

Optimistic Updates

Transaction-based with rollback. Handles concurrent mutations correctly:

import { useOptimisticUpdate } from "pinia-colada-plugin-normalizer";

const { apply, transaction } = useOptimisticUpdate();

// Simple (single mutation):
const { mutate } = useMutation({
  mutation: (data) => api.updateContact(data),
  onMutate: (data) => apply("contact", data.contactId, data),
  onError: (_err, _vars, rollback) => rollback?.(),
});

// Multi-mutation transaction:
const tx = transaction();
tx.set("contact", "1", { name: "Alicia" });
tx.set("order", "5", { status: "confirmed" });
// On success: tx.commit()
// On failure: tx.rollback() — restores server truth, replays other active transactions

Cache Redirects (Zero-Spinner Navigation)

Navigate from a list to a detail page with zero loading spinner — if the entity was already fetched by a list query, the detail page shows it instantly while the full data loads in the background.

Automatic (convention-based)

PiniaColadaNormalizer({
  entities: { contact: defineEntity({ idField: "contactId" }) },
  autoRedirect: true, // ← one flag
});

// Any query with key ['contact', id] auto-serves from cache:
const { data, isPlaceholderData } = useQuery({
  key: ["contact", id],
  query: () => fetchContact(id),
  normalize: true,
});
// data is available INSTANTLY if contact was in a prior list query.
// isPlaceholderData is true until the real fetch completes.

The convention: if a query key is [registeredEntityType, id] (exactly 2 segments, first matches an entity in your config), the plugin auto-injects placeholderData from the entity store. List queries (1 segment) and nested resources (3+ segments) are skipped.

Per-query overrides:

// Disable for a specific query:
useQuery({ key: ["contact", id], ..., redirect: false });

// Custom mapping for non-standard keys:
useQuery({ key: ["dashboard-contact", id], ..., redirect: { entityType: "contact" } });

Manual (composable)

For full control, use useCachedEntity directly:

import { useCachedEntity } from "pinia-colada-plugin-normalizer";

const { data } = useQuery({
  key: ["contact", id],
  query: () => fetchContact(id),
  placeholderData: useCachedEntity("contact", () => id),
});

Array Operations

Add or remove entities from list queries without refetching:

import { updateQueryData, deleteEntity } from "pinia-colada-plugin-normalizer";

// Add to a specific list query:
entityStore.set("contact", "99", newContact);
updateQueryData(["contacts"], (data) => [...(data as any[]), newContact]);

// Remove from ALL queries + entity store (one call):
deleteEntity("contact", "42");

Real-Time Hooks

Fine-grained entity lifecycle events:

import { onEntityAdded, onEntityUpdated, onEntityRemoved } from "pinia-colada-plugin-normalizer";

onEntityAdded("contact", (event) => toast.success(`${event.data.name} joined!`));
onEntityUpdated("contact", (event) => console.log("Updated:", event.id));
onEntityRemoved("contact", (event) => toast.info(`${event.previousData?.name} left`));

Entity Queries & Indexes

Filtered reactive views and O(1) field lookups:

import { useEntityQuery, createEntityIndex } from "pinia-colada-plugin-normalizer";

// Filtered view (reactive, updates automatically)
const activeContacts = useEntityQuery("contact", (c) => c.status === "active");

// Index for O(1) lookups by field value
const statusIndex = createEntityIndex("contact", "status");
const active = statusIndex.get("active"); // ComputedRef<Contact[]>

Coalescing

Batch multiple notifications into a single fetch:

import { createCoalescer } from "pinia-colada-plugin-normalizer";

const coalescer = createCoalescer(async (entityKeys) => {
  const entities = await api.fetchEntitiesByIds(entityKeys);
  for (const entity of entities) {
    entityStore.set("contact", entity.id, entity);
  }
}, 100); // 100ms batching window

ws.on("ENTITY_STALE", ({ key }) => coalescer.add(key));

API Reference

PiniaColadaNormalizer(options?)

Creates the plugin.

| Option | Type | Default | Description | | ---------------- | ---------------------------------- | --------- | ---------------------------------------------- | | entities | Record<string, EntityDefinition> | {} | Entity type configurations | | defaultIdField | string | 'id' | Default ID field for auto-detection | | store | EntityStore | in-memory | Custom storage backend | | autoNormalize | boolean | false | Normalize all queries by default | | autoRedirect | boolean | false | Auto-serve cached entities as placeholder data |

defineEntity<T>(config)

Configure an entity type. The generic T provides type safety for callbacks.

| Option | Type | Default | Description | | --------- | --------------------------------- | ------------- | ----------------------------------------- | | idField | string & keyof T | 'id' | Field containing the entity ID | | getId | (entity: T) => string \| null | — | Custom ID extraction (for composite keys) | | merge | (existing: T, incoming: T) => T | shallow merge | Custom merge strategy |

useEntityStore(pinia?)

Access the entity store. Returns typed results when EntityRegistry is augmented.

invalidateEntity(entityType, id, pinia?)

Refetch all active queries referencing the given entity.

updateQueryData(key, updater, pinia?)

Update a query's data directly. Updater receives denormalized data, result is re-normalized.

deleteEntity(entityType, id, pinia?)

Remove an entity from all normalized queries and the entity store.

useCachedEntity(entityType, id, pinia?)

Returns a placeholderData-compatible function that serves cached entities instantly. See Cache Redirects.

useOptimisticUpdate(pinia?)

Returns { apply, transaction } for optimistic updates with rollback.

createCoalescer<T>(onFlush, delay?)

Batch items and flush after a delay. Framework-agnostic.

Entity Store Interface

| Method | Description | | ---------------------------------------- | --------------------------------------------------------- | | set(type, id, data) | Shallow-merge entity | | replace(type, id, data) | Full replacement (no merge) | | setMany(entities) | Batch write | | remove(type, id) | Remove entity | | get(type, id) | Reactive ref (typed when registry used) | | getByType(type) | Reactive computed array (typed when registry used) | | getEntriesByType(type) | Non-reactive snapshot of {id, data} pairs | | has(type, id) | Check existence | | subscribe(listener, filter?) | Entity change events (typed when registry used) | | retain(type, id) / release(type, id) | Reference counting for GC | | gc() | Collect unreferenced entities | | toJSON() / hydrate(snapshot) | Serialization / SSR hydration (handles nested EntityRefs) | | clear() | Remove all entities |

How It Works

Uses Vue's customRef to transparently intercept reads and writes on entry.state:

  1. On write: When Pinia Colada sets query state, the customRef setter extracts entities, stores them in the entity store, and saves references internally
  2. On read: When components access query data, the customRef getter replaces references with live reactive entity data
  3. On entity change: entityStore.set() or remove() writes directly — Vue reactivity propagates to all queries referencing that entity. Entities that arrive late (out-of-order) or are removed and re-added trigger re-renders automatically.

Follows the delay plugin's pattern of replacing entry properties with customRef during the extend hook. SSR-safe via defineStore scoping.

License

MIT