Skip to content

Introduction

LiveStore is an event-driven data layer with a built-in sync engine. Its prime use case is building complex, client-side apps like Linear, Figma or Notion that also work offline.

Think of LiveStore as a next-generation state management library (like Zustand or Redux) that also persists and distributes state:

Reactive state

Syncing


  • Replaces Redux, Zustand, ...

  • High-performance


  • Works offline


  • Multi-player

  • Real-time


Persistence

Reactive state

Syncing


  • Replaces Redux, Zustand, ...

  • High-performance


  • Works offline


  • Multi-player

  • Real-time


Persistence

The combination of persisted and distributed state is a giant leap for creating an amazing user experience (UX), while also providing a best-in-class developer experience (DX). The immutable eventlog enables robust testing and tight feedback loops, making it perfect for agentic coding.

UX

  • Synced: Real-time updates across devices
  • Fast: No async loading over the network
  • Persistent: Works offline, survives page refresh

DX

  • Principled: Event-driven instead of mutable state
  • Composable & Type-safe: Fully typed events & queries
  • Reactive: Automatic UI updates when data changes

AX

  • Testable: Immutable eventlog for feedback loop
  • Debuggable: Same events always produce same state
  • Evolvable: Reset & fork state for experiments

LiveStore works cross-platform and can be used for building UI apps (web, mobile, desktop, …), agents, and any other software like CLIs, scripts or server-to-server applications.

See this talk for more info: Sync different: Event sourcing in local-first apps.

Are you an expert? See here for advanced topics.
  • How LiveStore deals with merge conflicts?
  • Why event-sourcing instead of CRDTs or query-driven sync?
  • How do schema migrations work?
  • What are limitations of LiveStore?
  • How LiveStore is perfect for coding agents.

The core idea: Synced events -> State -> UI

Section titled “The core idea: Synced events -> State -> UI”

Unlike other sync solutions, LiveStore syncs events—not state!

Events are immutable facts that describe what happened (“TodoCreated”, “TodoCompleted”), while state is derived by replaying them. This means every client reconstructs the same state from the same event history, making sync predictable and debuggable.

The state then changes trigger reaactive UI updates.

Traditional state management uses ephemeral, in-memory state

Section titled “Traditional state management uses ephemeral, in-memory state”

Traditional state management works like this: you dispatch actions that update an in-memory store, and your UI reacts to changes. But that state vanishes when the user refreshes or closes the browser. Add persistence and you need to manage local storage which essentially serves as a secondary database to the data you’ve stored in the cloud. Add sync and you’re dealing with conflict resolution, offline queues, and backend integration. LiveStore handles all this for you with one simple, event-driven API!

LiveStore persists events which materialize into state

Section titled “LiveStore persists events which materialize into state”

LiveStore handles all of this through one unified pattern: event sourcing.

User actionEventEventlogSQLite stateUISync to other clients

Instead of mutating state directly, you commit events that describe what happened. These events are persisted to an eventlog (like a git history) and automatically materialized into a local SQLite database that your UI queries reactively.

If you want to learn more, you can dive deeper into how LiveStore works.

Comparison with traditional state management like Redux

Section titled “Comparison with traditional state management like Redux”

If you’ve used Redux, this pattern of “comitting events” will feel familiar: Events are like actions, materializers are like reducers, and the SQLite state is like your store.

But there are key differences:

ReduxLiveStore
Actions dispatch → reducers update in-memory stateEvents commit → materializers update SQLite
State lost on refreshEvents persisted locally
Sync requires external setupSync built-in via eventlog
Fixed state shapeQuery any shape with SQL

Let’s walk through a simple example of a todo list with LiveStore and React.

At the core of every app built with LiveStore, you have a schema which consists of three parts:

  • Events: describe what can happen in your app
  • State: defines how data is stored in your app
  • Materializers: determines how events are mapped to state in your app

Here’s an example:

// schema.ts
import {
import Events
Events
,
const makeSchema: <TInputSchema extends InputSchema>(inputSchema: TInputSchema) => FromInputSchema.DeriveSchema<TInputSchema>
makeSchema
,
import Schema
Schema
,
import State
State
} from '@livestore/livestore'
// 1. Define events (the things that can happen in your app)
export const
const events: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
}
events
= {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>
todoCreated
:
import Events
Events
.
synced<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>(args: {
name: "v1.TodoCreated";
schema: Schema.Codec<Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">, never, never>;
} & Omit<...>): State.SQLite.EventDef<...>
export synced

Creates a synced event definition.

Synced events are sent to the sync backend and distributed to all connected clients. Use this for collaborative data that should be shared across users and devices.

Event names should be versioned (e.g., v1.TodoCreated) to support schema evolution over time.

@example

import { Events } from '@livestore/livestore'
import { Schema } from 'effect'
const todoCreated = Events.synced({
name: 'v1.TodoCreated',
schema: Schema.Struct({
id: Schema.String,
text: Schema.String,
completed: Schema.Boolean,
}),
})
// Commit the event
store.commit(todoCreated({ id: 'abc', text: 'Buy milk', completed: false }))

synced
({
name: "v1.TodoCreated"
name
: 'v1.TodoCreated',
schema: Schema.Codec<Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">, never, never>
schema
:
import Schema
Schema
.
function Struct<{
readonly id: Schema.String;
readonly text: Schema.String;
}>(fields: {
readonly id: Schema.String;
readonly text: Schema.String;
}): Schema.Struct<{
readonly id: Schema.String;
readonly text: Schema.String;
}>

Defines a struct schema from a map of field schemas.

Details

Each field value is a schema. Use

optionalKey

or

optional

to mark fields as optional, and

mutableKey

to mark them as mutable.

The resulting schema's Type is a readonly object type with the fields' decoded types. The Encoded form mirrors the field schemas' encoded types.

Example (Defining a basic struct)

import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number,
email: Schema.optionalKey(Schema.String)
})
// { readonly name: string; readonly age: number; readonly email?: string }
type Person = typeof Person.Type
const alice = Schema.decodeUnknownSync(Person)({ name: "Alice", age: 30 })
console.log(alice)
// { name: 'Alice', age: 30 }

@since3.10.0

Struct
({
id: Schema.String
id
:
import Schema
Schema
.
const String: Schema.String

Type-level representation of

String

.

Schema for string values. Validates that the input is typeof "string".

@since4.0.0

@since4.0.0

String
,
text: Schema.String
text
:
import Schema
Schema
.
const String: Schema.String

Type-level representation of

String

.

Schema for string values. Validates that the input is typeof "string".

@since4.0.0

@since4.0.0

String
,
}),
}),
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>
todoCompleted
:
import Events
Events
.
synced<"v1.TodoCompleted", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>(args: {
name: "v1.TodoCompleted";
schema: Schema.Codec<Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">, never, never>;
} & Omit<State.SQLite.DefineEventOptions<Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, false>, "derived" | "clientOnly">): State.SQLite.EventDef<...>
export synced

Creates a synced event definition.

Synced events are sent to the sync backend and distributed to all connected clients. Use this for collaborative data that should be shared across users and devices.

Event names should be versioned (e.g., v1.TodoCreated) to support schema evolution over time.

@example

import { Events } from '@livestore/livestore'
import { Schema } from 'effect'
const todoCreated = Events.synced({
name: 'v1.TodoCreated',
schema: Schema.Struct({
id: Schema.String,
text: Schema.String,
completed: Schema.Boolean,
}),
})
// Commit the event
store.commit(todoCreated({ id: 'abc', text: 'Buy milk', completed: false }))

synced
({
name: "v1.TodoCompleted"
name
: 'v1.TodoCompleted',
schema: Schema.Codec<Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">, never, never>
schema
:
import Schema
Schema
.
function Struct<{
readonly id: Schema.String;
}>(fields: {
readonly id: Schema.String;
}): Schema.Struct<{
readonly id: Schema.String;
}>

Defines a struct schema from a map of field schemas.

Details

Each field value is a schema. Use

optionalKey

or

optional

to mark fields as optional, and

mutableKey

to mark them as mutable.

The resulting schema's Type is a readonly object type with the fields' decoded types. The Encoded form mirrors the field schemas' encoded types.

Example (Defining a basic struct)

import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number,
email: Schema.optionalKey(Schema.String)
})
// { readonly name: string; readonly age: number; readonly email?: string }
type Person = typeof Person.Type
const alice = Schema.decodeUnknownSync(Person)({ name: "Alice", age: 30 })
console.log(alice)
// { name: 'Alice', age: 30 }

@since3.10.0

Struct
({
id: Schema.String
id
:
import Schema
Schema
.
const String: Schema.String

Type-level representation of

String

.

Schema for string values. Validates that the input is typeof "string".

@since4.0.0

@since4.0.0

String
}),
}),
}
// 2. Define SQLite tables (how to query your state)
export const
const tables: {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;
}
tables
= {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos
:
import State
State
.
import SQLite
SQLite
.
function table<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}, Partial<...>>(args: {
...;
} & Partial<...>): State.SQLite.TableDef<...> (+2 overloads)

Creates a SQLite table definition from columns or an Effect Schema.

This function supports two main ways to define a table:

  1. Using explicit column definitions
  2. Using an Effect Schema (either the name property needs to be provided or the schema needs to have a title/identifier)
// Using explicit columns
const usersTable = State.SQLite.table({
name: 'users',
columns: {
id: State.SQLite.text({ primaryKey: true }),
name: State.SQLite.text({ nullable: false }),
email: State.SQLite.text({ nullable: false }),
age: State.SQLite.integer({ nullable: true }),
},
})
// Using Effect Schema with annotations
import { Schema } from '@livestore/utils/effect'
const UserSchema = Schema.Struct({
id: Schema.Int.pipe(State.SQLite.withPrimaryKey).pipe(State.SQLite.withAutoIncrement),
email: Schema.String.pipe(State.SQLite.withUnique),
name: Schema.String,
active: Schema.Boolean.pipe(State.SQLite.withDefault(true)),
createdAt: Schema.optional(Schema.Date),
})
// Option 1: With explicit name
const usersTable = State.SQLite.table({
name: 'users',
schema: UserSchema,
})
// Option 2: With name from schema annotation (title or identifier)
const AnnotatedUserSchema = UserSchema.annotate({ title: 'users' })
const usersTable2 = State.SQLite.table({
schema: AnnotatedUserSchema,
})
// Adding indexes
const PostSchema = Schema.Struct({
id: Schema.String.pipe(State.SQLite.withPrimaryKey),
title: Schema.String,
authorId: Schema.String,
createdAt: Schema.Date,
}).annotate({ identifier: 'posts' })
const postsTable = State.SQLite.table({
schema: PostSchema,
indexes: [
{ name: 'idx_posts_author', columns: ['authorId'] },
{ name: 'idx_posts_created', columns: ['createdAt'], isUnique: false },
],
})

table
({
name: "todos"
name
: 'todos',
columns: {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}
columns
: {
id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
}
id
:
import State
State
.
import SQLite
SQLite
.
const text: <string, string, false, typeof NoDefault, true, false>(args: {
schema?: Schema.Codec<string, string, never, never>;
default?: typeof NoDefault;
nullable?: false;
primaryKey?: true;
autoIncrement?: false;
}) => {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
} (+1 overload)
text
({
primaryKey?: true
primaryKey
: true }),
text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
}
text
:
import State
State
.
import SQLite
SQLite
.
const text: <string, string, false, "", false, false>(args: {
schema?: Schema.Codec<string, string, never, never>;
default?: "";
nullable?: false;
primaryKey?: false;
autoIncrement?: false;
}) => {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
} (+1 overload)
text
({
default?: ""
default
: '' }),
completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
}
completed
:
import State
State
.
import SQLite
SQLite
.
const boolean: <boolean, false, false, false, false>(args: {
default?: false;
nullable?: false;
primaryKey?: false;
autoIncrement?: false;
}) => {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
} (+1 overload)
boolean
({
default?: false
default
: false }),
},
}),
}
// 3. Define materializers (how to turn events into state)
const
const materializers: {
"v1.TodoCreated": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>>;
"v1.TodoCompleted": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>>;
}
materializers
=
import State
State
.
import SQLite
SQLite
.
const materializers: <{
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
}>(_eventDefRecord: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
}, handlers: {
...;
}) => {
...;
}

Builder function for creating a type-safe materializer map.

This is the primary way to define materializers in LiveStore. It ensures:

  • Every non-derived event has a corresponding materializer
  • Materializer argument types match their event schemas
  • Derived events are excluded from the required handlers

@example

import { State } from '@livestore/livestore'
const handlers = State.SQLite.materializers(events, {
// Handler for each event - argument types are inferred
'v1.TodoCreated': ({ id, text, completed }) =>
tables.todos.insert({ id, text, completed }),
'v1.TodoUpdated': ({ id, text }) =>
tables.todos.update({ text }).where({ id }),
// Can return multiple operations
'v1.UserCreatedWithDefaults': ({ userId, name }) => [
tables.users.insert({ id: userId, name }),
tables.settings.insert({ userId, theme: 'light' }),
],
// Can query current state
'v1.TodoToggled': ({ id }, { query }) => {
const todo = query(tables.todos.select().where({ id }).first())
return tables.todos.update({ completed: !todo?.completed }).where({ id })
},
})

materializers
(
const events: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
}
events
, {
'v1.TodoCreated': ({
id: string
id
,
text: string
text
}) =>
const tables: {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;
}
tables
.
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos
.
insert: (values: {
readonly id: string;
readonly text?: string;
readonly completed?: boolean;
}) => QueryBuilder<readonly Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.Codec<string, string, never, never>;
readonly text: Schema.Codec<string, string, never, never>;
readonly completed: Schema.Codec<boolean, number, never, never>;
}, "Type">[], State.SQLite.TableDefBase<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
...;
};
readonly completed: {
...;
};
}>, State.SQLite.WithDefaults<...>>, "select" | ... 6 more ... | "row">

Insert a new row into the table.

@example

db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' })

@paramvalues - The row values to insert.

insert
({
id: string
id
,
text?: string
text
}),
'v1.TodoCompleted': ({
id: string
id
}) =>
const tables: {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;
}
tables
.
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos
.
update: (values: Partial<Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.Codec<string, string, never, never>;
readonly text: Schema.Codec<string, string, never, never>;
readonly completed: Schema.Codec<boolean, number, never, never>;
}, "Type">>) => QueryBuilder<readonly Schema.Struct.ReadonlySide<{
readonly id: Schema.Codec<string, string, never, never>;
readonly text: Schema.Codec<string, string, never, never>;
readonly completed: Schema.Codec<boolean, number, never, never>;
}, "Type">[], State.SQLite.TableDefBase<...>, "select" | ... 6 more ... | "row">

Update rows in the table that match the where clause

Example:

db.todos.update({ status: 'completed' }).where({ id: '123' })

update
({
completed?: boolean
completed
: true }).
where: (params: Partial<{
readonly id: string | {
op: Exclude<QueryBuilder<TResult, TTableDef extends State.SQLite.TableDefBase<any, any>, TWithout extends QueryBuilder.ApiFeature = never>.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>;
value: string;
} | {
op: QueryBuilder.WhereOps.MultiValue;
value: readonly string[];
} | undefined;
readonly text: string | {
op: Exclude<QueryBuilder.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>;
value: string;
} | {
...;
} | undefined;
readonly completed: boolean | ... 2 more ... | undefined;
}>) => QueryBuilder<...> (+3 overloads)
where
({
id?: string | {
op: Exclude<QueryBuilder.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>;
value: string;
} | {
op: QueryBuilder.WhereOps.MultiValue;
value: readonly string[];
} | undefined
id
}),
})
const
const state: InternalState
state
=
import State
State
.
import SQLite
SQLite
.
const makeState: <{
tables: {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;
};
materializers: {
...;
};
}>(inputSchema: {
tables: {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;
};
materializers: {
...;
};
}) => InternalState
makeState
({
tables: {
todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", {
readonly id: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: None<never>;
nullable: false;
primaryKey: true;
autoIncrement: false;
};
readonly text: {
columnType: "text";
schema: Schema.Codec<string, string, never, never>;
default: Some<"">;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
readonly completed: {
columnType: "integer";
schema: Schema.Codec<boolean, number, never, never>;
default: Some<false>;
nullable: false;
primaryKey: false;
autoIncrement: false;
};
}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;
}
tables
,
materializers: {
"v1.TodoCreated": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>>;
"v1.TodoCompleted": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>>;
}
materializers
})
export const
const schema: FromInputSchema.DeriveSchema<{
events: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
};
state: InternalState;
}>
schema
=
makeSchema<{
events: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
};
state: InternalState;
}>(inputSchema: {
events: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
};
state: InternalState;
}): FromInputSchema.DeriveSchema<...>
makeSchema
({
events: {
todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
readonly text: Schema.String;
}, "Encoded">>;
todoCompleted: State.SQLite.EventDef<"v1.TodoCompleted", Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Type">, Schema.Struct.ReadonlySide<{
readonly id: Schema.String;
}, "Encoded">>;
}
events
,
state: InternalState
state
})

LiveStore comes with integrations for all major frontend frameworks, e.g. for React or Vue.

The queryDb function creates a reactive query which updates automatically when its data in the database changes. Here’s how to use it in React:

import {
function useCallback<T extends Function>(callback: T, deps: DependencyList): T

useCallback will return a memoized version of the callback that only changes if one of the inputs has changed.

useCallback
} from 'react'
import {
function unstable_batchedUpdates<A, R>(callback: (a: A) => R, a: A): R (+1 overload)
unstable_batchedUpdates
as
function batchUpdates<A, R>(callback: (a: A) => R, a: A): R (+1 overload)
batchUpdates
} from 'react-dom'
// TodoApp.tsx
import {
const makeInMemoryAdapter: (options?: InMemoryAdapterOptions) => Adapter

Creates a web-only in-memory LiveStore adapter.

This adapter runs entirely in memory with no persistence. Ideal for:

  • Unit tests and integration tests
  • Sandboxes and demos
  • Ephemeral sessions where persistence isn't needed

Characteristics:

  • Fast, zero I/O overhead
  • Works in all browser contexts: Window, WebWorker, SharedWorker, ServiceWorker
  • Supports optional sync backends for real-time collaboration
  • No data persists after page reload

For persistent storage, use makePersistedAdapter instead.

@example

import { makeInMemoryAdapter } from '@livestore/adapter-web'
const adapter = makeInMemoryAdapter()

@example

// With sync backend for real-time collaboration
import { makeInMemoryAdapter } from '@livestore/adapter-web'
import { makeWsSync } from '@livestore/sync-cf/client'
const adapter = makeInMemoryAdapter({
sync: {
backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
},
})

@example

// Pre-populate with existing data
const adapter = makeInMemoryAdapter({
importSnapshot: existingDbSnapshot,
})

makeInMemoryAdapter
} from '@livestore/adapter-web'
import {
const queryDb: {
<TResultSchema, TResult = TResultSchema>(queryInput: QueryInputRaw<TResultSchema, ReadonlyArray<any>> | QueryBuilder<TResultSchema, any, any>, options?: {
map?: (rows: TResultSchema) => TResult;
label?: string;
deps?: DepKey;
}): LiveQueryDef<TResult>;
<TResultSchema, TResult = TResultSchema>(queryInput: ((get: GetAtomResult) => QueryInputRaw<TResultSchema, ReadonlyArray<any>>) | ((get: GetAtomResult) => QueryBuilder<TResultSchema, any, any>), options?: {
map?: (rows: TResultSchema) => TResult;
label?: string;
deps?: DepKey;
}): LiveQueryDef<TResult>;
}

NOTE queryDb is only supposed to read data. Don't use it to insert/update/delete data but use events instead.

When using contextual data when constructing the query, please make sure to include it in the deps option.

@example

const todos$ = queryDb(tables.todos.where({ complete: true }))

@example

// Group-by raw SQL query
const colorCounts$ = queryDb({
query: sql`SELECT color, COUNT(*) as count FROM todos WHERE complete = ? GROUP BY color`,
schema: Schema.Array(Schema.Struct({
color: Schema.String,
count: Schema.Number,
})),
bindValues: [1],
})

@example

// Using contextual data when constructing the query
const makeFilteredQuery = (filter: string) =>
queryDb(tables.todos.where({ title: { op: 'like', value: filter } }), { deps: [filter] })
const filteredTodos$ = makeFilteredQuery('buy coffee')

queryDb
} from '@livestore/livestore'
import {
const useStore: <TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>(options: RegistryStoreOptions<TSchema, TContext, TSyncPayloadSchema>) => Store<TSchema, TContext> & ReactApi

Returns a store instance augmented with hooks (store.useQuery() and store.useClientDocument()) for reactive queries.

@example

function Issue() {
// Suspends until loaded or returns immediately if already loaded
const issueStore = useStore(issueStoreOptions('abc123'))
const [issue] = issueStore.useQuery(queryDb(tables.issue.select()))
const toggleStatus = () =>
issueStore.commit(
issueEvents.issueStatusChanged({
id: issue.id,
status: issue.status === 'done' ? 'todo' : 'done',
}),
)
const preloadParentIssue = (issueId: string) =>
storeRegistry.preload({
...issueStoreOptions(issueId),
unusedCacheTime: 10_000,
})
return (
<>
<h2>{issue.title}</h2>
<button onClick={() => toggleStatus()}>Toggle Status</button>
<button onMouseEnter={() => preloadParentIssue(issue.parentIssueId)}>Open Parent Issue</button>
</>
)
}

@returnsThe loaded store instance augmented with React hooks

@throwsunknown - store loading error or if called outside <StoreRegistryProvider>

useStore
} from '@livestore/react'
import {
import events
events
,
import schema
schema
,
import tables
tables
} from './schema.ts'
const
const adapter: Adapter
adapter
=
function makeInMemoryAdapter(options?: InMemoryAdapterOptions): Adapter

Creates a web-only in-memory LiveStore adapter.

This adapter runs entirely in memory with no persistence. Ideal for:

  • Unit tests and integration tests
  • Sandboxes and demos
  • Ephemeral sessions where persistence isn't needed

Characteristics:

  • Fast, zero I/O overhead
  • Works in all browser contexts: Window, WebWorker, SharedWorker, ServiceWorker
  • Supports optional sync backends for real-time collaboration
  • No data persists after page reload

For persistent storage, use makePersistedAdapter instead.

@example

import { makeInMemoryAdapter } from '@livestore/adapter-web'
const adapter = makeInMemoryAdapter()

@example

// With sync backend for real-time collaboration
import { makeInMemoryAdapter } from '@livestore/adapter-web'
import { makeWsSync } from '@livestore/sync-cf/client'
const adapter = makeInMemoryAdapter({
sync: {
backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
},
})

@example

// Pre-populate with existing data
const adapter = makeInMemoryAdapter({
importSnapshot: existingDbSnapshot,
})

makeInMemoryAdapter
()
const
const useAppStore: () => Store<any, {}> & ReactApi
useAppStore
= () =>
useStore<any, {}, Codec<Json, Json, never, never>>(options: RegistryStoreOptions<any, {}, Codec<Json, Json, never, never>>): Store<any, {}> & ReactApi

Returns a store instance augmented with hooks (store.useQuery() and store.useClientDocument()) for reactive queries.

@example

function Issue() {
// Suspends until loaded or returns immediately if already loaded
const issueStore = useStore(issueStoreOptions('abc123'))
const [issue] = issueStore.useQuery(queryDb(tables.issue.select()))
const toggleStatus = () =>
issueStore.commit(
issueEvents.issueStatusChanged({
id: issue.id,
status: issue.status === 'done' ? 'todo' : 'done',
}),
)
const preloadParentIssue = (issueId: string) =>
storeRegistry.preload({
...issueStoreOptions(issueId),
unusedCacheTime: 10_000,
})
return (
<>
<h2>{issue.title}</h2>
<button onClick={() => toggleStatus()}>Toggle Status</button>
<button onMouseEnter={() => preloadParentIssue(issue.parentIssueId)}>Open Parent Issue</button>
</>
)
}

@returnsThe loaded store instance augmented with React hooks

@throwsunknown - store loading error or if called outside <StoreRegistryProvider>

useStore
({
CreateStoreOptions<TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>.storeId: string

Unique identifier for the Store instance, stable for its lifetime.

  • Valid characters: Only alphanumeric characters, underscores (_), and hyphens (-) are allowed. Must match /^[a-zA-Z0-9_-]+$/.
  • Globally unique: Use globally unique IDs (e.g., nanoid) to prevent collisions across stores.
  • Use namespaces: Prefix to avoid collisions and for easier identification when debugging (e.g., app-root, workspace-abc123, issue-456)

storeId
: 'my-app',
CreateStoreOptions<any, {}, Codec<Json, Json, never, never>>.schema: any

The LiveStore schema defining tables, events, and materializers.

schema
,
CreateStoreOptions<TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>.adapter: Adapter

Adapter used for data storage and synchronization.

adapter
,
CreateStoreOptions<any, {}, Codec<Json, Json, never, never>>.batchUpdates?: (run: () => void) => void

Needed in React so LiveStore can apply multiple events in a single render.

@example

// With React DOM
import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
// With React Native
import { unstable_batchedUpdates as batchUpdates } from 'react-native'

batchUpdates
,
})
// Define a reactive query
const
const visibleTodos$: LiveQueryDef<unknown, "def">
visibleTodos$
=
queryDb<unknown, unknown>(queryInput: ((get: GetAtomResult) => QueryInputRaw<unknown, readonly any[]>) | ((get: GetAtomResult) => QueryBuilder<unknown, any, any>), options?: {
map?: (rows: unknown) => unknown;
label?: string;
deps?: DepKey;
} | undefined): LiveQueryDef<unknown, "def"> (+1 overload)

NOTE queryDb is only supposed to read data. Don't use it to insert/update/delete data but use events instead.

When using contextual data when constructing the query, please make sure to include it in the deps option.

@example

const todos$ = queryDb(tables.todos.where({ complete: true }))

@example

// Group-by raw SQL query
const colorCounts$ = queryDb({
query: sql`SELECT color, COUNT(*) as count FROM todos WHERE complete = ? GROUP BY color`,
schema: Schema.Array(Schema.Struct({
color: Schema.String,
count: Schema.Number,
})),
bindValues: [1],
})

@example

// Using contextual data when constructing the query
const makeFilteredQuery = (filter: string) =>
queryDb(tables.todos.where({ title: { op: 'like', value: filter } }), { deps: [filter] })
const filteredTodos$ = makeFilteredQuery('buy coffee')

queryDb
(() =>
import tables
tables
.
any
todos
, {
label?: string

Used for debugging / devtools

label
: 'visibleTodos',
})
type
type Todo = {
id: string;
text: string;
completed: boolean;
}
Todo
= {
id: string
id
: string
text: string
text
: string
completed: boolean
completed
: boolean
}
export const
const TodoApp: () => JSX.Element
TodoApp
= () => {
const
const store: Store<any, {}> & ReactApi
store
=
const useAppStore: () => Store<any, {}> & ReactApi
useAppStore
()
// Reactively updates when todos change in the DB
const
const todos: unknown
todos
=
const store: Store<any, {}> & ReactApi
store
.
useQuery: <LiveQueryDef<unknown, "def">>(queryable: LiveQueryDef<unknown, "def">, options?: {
store?: Store;
}) => unknown

Returns the result of a query and subscribes to future updates.

Example:

const App = () => {
const todos = useQuery(queryDb(tables.todos.query.where({ complete: true })))
return <div>{todos.map((todo) => <div key={todo.id}>{todo.title}</div>)}</div>
}

useQuery
(
const visibleTodos$: LiveQueryDef<unknown, "def">
visibleTodos$
)
const
const addTodo: (text: string) => void
addTodo
=
useCallback<(text: string) => void>(callback: (text: string) => void, deps: DependencyList): (text: string) => void

useCallback will return a memoized version of the callback that only changes if one of the inputs has changed.

useCallback
(
(
text: string
text
: string) => {
// Commit an event to the store
const store: Store<any, {}> & ReactApi
store
.
Store<any, {}>.commit: <readonly [any]>(list_0: any) => void (+3 overloads)
commit
(
import events
events
.
any
todoCreated
({
id: `${string}-${string}-${string}-${string}-${string}`
id
:
var crypto: Crypto
crypto
.
Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${string}` (+2 overloads)
randomUUID
(),
text: string
text
,
}),
)
},
[
const store: Store<any, {}> & ReactApi
store
],
)
const
const completeTodo: (id: string) => void
completeTodo
=
useCallback<(id: string) => void>(callback: (id: string) => void, deps: DependencyList): (id: string) => void

useCallback will return a memoized version of the callback that only changes if one of the inputs has changed.

useCallback
(
(
id: string
id
: string) => {
// Commit an event to the store
const store: Store<any, {}> & ReactApi
store
.
Store<any, {}>.commit: <readonly [any]>(list_0: any) => void (+3 overloads)
commit
(
import events
events
.
any
todoCompleted
({
id: string
id
}))
},
[
const store: Store<any, {}> & ReactApi
store
],
)
const
const handleAddTodo: () => void
handleAddTodo
=
useCallback<() => void>(callback: () => void, deps: DependencyList): () => void

useCallback will return a memoized version of the callback that only changes if one of the inputs has changed.

useCallback
(() => {
const addTodo: (text: string) => void
addTodo
('New todo')
}, [
const addTodo: (text: string) => void
addTodo
])
return (
<
JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
div
>
<
JSX.IntrinsicElements.button: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button
ButtonHTMLAttributes<HTMLButtonElement>.type?: "button" | "submit" | "reset" | undefined
type
="button"
DOMAttributes<HTMLButtonElement>.onClick?: MouseEventHandler<HTMLButtonElement> | undefined
onClick
={
const handleAddTodo: () => void
handleAddTodo
}>
Add
</
JSX.IntrinsicElements.button: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button
>
{
const todos: unknown
todos
.
any
map
((
todo: any
todo
) => (
<
const TodoListItem: ({ todo, onComplete }: {
todo: Todo;
onComplete: (id: string) => void;
}) => JSX.Element
TodoListItem
Attributes.key?: Key | null | undefined
key
={
todo: any
todo
.
any
id
}
todo: Todo
todo
={
todo: any
todo
}
onComplete: (id: string) => void
onComplete
={
const completeTodo: (id: string) => void
completeTodo
} />
))}
</
JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
div
>
)
}
const
const TodoListItem: ({ todo, onComplete }: {
todo: Todo;
onComplete: (id: string) => void;
}) => JSX.Element
TodoListItem
= ({
todo: Todo
todo
,
onComplete: (id: string) => void
onComplete
}: {
todo: Todo
todo
:
type Todo = {
id: string;
text: string;
completed: boolean;
}
Todo
;
onComplete: (id: string) => void
onComplete
: (
id: string
id
: string) => void }) => {
const
const handleComplete: () => void
handleComplete
=
useCallback<() => void>(callback: () => void, deps: DependencyList): () => void

useCallback will return a memoized version of the callback that only changes if one of the inputs has changed.

useCallback
(() => {
onComplete: (id: string) => void
onComplete
(
todo: Todo
todo
.
id: string
id
)
}, [
onComplete: (id: string) => void
onComplete
,
todo: Todo
todo
.
id: string
id
])
return (
<
JSX.IntrinsicElements.button: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button
ButtonHTMLAttributes<HTMLButtonElement>.type?: "button" | "submit" | "reset" | undefined
type
="button"
DOMAttributes<HTMLButtonElement>.onClick?: MouseEventHandler<HTMLButtonElement> | undefined
onClick
={
const handleComplete: () => void
handleComplete
}>
{
todo: Todo
todo
.
completed: boolean
completed
=== true ? '✓' : '○'} {
todo: Todo
todo
.
text: string
text
}
</
JSX.IntrinsicElements.button: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button
>
)
}

This code replaces all the API calls, state management, UI update, caching, and synchronization logic you may be used to from writing apps in a more traditional way. If configured, it also automatically takes care of syncing data to other clients.

Also notice how there’s no loading state for queries—SQLite runs in-memory on the main thread, so reads are synchronous and instant, making your app super snappy and reactive to user input.

But why use events at all, rather than directly mutating state?

Benefits of using events for state management

Section titled “Benefits of using events for state management”
  • Events capture intent, not just outcome. When you mutate state directly (todo.completed = true), you lose the why. Events like TodoCompleted preserve the user’s intent, which matters for debugging, analytics, undo/redo, and features like activity feeds.
  • Events decouple what happened from how it’s stored. Your state shape can evolve independently of your event history. Need a new denormalized table for performance? Just add a materializer—no data migration required.
  • Events make sync tractable. Syncing mutable state across devices is hard (which field wins?). Syncing an append-only event log is simpler: you’re merging histories, not reconciling conflicting states.

The eventlog sits the core of LiveStore and gives you several benefits:

  • Persistence: Events survive page refreshes and app restarts
  • Sync: Events replay identically across devices
  • History: Full audit trail of every change (enables time travel and helps with debugging)
  • Flexibility: Change your queries without migrations—just materialize differently

LiveStore includes a sync engine which handles syncing for you under the hood.

When you can enable syncing, the sync engine distributes your state across various other clients (these can be other browsers, apps, devices, servers—anything that can connect to your store via an adapter).

LiveStore uses a push/pull model inspired by git:

  1. Local events are committed immediately (optimistic updates by default)
  2. Events sync to a central backend when online
  3. Other clients pull new events and materialize them locally
  4. Conflicts resolve deterministically (last-write-wins by default, or custom logic)

Here’s an example that syncs your state via Cloudflare (using the @livestore/sync-cf package):

import {
const makeWorker: (options: WorkerOptions) => void
makeWorker
} from '@livestore/adapter-web/worker'
import {
const makeWsSync: (options: WsSyncOptions) => SyncBackendConstructor<SyncMetadata>

Creates a sync backend that uses WebSocket to communicate with the sync backend.

@example

import { makeWsSync } from '@livestore/sync-cf/client'
const syncBackend = makeWsSync({ url: 'wss://sync.example.com' })

makeWsSync
} from '@livestore/sync-cf/client'
import {
import schema
schema
} from './schema.ts'
function makeWorker(options: WorkerOptions): void
makeWorker
({
schema: LiveStoreSchema<DbSchema, EventDefRecord>
schema
,
sync?: SyncOptions
sync
: {
backend?: SyncBackendConstructor<any, Json>
backend
:
function makeWsSync(options: WsSyncOptions): SyncBackendConstructor<SyncMetadata>

Creates a sync backend that uses WebSocket to communicate with the sync backend.

@example

import { makeWsSync } from '@livestore/sync-cf/client'
const syncBackend = makeWsSync({ url: 'wss://sync.example.com' })

makeWsSync
({
WsSyncOptions.url: string

URL of the sync backend

The protocol can either http/https or ws/wss

url
: `${
var location: Location

The read-only location property of the Window interface returns a Location object with information about the current location of the document.

MDN Reference

The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.

MDN Reference

location
.
Location.origin: string

The origin read-only property of the Location interface returns a string containing the Unicode serialization of the origin of the location's URL.

MDN Reference

origin
}/sync` }),
},
})

That’s all the configuration needed—your app works offline automatically. When connectivity returns, LiveStore syncs pending events and reconciles state.

LiveStore works well for:

  • Productivity apps (todo lists, note-taking, project management, …)
  • Collaborative tools with multi-player support
  • Apps with complex local state that need SQL-level queries
  • Local-first apps with offline support
  • Cross-platform apps (web, mobile, desktop, server)
  • … or any combination of the above

LiveStore may not fit if:

  • Your data must live on an existing server database (consider ElectricSQL or Zero)
  • You’re building a traditional client-server app without offline needs
  • You need unbounded data that won’t fit in client memory

If you’re unsure, go through our evaluation exercise to find out whether LiveStore is a good fit for your project or compare it with similar tools.