Support Ukraine Now πŸ‡ΊπŸ‡¦

Basic likes feature with LiveStore

December 29, 2025
4 min read

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:

  1. Offline-first architecture - something fascinating to tackle
  2. 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:

  1. Create LikeAdded event
  2. Define basic DB table for counter and create a schema
  3. 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:

schema.ts
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:

No events yet

Tables

We can define likes table with counter, there will be only 1 row. LiveStore offers a simple ORM for this:

schema.ts
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.

schema.ts
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.

schema.ts
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.

reactive-like.tsx
<LikeButton
  onClick={() => store.commit(
    events.likeAdded({})
  )}
/>
likes table
id
count
"counter"
0
No events yet

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:

reactive-like.tsx
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!