The reactive data store for local-first apps.
npm install @level.up/tinybase-cjsModern apps deserve better. Why trade reactive user experiences to be able to use relational data? Why sacrifice store features for bundle size? And why should the cloud do all the work anyway? TinyBase is a smart new way to structure your local app data: Tiny by name, tiny by nature, TinyBase only costs 4.2kB - 8.7kB when compressed, and has zero dependencies. And of course it's well tested, fully documented, and open source. Other FAQs?The reactive data store for local-first apps.
Creating a Store requires just a simple call to the createStore function. Once you have one, you can easily set Values in it by unique Id. And of course you can easily get them back out again.
Read more about using keyed value data in The Basics guide.
``js
const store = createStore()
.setValues({employees: 3})
.setValue('open', true);
console.log(store.getValues());
// -> {employees: 3, open: true}
`
For other types of data applications, a tabular data structure is more useful. TinyBase lets you set and get nested Read more about setting and changing data in The Basics guide.Level up to use tabular data.
Table, Row, or Cell data, by unique Id and in the same Store as the keyed values.
`js
store
.setTable('pets', {fido: {species: 'dog'}})
.setCell('pets', 'fido', 'color', 'brown');
console.log(store.getRow('pets', 'fido'));
// -> {species: 'dog', color: 'brown'}
`
The magic starts to happen when you register listeners on a Read more about listeners in the Listening To Stores guide.Register listeners at any granularity.
Value, Table, Row, or Cell. They get called when any part of that object changes. You can also use wildcards - useful when you don't know the Id of the objects that might change.
`js
const listenerId = store.addTableListener('pets', () =>
console.log('changed'),
);
store.setCell('pets', 'fido', 'sold', false);
// -> 'changed'
store.delListener(listenerId);
`
If you're using React in your application, the optional More magic! The Basically you simply describe what data you want in your user interface and TinyBase will take care of the whole lifecycle of updating it for you. Read more about the using hooks in the Using React Hooks guide.Call hooks to bind to data.
ui-react module provides hooks to bind to the data in a Store.useCell hook in this example fetches the dog's color. But it also registers a listener on that cell that will fire and re-render the component whenever the value changes.
`jsx
const App1 = () => {
const color = useCell('pets', 'fido', 'color', store);
return <>Color: {color}>;
};
const app = document.createElement('div');
const root = ReactDOMClient.createRoot(app);
root.render(
console.log(app.innerHTML);
// -> 'Color: brown'
store.setCell('pets', 'fido', 'color', 'walnut');
console.log(app.innerHTML);
// -> 'Color: walnut'
`
The react module provides simple React components with bindings that make it easy to create a fully reactive user interface based on a In this example, the library's The module also includes a context Provider that sets up default for an entire app to use, reducing the need to drill all your props down into your app's hierarchy. Most of the demos showcase the use of these React hooks and components. Take a look at Todo App v1 (the basics) to see these user interface binding patterns in action. Read more about the Use components for reactive apps.
Store.RowView component just needs a reference to the Store, the tableId, and the rowId in order to render the contents of that row. An optional cellComponent prop lets you override how you want each Cell rendered. Again, all the listeners and updates are taken care of for you.ui-react module in the Building UIs guides.
`jsx
const MyCellView = (props) => (
<>
{props.cellId}:
>
);
const App2 = () => (
tableId="pets"
rowId="fido"
cellComponent={MyCellView}
/>
);
root.render(
console.log(app.innerHTML);
// -> 'species: dog
store.setCell('pets', 'fido', 'sold', true);
console.log(app.innerHTML);
// -> 'species: dog
root.unmount();
`
By default, a In this example, we set a second Read more about schemas in the Using Schemas guide.Apply schemas to tables and values.
Store can contain any arbitrary Value, and a Row can contain any arbitrary Cell. But you can add a ValuesSchema or a TablesSchema to a Store to ensure that the values are always what you expect: constraining their types, and providing defaults.Row without the sold Cell in it. The schema ensures it's present with default of false.
`js
store.setTablesSchema({
pets: {
species: {type: 'string'},
color: {type: 'string'},
sold: {type: 'boolean', default: false},
},
});
store.setRow('pets', 'felix', {species: 'cat'});
console.log(store.getRow('pets', 'felix'));
// -> {species: 'cat', sold: false}
store.delTablesSchema();
`
You can easily persist a Read more about persisters in the Persisting Data guide.Persist data to browser, file, or server.
Store between browser page reloads or sessions. You can also synchronize it with a web endpoint, or (if you're using TinyBase in an appropriate environment), load and save it to a file.
`js
const persister = createSessionPersister(store, 'demo');
await persister.save();
console.log(sessionStorage.getItem('demo'));
// -> '[{"pets":{"fido":{"species":"dog","color":"walnut","sold":true},"felix":{"species":"cat","sold":false}}},{"employees":3,"open":true}]'
persister.destroy();
sessionStorage.clear();
`
The Accessors and listeners let you sort and paginate the results efficiently, making building rich tabular interfaces easier than ever. In this example, we have two tables: of pets and their owners. They are joined together by the pet's ownerId We access the results by descending price, essentially answering the question: "which is the highest-priced species, and in which state?" Needless to say, the results are reactive too! You can add listeners to queries just as easily as you do to raw tables. Read more about Build complex queries with TinyQL.
Queries object lets you query data across tables, with filtering and aggregation - using a SQL-adjacent syntax called TinyQL.Cell. We select the pet's species, and the owner's state, and then aggregate the prices for the combinations.Queries in the v2.0 Release Notes, the Making Queries guide, and the Car Analysis demo and Movie Database demo.
`js
store
.setTable('pets', {
fido: {species: 'dog', ownerId: '1', price: 5},
rex: {species: 'dog', ownerId: '2', price: 4},
felix: {species: 'cat', ownerId: '2', price: 3},
cujo: {species: 'dog', ownerId: '3', price: 4},
})
.setTable('owners', {
1: {name: 'Alice', state: 'CA'},
2: {name: 'Bob', state: 'CA'},
3: {name: 'Carol', state: 'WA'},
});
const queries = createQueries(store);
queries.setQueryDefinition(
'prices',
'pets',
({select, join, group}) => {
select('species');
select('owners', 'state');
select('price');
join('owners', 'ownerId');
group('price', 'avg').as('avgPrice');
},
);
queries
.getResultSortedRowIds('prices', 'avgPrice', true)
.forEach((rowId) => {
console.log(queries.getResultRow('prices', rowId));
});
// -> {species: 'dog', state: 'CA', avgPrice: 4.5}
// -> {species: 'dog', state: 'WA', avgPrice: 4}
// -> {species: 'cat', state: 'CA', avgPrice: 3}
queries.destroy();
`
A In this example, we create a new table of the pet species, and keep a track of which is most expensive. When we add horses to our pet store, the listener detects that the highest price has changed. Read more about Define metrics and aggregations.
Metrics object makes it easy to keep a running aggregation of Cell values in each Row of a Table. This is useful for counting rows, but also supports averages, ranges of values, or arbitrary aggregations.Metrics in the Using Metrics guide.
`js
store.setTable('species', {
dog: {price: 5},
cat: {price: 4},
worm: {price: 1},
});
const metrics = createMetrics(store);
metrics.setMetricDefinition(
'highestPrice', // metricId
'species', // tableId to aggregate
'max', // aggregation
'price', // cellId to aggregate
);
console.log(metrics.getMetric('highestPrice'));
// -> 5
metrics.addMetricListener('highestPrice', () =>
console.log(metrics.getMetric('highestPrice')),
);
store.setCell('species', 'horse', 'price', 20);
// -> 20
metrics.destroy();
`
An In this example, we create an index on the Read more about Create indexes for fast lookups.
Indexes object makes it easy to look up all the Row objects that have a certain value in a Cell.species Cell values. We can then get the the list of distinct Cell value present for that index (known as 'slices'), and the set of Row objects that match each value.Indexes objects are reactive too. So you can set listeners on them just as you do for the data in the underlying Store.Indexes in the Using Indexes guide.
`js
const indexes = createIndexes(store);
indexes.setIndexDefinition(
'bySpecies', // indexId
'pets', // tableId to index
'species', // cellId to index
);
console.log(indexes.getSliceIds('bySpecies'));
// -> ['dog', 'cat']
console.log(indexes.getSliceRowIds('bySpecies', 'dog'));
// -> ['fido', 'rex', 'cujo']
indexes.addSliceIdsListener('bySpecies', () =>
console.log(indexes.getSliceIds('bySpecies')),
);
store.setRow('pets', 'lowly', {species: 'worm'});
// -> ['dog', 'cat', 'worm']
indexes.destroy();
`
A In this example, the Like everything else, you can set listeners on Read more about Model relationships between tables.
Relationships object lets you associate a Row in a local Table with the Id of a Row in a remote Table. You can also reference a table to itself to create linked lists.species Cell of the pets Table is used to create a relationship to the species Table, so that we can access the price of a given pet.Relationships too.Relationships in the Using Relationships guide.
`js
const relationships = createRelationships(store);
relationships.setRelationshipDefinition(
'petSpecies', // relationshipId
'pets', // local tableId to link from
'species', // remote tableId to link to
'species', // cellId containing remote key
);
console.log(
store.getCell(
relationships.getRemoteTableId('petSpecies'),
relationships.getRemoteRowId('petSpecies', 'fido'),
'price',
),
);
// -> 5
relationships.destroy();
`
A In this example, we set a checkpoint, then sell one of the pets. Later, the pet is brought back to the shop, and we go back to that checkpoint to revert the store to its previous state. Read more about Set checkpoints for an undo stack.
Checkpoints object lets you set checkpoints on a Store. Move forward and backward through them to create undo and redo functions.Checkpoints in the Using Checkpoints guide.
`js
const checkpoints = createCheckpoints(store);
store.setCell('pets', 'felix', 'sold', false);
checkpoints.addCheckpoint('pre-sale');
store.setCell('pets', 'felix', 'sold', true);
console.log(store.getCell('pets', 'felix', 'sold'));
// -> true
checkpoints.goBackward();
console.log(store.getCell('pets', 'felix', 'sold'));
// -> false
`
You can easily create TypeScript Read more about TinyBase's tools and CLI in the Developer Tools guide.Generate ORM-like APIs
.d.ts definitions that model your data and encourage type-safety when reading and writing data - as well as .ts implementations that provide ORM-like methods for your named tables.
`js yolo
const tools = createTools(store);
const [dTs, ts] = tools.getStoreApi('shop');
// -- shop.d.ts --
/ Represents the 'pets' Table. /
export type PetsTable = {[rowId: Id]: PetsRow};
/ Represents a Row when getting the content of the 'pets' Table. /
export type PetsRow = {species: string / ... /};
//...
// -- shop.ts --
export const createShop: typeof createShopDecl = () => {
//...
};
``
If you use the basic The Read more about how TinyBase is structured in the Architecture guide.Did we say tiny?
store module alone, you'll only add a gzipped 4.2kB to your app. You can incrementally add the other modules as you need more functionality, or get it all for 8.7kB.ui-react module is just another 3.4kB, the tools module is 5.4kB, and everything is fast. Life's easy when you have zero dependencies!
| .js.gz | .js | debug.js | .d.ts | |
|---|---|---|---|---|
| store | 4.2kB | 10.0kB | 43.9kB | 184.6kB |
| metrics | 1.8kB | 3.6kB | 14.8kB | 29.1kB |
| indexes | 1.9kB | 3.7kB | 16.6kB | 33.9kB |
| relationships | 1.8kB | 3.6kB | 16.8kB | 42.1kB |
| queries | 2.6kB | 5.5kB | 24.9kB | 106.8kB |
| checkpoints | 1.5kB | 3.0kB | 12.5kB | 33.4kB |
| persisters | 0.8kB | 1.7kB | 5.2kB | 27.2kB |
| common | 0.1kB | 0.1kB | 0.1kB | 3.5kB |
| tinybase (all) | 8.7kB | 21.1kB | 92.8kB | 0.3kB |
TinyBase has 100.0% test coverage, including the code throughout the documentation - even on this page! The guides, demos, and API examples are designed to make it as easy as possible to get up and running.
Read more about how TinyBase is tested in the Unit Testing guide.
| Total | Tested | Coverage | |
|---|---|---|---|
| Lines | 1,794 | 1,794 | 100.0% |
| Statements | 1,932 | 1,932 | 100.0% |
| Functions | 768 | 768 | 100.0% |
| Branches | 628 | 628 | 100.0% |
| Tests | 2,581 | ||
| Assertions | 12,421 | ||
Building TinyBase was originally an interesting exercise for me in API design, minification, and documentation. It could not have been built without these great projects and friends, and I hope you enjoy using it!