Skip to content

Reactive, memoizable, deeply-indexable, read-optimized data structure for keyed collections - all in <1 KB.

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, deeply-indexable, sortable and memoizable maps — all in <1 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 additional code complexity is not worth it. 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 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 data structure)-

// 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 data structure)-

// 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) => {
        // Create a sub-partition to contain filtered items
        p.index((todo) => (todo.title.includes(KEYWORD) ? "Filtered" : null));
        p.sort(
            (a, b) =>
                Number(b.isImportant) - Number(a.isImportant) ||
                a.createdDate.getTime() - b.createdDate.getTime(),
        );
    },
);

// Reading specific partitions
todos.partition("list1").partition("Filtered"); // Get sorted & filtered todo's in "list1"
todos.partition("Important").partition("Filtered"); // Get sorted & filtered important todo's

// Update a todo (identical to vanilla)
todo.set("1", { title: "Updated title" });

// Whenever keyword updates for a visible partition OR when a new partition becomes visible-
partition.index(); // Ensures latest keyword filter is applied
partition.memo(); // Ensure that the results are memoized for fast reads

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>();
table.sort((task1, task2) => task1.title.localeCompare(task2.title)); // ✅ Comparator applied at root
table.index((task) => task.listId); // ✅ List index creates a partition for every listId

// ✅ Generic React component that renders a task list
function TaskList({ listId }) {
    useTable(table.partition(listId)); // ✅ 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 (✅ mounted/visible list gets memoized automatically enabling efficient render passes)
<TaskList listId={"list1"} />;
<TaskList listId={"list2"} />;

// Update task table
taskTable.set("1", { listId: "list1", title: "Task" }); // only re-renders "list1" node

Live Demo

Check out this example — a demo showing how heavy data processing apps can achieve better interactivity using memotable.

Run it locally:

git clone https://github.com/shudv/memotable.git
cd memotable
pnpm install
pnpm demo

Demo GIF

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 with a lot of user data) AND
  • 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, memoizes the data, 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

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, memoizable, deeply-indexable, read-optimized data structure for keyed collections - all in <1 KB.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •