If you have ever wired up Zustand for state, dropped in localStorage for persistence, hand-rolled a few derived selectors, and then promised yourself you would "add sync later," TinyBase is the library that quietly does all of that for you. It calls itself "a reactive data store and sync engine," and the description is refreshingly honest. You get a store that feels like a miniature relational database — tables, rows, cells, schemas, indexes, relationships, and a real query language — wrapped in granular reactive bindings for React, Solid, and Svelte. Then, when you are ready, you flip on persistence to a dozen different backends and CRDT-based synchronization across devices, all without leaving the API you already learned.
The remarkable part is the size. The core store is around 6 kB gzipped, the whole library lands near 14 kB, and there are exactly zero runtime dependencies. TinyBase is built for local-first apps: software where data lives on the user's device, keeps working offline, and optionally syncs when a connection appears. It is the kind of tool that makes you reconsider whether you needed a backend ORM at all.
Why TinyBase Earns Its Name
TinyBase sits in an unusual sweet spot. It is more capable than a simple key-value store but far lighter than a full document database. A few things make it stand out:
- Two data models in one store. You get a simple key-value bag for settings-style data and a full tabular model (Tables to Rows to Cells) for relational data, living side by side.
- Granular reactivity. Listeners fire only when the exact value, cell, row, or table they watch changes, which means UI components re-render with surgical precision instead of on every mutation.
- A reactive query layer. Queries, metrics, indexes, and relationships are first-class objects that stay in sync with the store automatically. Query results are themselves reactive tables.
- Pluggable persistence. Save and load to Local Storage, IndexedDB, OPFS, SQLite, PostgreSQL, Turso, Yjs, Automerge, and more — often with automatic two-way syncing.
- Built-in CRDT sync. A
MergeableStoreis a drop-in replacement for the regular store that merges concurrent edits from multiple devices without conflicts. - TypeScript-first. Optional schemas can generate fully typed APIs, and schematizer modules integrate with Zod, Valibot, TypeBox, ArkType, Yup, and Effect Schema.
You adopt only the pieces you need. Every capability lives behind its own ESM entrypoint, so a tree-shaking bundler keeps your app lean.
Getting It Into Your Project
Installation is a single package with no extra peers to chase down.
npm install tinybase
yarn add tinybase
If you prefer to start from a working template, the project also ships a scaffolding command:
npm create tinybase@latest
From there you import only the entrypoints you touch — tinybase for the core, tinybase/ui-react for hooks, tinybase/persisters/persister-indexed-db for storage, and so on.
Shaping Your First Store
A store can hold both plain values and tabular data at the same time. Values are great for app-wide settings, while tables model collections of records.
import { createStore } from 'tinybase';
const store = createStore()
// Key-value data: simple settings-style storage.
.setValues({ employees: 3 })
.setValue('open', true)
// Tabular data: Tables -> Rows -> Cells.
.setTable('pets', {
fido: { species: 'dog', sold: false },
})
.setCell('pets', 'fido', 'color', 'brown');
console.log(store.getValue('open')); // true
console.log(store.getRow('pets', 'fido')); // { species: 'dog', sold: false, color: 'brown' }
Values can be strings, numbers, or booleans. Tables nest rows by id, and each row is a flat bag of cells. There is no schema required to get started — you can sketch out a data model in minutes and tighten it later.
Listening for Exactly What Changed
TinyBase's reactivity is what makes everything else efficient. You register a listener on a specific scope, and it only runs when that scope changes.
const listenerId = store.addTableListener('pets', () => {
console.log('The pets table changed');
});
store.setCell('pets', 'fido', 'sold', true); // logs the message
store.delListener(listenerId);
Listeners exist at every level — values, tables, rows, and individual cells — and they support wildcards, so you can react to "any row in this table" or "any cell in this row." This granularity is the foundation the UI bindings are built on.
Adding a Schema for Safety
When you want guarantees about structure and defaults, attach a schema. Cells gain types and optional defaults, and missing values fill in automatically.
store.setTablesSchema({
pets: {
species: { type: 'string' },
sold: { type: 'boolean', default: false },
},
});
store.setRow('pets', 'rex', { species: 'dog' });
console.log(store.getCell('pets', 'rex', 'sold')); // false (from the default)
For full type safety, the with-schemas entrypoint generates a typed store API from your schema, and the schematizer modules let you reuse an existing Zod or Valibot schema instead of writing one twice.
Binding It to React
The tinybase/ui-react package provides hooks that subscribe a component to precisely the data it reads. When that data changes, only that component re-renders.
import { useCell, useValue } from 'tinybase/ui-react';
const PetColor = () => {
const color = useCell('pets', 'fido', 'color', store);
const isOpen = useValue('open', store);
return (
<p>
Fido is {color}. We are {isOpen ? 'open' : 'closed'}.
</p>
);
};
There is a hook for every shape of data: useStore, useTable, useRow, useCell, useValue, useValues, plus hooks for queries, metrics, indexes, relationships, checkpoints, and persisters. Wrapping your tree in the Provider component supplies the store through context, so you can drop the explicit store argument from every hook.
import { Provider, useCell } from 'tinybase/ui-react';
const App = () => (
<Provider store={store}>
<PetColor />
</Provider>
);
If you want to inspect or scaffold UI quickly, tinybase/ui-react-dom ships ready-made components — sortable table viewers, editable cells and rows — and a floating <Inspector /> that lets you browse the live store contents during development.
Querying Like It's a Database
Here is where TinyBase stops feeling small. The Queries object lets you define SQL-adjacent queries with select, join, group, and aggregation — and the results are reactive tables you can read or listen to like any other.
import { createQueries } from 'tinybase';
const queries = createQueries(store);
queries.setQueryDefinition('avgPrices', 'pets', ({ select, join, group }) => {
select('species');
select('owners', 'state');
join('owners', 'ownerId');
group('price', 'avg').as('avgPrice');
});
console.log(queries.getResultTable('avgPrices'));
Because the query result is itself a reactive table, you can feed it straight into a useResultTable hook and watch your UI update as the underlying rows change. Alongside queries you get Metrics for running aggregations (count, sum, average, min, max, or custom), Indexes for fast lookups exposed as reactive slices, and Relationships for navigating between linked rows across tables. Each is a separate object layered on the store, kept current for you.
Undo, Redo, and Time Travel
The Checkpoints object records the store's history so you can move backward and forward through changes — undo and redo with no extra bookkeeping on your side.
import { createCheckpoints } from 'tinybase';
const checkpoints = createCheckpoints(store);
store.setCell('pets', 'fido', 'sold', true);
checkpoints.addCheckpoint('sold Fido');
checkpoints.goBackward(); // Fido is no longer sold
checkpoints.goForward(); // Fido is sold again
Persisting and Syncing Across Devices
Persisters connect a store to a storage backend and can keep the two automatically in sync. The browser persister is a one-liner.
import { createSessionPersister } from 'tinybase/persisters/persister-browser';
const persister = createSessionPersister(store, 'my-app');
await persister.save(); // store -> storage
await persister.load(); // storage -> store
await persister.startAutoSave(); // keep storage updated as the store changes
await persister.startAutoLoad(); // keep the store updated as storage changes
The list of supported targets is long: Local Storage, Session Storage, OPFS, IndexedDB, several SQLite drivers (including expo-sqlite for React Native), PostgreSQL via postgres or PGlite, Turso/libSQL, Cloudflare Durable Objects, and CRDT libraries like Yjs and Automerge. SQLite and Postgres persisters even support a tabular mode that maps TinyBase tables onto real database tables and columns, so you can share a schema with an existing relational backend instead of storing an opaque JSON blob.
Conflict-Free Sync With MergeableStore
For multi-device and collaborative apps, swap createStore for createMergeableStore. It behaves identically but tracks the metadata needed for CRDT merging: concurrent edits to different cells of the same row both survive, and edits to the same cell resolve deterministically by logical timestamp. Because it is a drop-in replacement, you can adopt sync incrementally without rewriting your data layer.
import { createMergeableStore } from 'tinybase';
import { createWsSynchronizer } from 'tinybase/synchronizers/synchronizer-ws-client';
const store = createMergeableStore();
const synchronizer = await createWsSynchronizer(
store,
new WebSocket('ws://localhost:8040'),
);
await synchronizer.startSync();
Synchronizers move changes between mergeable stores over a transport. Besides WebSocket — which pairs with a Node, Bun, or Cloudflare Durable Object server — there is a BroadcastChannel synchronizer for sharing state between tabs in the same browser, plus local and custom transports. The same store that persists offline can sync online the moment a connection appears.
Where TinyBase Fits
It helps to know when TinyBase is the right tool. Compared to Dexie.js, a thin IndexedDB wrapper, TinyBase adds queries, reactive bindings, and sync that Dexie leaves to you. Compared to RxDB or WatermelonDB, full local-first databases, TinyBase is dramatically smaller and leans toward "reactive store with optional sync" rather than a complete document or SQLite engine. And compared to raw Yjs or Automerge, which give you CRDT primitives but no indexing, querying, or persistence, TinyBase builds the merge logic into MergeableStore while handing you a full query and relationship layer on top — it can even use Yjs or Automerge as a persister.
The positioning is "tiny but surprisingly complete." If you want a queryable, reactive, persistable, optionally-syncable store without committing to a heavy database engine or a backend, TinyBase is hard to beat. It is also genuinely well-built: the project advertises 100% test coverage with thousands of tests, ships frequent releases, and tests every code example in its documentation.
Wrapping Up
TinyBase manages to be small and ambitious at the same time. You can start with a plain in-memory store on Monday, add a schema and a few queries on Tuesday, persist to IndexedDB on Wednesday, and turn on cross-device CRDT sync on Thursday — all on the same API, all without growing your bundle in any meaningful way. For local-first apps, multi-tab experiences, React Native projects on SQLite, or just replacing an ad-hoc store-plus-localStorage setup with something queryable and reactive, it is one of the most quietly impressive libraries in the JavaScript ecosystem. Tiny by name, tiny by nature — and a lot bigger on the inside than it looks.