Reactive, recursively-indexable, sortable and memoizable maps — all in <1.5 KB.
Written in TypeScript with full type definitions. Side-effects free.
The correct way to memoize indexed and ordered keyed-collections.
Most web apps don’t need collection memoization. The DOM is almost always the real bottleneck for performance.
That said, when you are processing huge amounts of data (e.g. a realtime dashboard or a fully-offline app),memotablegives you the correct memoizable primitive.
When writing React code, most developers reach for useMemo to cache filtered or sorted collections - but that pattern is a subtle trap.
function TaskList({ tasks, filter, comparator }) {
// ❌ Looks efficient, but isn't.
const filtered = useMemo(() => tasks.filter(filter), [tasks, filter]);
const sorted = useMemo(() => filtered.sort(comparator), [filtered, comparator]);
...
}This has two fundamental problems:
- If you mutate the array in place,
useMemocan silently return stale data because the reference didn’t change. - If you recreate the array on every render, you pay full recomputation cost every time — so your “optimization” does nothing.
Most of the time, you don’t need to “memoize” collections at all — just recompute them and move on. But when you do need to avoid recomputation — say, thousands of values with heavy indexing/comparator logic — you need a structure that’s actually designed for that.
That’s what memotable is.
It provides:
- Indexing — Index collections as deeply as needed.
- Sorting — Sort at the root or any child node - applies recursively from any node to its children.
- Subscriptions — Subscribe only to the specific partition you are interested in, ignoring other changes.
💡 You can think of memotable as a utility that lets you shape your data into a render-ready form, and then keeps that shape up to date automatically and efficiently as edits come in. It also enables you to memoize specific partitions that are frequently read for optimal performance.
Benefits:
- Lighter render passes – Heavy operations like sorting and indexing are applied outside the render loop.
- Less re-renders – A table partition notifies subscribers only when it sees any change.
Sample todo app with filtering and sorting, setup using vanilla JS (write friendly)-
// Simple map holding all todo's
const todos = new Map<string, ITodo>();
// Generic function to get todo's that match any filter criteria
function getTodos(group: string): ITodo[] {
return Array.from(todos.values())
.filter(
(todo) =>
(group === "Important" ? todo.isImportant : todo.listId === group) && // Matches group filter
todo.title.includes(KEYWORD), // Matches keyword filter
)
.sort(
(a, b) =>
Number(b.isImportant) - Number(a.isImportant) ||
a.createdDate.getTime() - b.createdDate.getTime(),
);
}
// Reading specific groups
getTodos("list1"); // Get todo's in "list1"
getTodos("Important"); // Get important todo's
// Update a todo
todo.set("1", { title: "Updated title" });Identical app setup using memotable (read friendly)-
// Table of todos
const todos = new Table<string, ITodo>();
// Register partition index
todos.index(
(todo) => [todo.listId, todo.isImportant ? "Important" : null], // Specify which all top-level partitions a todo belongs to
(p) => {
p.index(
(todo) => todo.title.includes(KEYWORD), // The default partition within each top-level partition matches applied keyword
(p) => p.memo(), // Memo the filtered partition for fast reads
);
p.sort(
(a, b) =>
Number(b.isImportant) - Number(a.isImportant) ||
a.createdDate.getTime() - b.createdDate.getTime(),
);
},
);
// Reading specific partitions
todos.partition("list1").partition(); // Get sorted & filtered todo's in "list1"
todos.partition("Important").partition(); // Get sorted & filtered important todo's
// Update a todo (identical to vanilla)
todo.set("1", { title: "Updated title" });It's important to understand the semantics of memotable so that you can come up with the most optimal setup for your scenario. Here are the core semantics-
- A
Tablecan be recursively split into derived readonly copies or subsets of itself usingindexmethod. - Edits (
set/delete/touch) can only be applied to the root node and the propagate to all derived nodes. sort/memocan be independently applied to any node and they propagate to all derived nodes.
Simple indexing and sorting in a React component
const table = new Table<string, Task>();
// ✅ Comparator applied and maintained incrementally
table.sort((task1, task2) => task1.title.localeCompare(task2.title));
// ✅ Index + memo enables fast per list reads
table.index(
(task) => task.listId,
(list) => list.memo(),
);
// ✅ Generic React component that renders a table of tasks
function TaskList({ table }) {
useTable(table); // ✅ Subscription that is only notified when this table gets updated
return (
<div>
{Array.from(table, ([id, task]) => (
<Task key={id} task={task} />
))}
</div>
);
}
// Render lists
<TaskList taskTable={taskTable.partition("list1")} />;
<TaskList taskTable={taskTable.partition("list2")} />;
// Update task table
taskTable.set("1", { listId: "list1", title: "Task" }); // only re-renders "list1" nodeComplex nested index, sorting and conditional memoization
type Location = {
id: string;
country: string;
region: string;
city: string;
district: string;
population: number;
};
table = new Table<string, Location>();
// Define complex multi-level hierarchical partitioning
table.index(
() => ["nested", "byCountry", "byCity"], // 3 top level partitions
(p, name) => {
switch (name) {
case "nested":
p.index(
// Nested level 1: Index by country
(l) => l.country,
(country) => {
// Nested level 2: Within each country, index by region
country.index(
(l) => l.region,
(region) => {
// Nested level 3: Within each region, index by city
region.index(
(l) => l.city,
(city) => {
// Sort each city partition by population
city.sort((a, b) => b.population - a.population);
},
);
},
);
},
);
break;
case "byCountry":
p.index(
(l) => l.country,
(country, name) => {
// Sort each country partition by population
country.sort((a, b) => b.population - a.population);
// IMPORTANT: Memoize only (large + frequently read) partitions
if (name === "India" || name === "USA") {
country.memo();
}
},
);
break;
case "byCity":
p.index(
(l) => l.city,
(city) => {
// Sort each city partition by name
city.sort((a, b) => a.city.localeCompare(b.city));
},
);
break;
}
},
);npm install memotable
# or
pnpm add memotable
# or
yarn add memotableCheck out the React Todo App example — a complete interactive demo showing indexing, partition-specific sorting, and reactive updates.
Run it locally:
git clone https://github.com/shudv/memotable.git
cd memotable
pnpm install
pnpm demoYou don't need it for simple apps.
✅ Use it when:
- Your data set is large enough that filtering/sorting frequently can cause visible frame drops (~10ms+). (typically heavy realtime dashboards OR fully-offline apps)
- Reads outnumber writes by at least 2-3x.
🚫 Avoid it when:
- Your data set is small enough that plain
.filter()/.sort()in a render pass is super fast (say <1ms) OR the number of render passes itself are naturally low enough. - The complexity of maintaining derived views correctly outweighs the performance gain.
- Your data set is so huge that even a single sort/filter pass is noticeably janky (memotable reduces sort/filter passes but does not eliminate them entirely). At that point, consider using a web worker for heavy computation or re-design your app to not require heavy data processing on the client.
It's not a full state management system like MobX or Zustand. Instead, it's a data structure primitive — designed to integrate with those systems or stand alone for efficient in-memory computation.
Memotable is optimized for read-heavy workloads. The tradeoff: slower writes, faster reads.
Scenario: 50 lists with 1000 tasks per list with list-based indexing, importance filtering, and two-factor sorting (importance + timestamp). Simulates a typical task management app with 800 reads and 200 writes.
| Operation | vanilla | memotable | Difference |
|---|---|---|---|
| Initial load | 3.0ms | 35.0ms | |
| 200 edits | 0.0ms | 30.3ms | |
| 800 reads | 244.7ms | 3.7ms | |
| Total | 247.8ms | 68.9ms | 3.6x faster |
Run pnpm benchmark to test on your machine.
Memotable is designed to integrate seamlessly with existing tools:
import { useTable } from "memotable/react";
function MyComponent({ table }) {
useTable(table); // Auto-subscribes, triggers re-render on change and cleans up on unmount
return <div>{table.size()} values</div>;
}Coming soon
The main Table class provides all the functionality for managing indexed, sorted, and memoized collections.
new Table<K, V>();Creates a new table with key type K and value type V.
set(key: K, value: V): this- Add or update a valueget(key: K): V | undefined- Get a value by keyhas(key: K): boolean- Check if a key existsdelete(key: K): boolean- Remove a value by keyclear(): void- Remove all values and reset indexing/sortingsize: number- Get the number of values in the table
keys(): MapIterator<K>- Iterate over keys (respects sorting if enabled)values(): MapIterator<V>- Iterate over values (respects sorting if enabled)entries(): MapIterator<[K, V]>- Iterate over key-value pairsforEach<T>(callbackfn, thisArg?): void- Execute a function for each entry
index(definition: (value: V) => string | string[] | null, partitionInitializer?: (name: string, partition: IReadonlyTable<K, V>) => void): void- Create partitions based on a definitionindex(null): void- Remove indexingindex(): void- Re-index based on existing definition (no-op if no definition provided before)partition(name: string): IReadonlyTable<K, V>- Get a specific partitionpartition(): IReadonlyTable<K, V>- Get the default partitionpartitions(): string[]- Get all partition names (includes empty partitions)
sort(comparator: (a: V, b: V) => number): void- Set a comparator functionsort(null): void- Remove sortingsort(): void- Re-sort based on existing comparator (no-op if no comparator applied before)
memo(flag?: boolean): void- Enable or disable memoization (default: true)
subscribe(subscriber: (keys: K[]) => void): () => void- Subscribe to changes. Returns an unsubscribe function.
batch(fn: (t: TBatchable<K, V>) => void): void- Group multiple operations into a single update
touch(key: K): void- Mark a value as changed without replacing it (useful when the value is mutated in place OR indexing/sorting logic changes in a way that affects a key)
useTable(table: IReadonlyTable<K, V>): void- React hook that subscribes to table changes and triggers re-renders
MIT
Next steps:
- 📖 Read the full example
- 🚀 Try it live
- 💬 Open an issue or contribute on GitHub