Before we start
Modern applications increasingly use sync engines for instant, offline-capable experiences. This βlocal-firstβ approach, pioneered by tools like Linear and Notion, eliminates loading spinners and provides server-like responsiveness. Ink & Switch describes this paradigm perfectly.
The appeal of seamless synchronization with simple APIs drew me in. With growing ecosystem options, you can compare approaches here.
LiveStore stood out for two key features that excited me personally:
- Offline-first architecture - something fascinating to tackle
- Event sourcing pattern - something I never tried before
Short intro to LiveStore
At its core, LiveStore combines event sourcing with a reactive SQLite database. User actions commit events that get stored in a local eventlog and immediately turned into queryable state in SQLite. The magic happens in sync: LiveStore pushes and pulls events between clients and a central sync backend, but the materialized data stays local - each client rebuilds its own SQLite state from the shared event history.
βββββββββββββββ βββββββββββββββ β Eventlog βββββΆβ SQLite DB β β β β (reactive β β (events) β β state) β βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ β Client A β β Client B β β β β β β Eventlog βββΌβββββββββββΌββΊ Eventlog β β SQLite DB β β SQLite DB β βββββββββββββββ βββββββββββββββ β² β² β β βββββββββββ¬βββββββββββββββ βΌ βββββββββββββββ β Sync Backendβ β (Cloudflare,β β ElectricSQL,β β etc.) β βββββββββββββββ
The scope over which data is synced is defined by a storeId. You can create multiple stores within one application for different domains like documents, workspaces, etc. Granularity may vary.
Multiple sync providers are supported, including Cloudflare, ElectricSQL, and S2, plus custom providers. The system uses βlast-write-winsβ conflict resolution by default.
It runs on Web, React Native, Node.js, Electron, and more via platform adapters.
Schemas define your events and database structure. Events describe what happened, materializers turn them into SQLite tables.
This should be enough to get basic understanding of concepts.
Building
Likes feature has a set of very minimal requirements:
- anybody can like an article
- likes are infinite
- no auth layer, hence no ability to remove the like
This converts to a set of steps we need to do:
- Create
LikeAddedevent - Define basic DB table for counter and create a schema
- Decide granularity of the store
Events
Simplicity of the feature allows us to keep event empty, as 1 event translates to 1 like, there is no other payload:
const events = { likeAdded: Events.synced({ name: "v1.LikeAdded", schema: Schema.Struct({}), }), };
Events are committed by users interactions - a like click. Here is a simplified version of event log without data querying, click it:
Tables
We can define likes table with counter, there will be only 1 row. LiveStore offers a simple ORM for this:
const tables = { likes: State.SQLite.table({ name: "likes", columns: { id: State.SQLite.text({ primaryKey: true }), count: State.SQLite.integer({ default: 0 }), }, }), };
Materializer & Schema
With existing events and tables we now can materialize committed events into queryable data. Each event has a materializer and allows us to query and mutate the DB.
const materializers = State.SQLite.materializers(events, { [events.likeAdded.name]: defineMaterializer( events.likeAdded, (_, ctx) => { const likes = ctx.query(tables.likes.first()); if (!likes) { return tables.likes.insert({ id: "counter", count: 1 }); } return tables.likes.update({ id: "counter", count: likes.count + 1 }); }), });
Combination of all parts would allow us to create a schema.
const state = State.SQLite.makeState({ tables, materializers }); export const schema = makeSchema({ events, state });
Commit
Every time user clicks on a button and event is committed - itβs being materialized to the DB.
<LikeButton onClick={() => store.commit( events.likeAdded({}) )} />
Display
We now have everything necessary to create a reactive query and actually display likes counter in a button. Letβs create a simple query for it and execute it on a store:
const DEFAULT_VALUE = { id: "counter", count: 0, }; export const likesCount$ = queryDb( tables.likes.where({ id: "counter" }).first({ behaviour: "fallback", fallback: () => DEFAULT_VALUE, }), { label: "likesCount", }, ); // .... const likesData = store.useQuery(likesCount$); <LikeButton onClick={() => store.commit( events.likeAdded({}) )} count={likesData.count} />
And just like that - we have a very simple likes feature.
This tutorial walked through building a likes feature with LiveStore, from basic event logging to a fully reactive, offline-capable system. The final example above is actually live - your likes are stored persistently and will sync across devices!