Skip to content

Reactive, recursively-indexable, sortable and memoizable maps — all in <1.5 KB. Zero dependencies.

License

Notifications You must be signed in to change notification settings

shudv/memotable

Repository files navigation

memotable

npm version Bundle size CI License: MIT Try it live

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), memotable gives you the correct memoizable primitive.

Why memotable?

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, useMemo can 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.

Comparison with vanilla implementation

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" });

Semantics

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-

  1. A Table can be recursively split into derived readonly copies or subsets of itself using index method.
  2. Edits (set / delete / touch) can only be applied to the root node and the propagate to all derived nodes.
  3. sort / memo can be independently applied to any node and they propagate to all derived nodes.

Using memotable

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" node

Complex 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;
        }
    },
);

Quick Start

npm install memotable
# or
pnpm add memotable
# or
yarn add memotable

Live Demo

Check 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 demo

When should you use memotable?

You 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.

When not to use memotable

🚫 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.

What memotable is not

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.

Benchmarks

Memotable is optimized for read-heavy workloads. The tradeoff: slower writes, faster reads.

Real-world benchmark

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.

Integrations

Memotable is designed to integrate seamlessly with existing tools:

React

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>;
}

Vue / Svelte (WIP)

Coming soon

API Reference

Table

The main Table class provides all the functionality for managing indexed, sorted, and memoized collections.

Constructor

new Table<K, V>();

Creates a new table with key type K and value type V.

Basic Operations

  • set(key: K, value: V): this - Add or update a value
  • get(key: K): V | undefined - Get a value by key
  • has(key: K): boolean - Check if a key exists
  • delete(key: K): boolean - Remove a value by key
  • clear(): void - Remove all values and reset indexing/sorting
  • size: number - Get the number of values in the table

Iteration

  • 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 pairs
  • forEach<T>(callbackfn, thisArg?): void - Execute a function for each entry

Indexing

  • index(definition: (value: V) => string | string[] | null, partitionInitializer?: (name: string, partition: IReadonlyTable<K, V>) => void): void - Create partitions based on a definition
  • index(null): void - Remove indexing
  • index(): void - Re-index based on existing definition (no-op if no definition provided before)
  • partition(name: string): IReadonlyTable<K, V> - Get a specific partition
  • partition(): IReadonlyTable<K, V> - Get the default partition
  • partitions(): string[] - Get all partition names (includes empty partitions)

Sorting

  • sort(comparator: (a: V, b: V) => number): void - Set a comparator function
  • sort(null): void - Remove sorting
  • sort(): void - Re-sort based on existing comparator (no-op if no comparator applied before)

Memoization

  • memo(flag?: boolean): void - Enable or disable memoization (default: true)

Subscriptions

  • subscribe(subscriber: (keys: K[]) => void): () => void - Subscribe to changes. Returns an unsubscribe function.

Batching

  • batch(fn: (t: TBatchable<K, V>) => void): void - Group multiple operations into a single update

Advanced

  • 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)

React Integration

  • useTable(table: IReadonlyTable<K, V>): void - React hook that subscribes to table changes and triggers re-renders

License

MIT

Next steps:

About

Reactive, recursively-indexable, sortable and memoizable maps — all in <1.5 KB. Zero dependencies.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •