This is the full developer documentation for LiveStore. ## Notes - Most LiveStore APIs are synchronous and don't need `await` # Start of LiveStore documentation # [Docs](https://dev.docs.livestore.dev//) ## State of the documentation Please note that the documentation is still work in progress. Please [leave feedback](https://github.com/livestorejs/livestore/issues) if you didn't find the information you were looking for and [consider contributing](/sustainable-open-source/contributing/docs) yourself. ### Docs for LLMs We support the [llms.txt](https://llmstxt.org/) convention for making documentation available to large language models and the applications that make use of them. Currently, we have the following root-level files: ### npm packages - Main package: - Framework integrations: - React: - Solid: - Platform adapters: - Web: - Expo: - Node: - Sync provider: - Cloudflare: - Electric: - Devtools: - Vite: - Expo: - SQLite packages: - sqlite-wasm (wrapper around wa-sqlite): - wa-sqlite fork: - Internal packages: - - - # [Cloudflare Durable Objects examples](https://dev.docs.livestore.dev/examples/cloudflare-adapter/) # Cloudflare Durable Objects Examples Examples using `@livestore/adapter-cloudflare` for Cloudflare Workers and Durable Objects. ## Cloudflare Adapter - Runs LiveStore inside Cloudflare Durable Objects - Uses Durable Object Storage API (not traditional databases) - SQLite WASM with Cloudflare-specific VFS - No WebSocket support - uses Durable Objects' distributed consistency --- View all examples on GitHub → # [Expo adapter examples](https://dev.docs.livestore.dev/examples/expo-adapter/) # Expo Adapter Examples Examples using `@livestore/adapter-expo` for React Native mobile applications. ## Expo Adapter - Uses native Expo SQLite stored in device's SQLite directory - Requires New Architecture (Fabric) - incompatible with old architecture - Single-threaded operation in main thread - WebSocket connections for sync via React Native dev server --- View all examples on GitHub → # [Examples](https://dev.docs.livestore.dev/examples//) # Example Applications Discover how to build local-first applications with LiveStore through our comprehensive collection of example apps. Each example demonstrates different features, patterns, and platform integrations to help you get started quickly. ## Browse by platform adapter LiveStore supports multiple platform adapters, each optimized for different environments. Choose the adapter that matches your target platform: ## Getting started 1. **Choose your platform** from the adapter categories above 2. **Browse examples** that match your use case and framework preference 3. **Clone and run** the examples locally to see LiveStore in action 4. **Study the source code** to understand patterns and best practices ## Multi-adapter examples Some examples demonstrate **cross-platform synchronization** by using multiple adapters: - **CF Chat** uses both Web and Cloudflare adapters for hybrid client-server architecture - **Sync-enabled examples** show how to connect different platforms seamlessly ## About LiveStore LiveStore is a local-first data layer that runs everywhere - from browsers to mobile apps to edge computing. Each adapter provides platform-optimized features while maintaining a consistent API across all environments. --- View all examples on GitHub → # [Node adapter examples](https://dev.docs.livestore.dev/examples/node-adapter/) # Node Adapter Examples Examples using `@livestore/adapter-node` for Node.js server-side applications. ## Node Adapter - Uses native Node.js SQLite with file system storage - Stores SQLite files directly on disk (default: current directory) - Supports single-threaded or worker thread modes - WebSocket connections for sync and devtools integration --- View all examples on GitHub → # [Web adapter examples](https://dev.docs.livestore.dev/examples/web-adapter/) # Web Adapter Examples Examples using `@livestore/adapter-web` for browser environments. ## Web Adapter - Uses SQLite WASM with OPFS (Origin Private File System) for browser storage - Runs in Web Workers and SharedWorkers for multi-tab coordination - Persists data via OPFS Access Handle Pool VFS - Supports WebSocket connections for sync and devtools ## Frameworks - React (`@livestore/react`) - SolidJS (`@livestore/solid`) - Svelte (`@livestore/svelte`) - Web Components - Vanilla JavaScript --- View all examples on GitHub → # [Complex UI state](https://dev.docs.livestore.dev/building-with-livestore/complex-ui-state/) LiveStore is a great fit for building apps with complex UI state. # [Data modeling](https://dev.docs.livestore.dev/building-with-livestore/data-modeling/) ## Core idea - Data modeling is probably the most important part of any app and needs to be done carefully. - The core idea is to model the read and write model separately. - Depending on the use case, you might also want to split up the read/write model into separate "containers" (e.g. for data-sharing/scalability/access control reasons). - There is no transactional consistency between containers. - Caveat: Event sourcing is not ideal for all use cases - some apps might be better off with another approach (e.g. use CRDTs for rich text editing). ## Considerations for data modeling - How much data do you expect to have and what is the shape of the data? - Some kind of data needs special handling (e.g. blobs or rich text) - Access patterns (performance, ...) - Access control - Data integrity / consistency - Sharing / collaboration - Regulatory requirements (e.g. GDPR, audit logs, ...) ## TODO - TODO: actually write this section - questions to answer - When to split things into separate containers? - How do migrations work? - Read model migrations - Write model migrations - How to create new write models based on existing ones - Example: An app has multiple workspaces and you now want to introduce the concept of "projects" inside a workspace. You might want to pre-populate a "default workspace project" for each workspace. # [CRUD](https://dev.docs.livestore.dev/building-with-livestore/crud/) ## CRUD # [Debugging a LiveStore app](https://dev.docs.livestore.dev/building-with-livestore/debugging/) When working on a LiveStore app you might end up in situations where you need to debug things. LiveStore is built with debuggability in mind and tries to make your life as a developer as easy as possible. Here are a few things that LiveStore offers to help you debug your app: - [OpenTelemetry](/building-with-livestore/opentelemetry) integration for tracing / metrics - [Devtools](/building-with-livestore/devtools) for inspecting the state of the store - Store helper methods ## Debugging helpers on the store The `store` exposes a `_dev` property which contains a few helpers that can help you debug your app. ## Other recommended practices and tools - Use the step debugger # [Events](https://dev.docs.livestore.dev/building-with-livestore/events/) ## Event definitions There are two types of events: - `synced`: Events that are synced across clients - `clientOnly`: Events that are only processed locally on the client (but still synced across client sessions e.g. across browser tabs/windows) An event definition consists of a unique name of the event and a schema for the event arguments. It's recommended to version event definitions to make it easier to evolve them over time. Events will be synced across clients and materialized into state (i.e. SQLite tables) via [materializers](/building-with-livestore/state/materializers). ### Example ## `reference/events/livestore-schema.ts` ```ts filename="reference/events/livestore-schema.ts" // livestore/schema.ts export const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), todoCompleted: Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ id: Schema.String }), }), } as const ``` ### Commiting events ## `reference/events/commit.ts` ```ts filename="reference/events/commit.ts" // somewhere in your app declare const store: Store store.commit(events.todoCreated({ id: '1', text: 'Buy milk' })) ``` ### Streaming events Currently only events confirmed by the sync backend are supported. ## `reference/events/stream.ts` ```ts filename="reference/events/stream.ts" declare const store: Store for await (const event of store.events()) { console.log('event from leader', event) } ``` ### Best practices - It's strongly recommended to use past-tense event names (e.g. `todoCreated`/`createdTodo` instead of `todoCreate`/`createTodo`) to indicate something already occurred. - When generating IDs for events (e.g. for the todo in the example above), it's recommended to use a globally unique ID generator (e.g. UUID, nanoid, etc.) to avoid conflicts. For convenience, `@livestore/livestore` re-exports the `nanoid` function. - TODO: write down more best practices - TODO: mention AI linting (either manually or via a CI step) - core idea: feed list of best practices to AI and check if events adhere to them + get suggestions if not - It's recommended to avoid `DELETE` events and instead use soft-deletes (e.g. add a `deleted` date/boolean column with a default value of `null`). This helps avoid some common concurrency issues. ## Nodes in the LiveStore system #### Client Session - `SyncState`: in-memory for pending events only - `dbState`: Materialized state that matches the schema (SQLite database) #### Client Leader - `dbEventLog`: Database that stores the durable event log and tracks global sequence of events which the backend has acknowledged - `dbState`: Materialized state that matches the schema (SQLite database) so leader can materialize events and handle rollbacks #### Sync backend - `EventLog`: Any storage solution that supports pushing and pulling events. Append only storage. ### Happy event path 1. Client session commits an event - Client session merges the new event `e3` into its local `SyncState` as pending - Client session pushes the pending event to the client leader thread; the leader still shows the previous head until it persists the event 2. Client leader persists the event - Client leader materializes the event and writes it to `EventLog` - Event `e3` remains unconfirmed from the leader's perspective because the backend has not acknowledged it yet 3. Leader thread emits signal back to subscribed clients - Client session merges the authoritative event from the leader - Event transitions from pending to confirmed on the client while the leader still waits for backend confirmation 4. Leader thread pushes the event to the sync backend - Leader pushes the pending event upstream; it stays marked as unconfirmed in the client leader's eventlog until the backend acknowledges receipt 5. Sync backend pulls the event from the client leader - Sync backend acknowledges the event and advances its head - Client leader receives the acknowledgement and marks the event as confirmed - All heads align on the confirmed sequence ### Conflict resolution This example shows how a client session rebases its pending events when new authoritative events arrive from upstream. Client `A` owns the local work that gets rebased, while client `B` introduces the authoritative change. Colors follow the client IDs so lineage remains visible, and origin notation tracks the rebased event. 1. Client session has local pending work while upstream advances - Client session holds pending event `A:e3'{todoRenamed}` built on top of shared history `e1 → e2` - Sync backend publishes authoritative event `B:e3{todoRenamed}` that replaces the client's local change 2. Client leader pulls authoritative events from the sync backend - Client compares its pending chain with upstream events and spots the divergence at `e2` - Client rolls back events and state to the point of divergence 3. Client applies authoritative upstream events - Client session and leader apply the authoritative upstream events and advances their heads to `e3` 4. Client replays its local pending events on top of the new head - Stored original events keep their payload but their sequence number gets updated to follow upstream head - Each newly numbered event is re-appplied and materialized to state in both client session and client leader 5. Client pushes its local pending events to sync backend - Upon receipt local pending events are marked as confirmed and the client leader advances its head to `e4` ## Unknown events Older clients might receive events that were introduced in newer app versions. Configure the behaviour centrally via `unknownEventHandling` when constructing the schema: ## `reference/events/unknown-event-handling.ts` ```ts filename="reference/events/unknown-event-handling.ts" const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), }, }), } as const const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), } as const const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) => tables.todos.insert({ id, text }), ), }) const state = State.SQLite.makeState({ tables, materializers }) // ---cut--- const _schema = makeSchema({ events, state, unknownEventHandling: { strategy: 'callback', onUnknownEvent: (event, error) => { console.warn('LiveStore saw an unknown event', { event, reason: error.reason }) }, }, }) ``` Pick `'warn'` (default) to log every occurrence, `'ignore'` to silently drop new events until the client updates, `'fail'` to halt immediately, or `'callback'` to delegate to custom logging/telemetry while continuing to process the log. ## Schema evolution \{#schema-evolution\} - Event definitions can't be removed after they were added to your app. - Event schema definitions can be evolved as long as the changes are forward-compatible. - That means data encoded with the old schema can be decoded with the new schema. - In practice, this means ... - for structs ... - you can add new fields if they have default values or are optional - you can remove fields ## Event format Each event has the following structure: | Field | Description | |-------|-------------| | `name` | Event name matching the event definition | | `args` | Event arguments as defined by the event schema | | `seqNum` | Sequence number identifying this event | | `parentSeqNum` | Parent event's sequence number (for causal ordering) | | `clientId` | Identifier of the client that created the event | | `sessionId` | Identifier of the session | ### Encoded vs decoded events Events exist in two formats: **Decoded** - Native TypeScript types used in application code: ```json { "name": "todoCreated-v1", "args": { "id": "abc123", "text": "Buy milk", "createdAt": Date }, "seqNum": 5, "parentSeqNum": 4, "clientId": "client-xyz", "sessionId": "session-123" } ``` **Encoded** - Serialized format for storage and sync: ```json { "name": "todoCreated-v1", "args": { "id": "abc123", "text": "Buy milk", "createdAt": "2024-01-15T10:30:00.000Z" }, "seqNum": 5, "parentSeqNum": 4, "clientId": "client-xyz", "sessionId": "session-123" } ``` The `args` field is encoded according to the event's schema (e.g., `Date` objects become ISO strings, binary data becomes base64). LiveStore handles encoding/decoding automatically. ### Client-side sequence numbers On the client, sequence numbers are expanded to track additional information for local events: ```json { "seqNum": { "global": 5, "client": 1, "rebaseGeneration": 0 }, "parentSeqNum": { "global": 5, "client": 0, "rebaseGeneration": 0 } } ``` - **global**: Globally unique integer assigned by the sync backend (`EventSequenceNumber.Global`) - **client**: Client-local counter (0 for synced events, increments for client-only events) (`EventSequenceNumber.Client`) - **rebaseGeneration**: Increments when the client rebases unconfirmed events Events can be represented as strings like `e5` (global event 5), `e5.1` (client-local event), or `e5r1` (after a rebase). For the full type definitions, see [`LiveStoreEvent`](https://github.com/livestorejs/livestore/tree/dev/packages/@livestore/common/src/schema/LiveStoreEvent) and [`EventSequenceNumber`](https://github.com/livestorejs/livestore/tree/dev/packages/@livestore/common/src/schema/EventSequenceNumber). ### Event type namespaces LiveStore organizes event types into namespaces based on their usage context: | Namespace | Description | Sequence Number Format | |-----------|-------------|----------------------| | `LiveStoreEvent.Input` | Events without sequence numbers (for committing) | None | | `LiveStoreEvent.Global` | Sync backend format | Integer (`seqNum: number`) | | `LiveStoreEvent.Client` | Client-side format with full metadata | Struct (`seqNum: { global, client, rebaseGeneration }`) | ## `reference/events/event-type-namespaces.ts` ```ts filename="reference/events/event-type-namespaces.ts" // Input events (no sequence numbers) - used when committing const _input: LiveStoreEvent.Input.Decoded = { name: 'todoCreated-v1', args: { id: 'abc123', text: 'Buy milk' }, } // Global events (sync backend format) - integer sequence numbers const _global: LiveStoreEvent.Global.Encoded = { name: 'todoCreated-v1', args: { id: 'abc123', text: 'Buy milk' }, seqNum: EventSequenceNumber.Global.make(5), parentSeqNum: EventSequenceNumber.Global.make(4), clientId: 'client-xyz', sessionId: 'session-123', } // Client events (local format) - composite sequence numbers const _client: LiveStoreEvent.Client.Encoded = { name: 'todoCreated-v1', args: { id: 'abc123', text: 'Buy milk' }, seqNum: EventSequenceNumber.Client.Composite.make({ global: 5, client: 0, rebaseGeneration: 0 }), parentSeqNum: EventSequenceNumber.Client.Composite.make({ global: 4, client: 0, rebaseGeneration: 0 }), clientId: 'client-xyz', sessionId: 'session-123', } ``` ## Eventlog The history of all events that have been committed is stored forms the "eventlog". It is persisted in the client as well as in the sync backend. Example `eventlog.db`: ![](https://share.cleanshot.com/R6ny879w+) # [Devtools](https://dev.docs.livestore.dev/building-with-livestore/devtools/) NOTE: Once LiveStore is open source, the devtools will be a [sponsor-only benefit](/sustainable-open-source/sponsoring). ## Features - Real-time data browser with 2-way sync ![](https://share.cleanshot.com/F79hpTCY+) - Query inspector ![](https://share.cleanshot.com/pkr2jqgb+) - Eventlog browser ![](https://share.cleanshot.com/PTgXpcPm+) - Sync status ![](https://share.cleanshot.com/VsKY3KnR+) - Export/import ![](https://share.cleanshot.com/LQKYX6rq+) - Reactivity graph / signals inspector ![](https://share.cleanshot.com/M26FHD6j+) - SQLite playground ![](https://share.cleanshot.com/BcWmLmn2+) ## Adapters ### `@livestore/adapter-web`: Requires the `@livestore/devtools-vite` package to be installed and configured in your Vite config: ## `reference/devtools/vite-config.ts` ```ts filename="reference/devtools/vite-config.ts" export default defineConfig({ // ... plugins: [livestoreDevtoolsPlugin({ schemaPath: './src/livestore/schema.ts' })], }) ``` The devtools can be opened in a separate tab (via e.g. `localhost:3000/_livestore/web). You should see the Devtools URL logged in the browser console when running the app. #### Chrome extension You can also use the Devtools Chrome extension. ![](https://share.cleanshot.com/wlM4ybFn+) Please make sure to manually install the extension version matching the LiveStore version you are using by downloading the appropriate version from the [GitHub releases page](https://github.com/livestorejs/livestore/releases) and installing it manually via `chrome://extensions/`. To install the extension: 1. **Unpack the ZIP file** (e.g. `livestore-devtools-chrome-0.3.0.zip`) into a folder on your computer. 2. Navigate to `chrome://extensions/` and enable **Developer mode** (toggle in the top-right corner). 3. Click **"Load unpacked"** and select the unpacked folder or drag and drop the folder onto the page. ### `@livestore/adapter-expo`: Requires the `@livestore/devtools-expo` package to be installed and configured in your metro config: ## `reference/devtools/metro-config.ts` ```ts filename="reference/devtools/metro-config.ts" // @noErrors // metro.config.js const { getDefaultConfig } = require('expo/metro-config') const { addLiveStoreDevtoolsMiddleware } = require('@livestore/devtools-expo') const config = getDefaultConfig(__dirname) addLiveStoreDevtoolsMiddleware(config, { schemaPath: './src/livestore/schema.ts' }) module.exports = config ``` You can open the devtools by pressing `Shift+m` in the Expo CLI process and then selecting `@livestore/devtools-expo` which will open the devtools in a new tab. ### `@livestore/adapter-node`: Devtools are configured out of the box for the `makePersistedAdapter` variant (note currently not supported for the `makeInMemoryAdapter` variant). You should see the Devtools URL logged when running the app. # [OpenTelemetry](https://dev.docs.livestore.dev/building-with-livestore/opentelemetry/) LiveStore has built-in support for OpenTelemetry. ## Usage with React ## `reference/opentelemetry/otel.ts` ```ts filename="reference/opentelemetry/otel.ts" /** * Configure a browser tracer that preserves parent/child spans across async work. * Requires a zone.js runtime (e.g. provided by many frameworks) so the ZoneContextManager * can keep context during timers, promises, and event callbacks. */ export const makeTracer = (serviceName: string) => { const url = import.meta.env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT as string | undefined const provider = new WebTracerProvider({ spanProcessors: url ? [new SimpleSpanProcessor(new OTLPTraceExporter({ url: `${url}/v1/traces` }))] : [], resource: resourceFromAttributes({ 'service.name': serviceName }), }) provider.register({ contextManager: new ZoneContextManager(), propagator: new W3CTraceContextPropagator(), }) return provider.getTracer('livestore') } export const tracer = makeTracer('my-app') ``` ## `reference/opentelemetry/app.tsx` ```tsx filename="reference/opentelemetry/app.tsx" const adapter = makeInMemoryAdapter() // ---cut--- const useAppStore = () => useStore({ storeId: 'otel-demo', schema, adapter, batchUpdates, otelOptions: { tracer }, }) export const App: FC = () => { const [storeRegistry] = useState(() => new StoreRegistry()) return ( Loading...}> ) } const AppContent: FC = () => { const _store = useAppStore() // Use the store in your components return
{/* Your app content */}
} ``` ### `reference/framework-integrations/react/schema.ts` ```ts filename="reference/framework-integrations/react/schema.ts" export const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), }, }), uiState: State.SQLite.clientDocument({ name: 'UiState', schema: Schema.Struct({ text: Schema.String }), default: { value: { text: '' } }, }), } as const export const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), } as const const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) => tables.todos.insert({ id, text, completed: false }), ), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) ``` ### `reference/opentelemetry/schema.ts` ```ts filename="reference/opentelemetry/schema.ts" export { schema } from '../framework-integrations/react/schema.ts' ```
## `reference/opentelemetry/livestore.worker.ts` ```ts filename="reference/opentelemetry/livestore.worker.ts" makeWorker({ schema, otelOptions: { tracer } }) ``` # [Production checklist](https://dev.docs.livestore.dev/building-with-livestore/production-checklist/) ## Production checklist TBD # [Reactivity system](https://dev.docs.livestore.dev/building-with-livestore/reactivity-system/) LiveStore has a high-performance, fine-grained reactivity system built in which is similar to Signals (e.g. in [SolidJS](https://docs.solidjs.com/concepts/signals)). ## Defining reactive state LiveStore provides 3 types of reactive state: - Reactive SQL queries on top of SQLite state (`queryDb()`) - Reactive state values (`signal()`) - Reactive computed values (`computed()`) Reactive state variables end on a `$` by convention (e.g. `todos$`). The `label` option is optional but can be used to identify the reactive state variable in the devtools. ### Reactive SQL queries ## `reference/reactivity-system/query-db.ts` ```ts filename="reference/reactivity-system/query-db.ts" /** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet exposes intermediate streams */ // ---cut--- const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), createdAt: State.SQLite.datetime(), }, }), } as const const uiState$ = signal({ showCompleted: false }, { label: 'uiState$' }) const todos$ = queryDb(tables.todos.orderBy('createdAt', 'desc'), { label: 'todos$' }) { const todos$ = queryDb( (get) => { const { showCompleted } = get(uiState$) return tables.todos.where(showCompleted ? { completed: true } : {}) }, { label: 'todos$' }, ) } ``` ### Signals Signals are reactive state values that can be set and get. This can be useful for state that is not materialized from events into SQLite tables. ## `reference/reactivity-system/signals.ts` ```ts filename="reference/reactivity-system/signals.ts" declare const store: Store const now$ = signal(Date.now(), { label: 'now$' }) setInterval(() => { store.setSignal(now$, Date.now()) }, 1000) const num$ = signal(0, { label: 'num$' }) const increment = () => store.setSignal(num$, (prev) => prev + 1) increment() increment() console.log(store.query(num$)) ``` ### Computed values ## `reference/reactivity-system/computed.ts` ```ts filename="reference/reactivity-system/computed.ts" /** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet highlights derived signal */ // ---cut--- const num$ = signal(0, { label: 'num$' }) const duplicated$ = computed((get) => get(num$) * 2, { label: 'duplicated$' }) ``` ## Accessing reactive state Reactive state is always bound to a `Store` instance. You can access the current value of reactive state the following ways: ### Using the `Store` instance ## `reference/reactivity-system/store-access.ts` ```ts filename="reference/reactivity-system/store-access.ts" declare const store: Store const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), title: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), }, }), } as const const count$ = queryDb(tables.todos.count(), { label: 'count$' }) const count = store.query(count$) console.log(count) const unsubscribe = store.subscribe(count$, (value) => { console.log(value) }) unsubscribe() ``` ### Via framework integrations #### React ## `reference/reactivity-system/react-component.tsx` ```tsx filename="reference/reactivity-system/react-component.tsx" declare const state$: LiveQueryDef export const MyComponent: FC = () => { const value = useQuery(state$) return
{value}
} ``` #### Solid ## `reference/reactivity-system/solid-component.tsx` ```tsx filename="reference/reactivity-system/solid-component.tsx" declare const state$: LiveQueryDef export const MyComponent = () => { const value = query(state$, 0) return
{value()}
} ``` ### Reacting to changing variables passed to queries If your query depends on a variable passed in by the component, use the deps array to react to changes in this variable. ## `reference/reactivity-system/deps-query.tsx` ```tsx filename="reference/reactivity-system/deps-query.tsx" const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), }, }), } as const declare const store: Store & ReactApi export const todos$ = ({ showCompleted }: { showCompleted: boolean }) => queryDb( () => { return tables.todos.where(showCompleted ? { completed: true } : {}) }, { label: 'todos$', deps: [showCompleted ? 'true' : 'false'], }, ) export const MyComponent: FC<{ showCompleted: boolean }> = ({ showCompleted }) => { const todos = store.useQuery(todos$({ showCompleted })) as ReadonlyArray<{ id: string text: string completed: boolean }> return
{todos.length} Done
} ``` ## Further reading - [Riffle](https://riffle.systems/essays/prelude/): Building data-centric apps with a reactive relational database - [Adapton](http://adapton.org/) / [miniAdapton](https://arxiv.org/pdf/1609.05337) ## Related technologies - [Signia](https://signia.tldraw.dev/): Signia is a minimal, fast, and scalable signals library for TypeScript developed by TLDraw. # [Rules for AI agents](https://dev.docs.livestore.dev/building-with-livestore/rules-for-ai-agents/) ## AGENTS.md Add the content of this `AGENTS.md` file or other custom rules: ```md wrap - When you need to look up information about LiveStore, always use the `docs` directory that's shipped as part of the `@livestore/livestore` package in `node_modules/@livestore/livestore/docs`. ``` # [Syncing](https://dev.docs.livestore.dev/building-with-livestore/syncing/) ## How it works LiveStore is based on [the idea of event-sourcing](/understanding-livestore/event-sourcing) which means it syncs events across clients (via a central sync backend) and then materializes the events in the local SQLite database. This means LiveStore isn't syncing the SQLite database itself directly but only the events that are used to materialize the database making sure it's kept in sync across clients. The syncing mechanism is similar to how Git works in that regard that it's based on a "push/pull" model. Upstream events always need to be pulled before a client can push its own events to preserve a [global total order of events](https://medium.com/baseds/ordering-distributed-events-29c1dd9d1eff). Local pending events which haven't been pushed yet need to be rebased on top of the latest upstream events before they can be pushed. ## Events A LiveStore event consists of the following data: - `seqNum`: event sequence number - `parentSeqNum`: parent event sequence number - `name`: event name (refers to a event definition in the schema) - `args`: event arguments (encoded using the event's schema definition, usually JSON) ### Event sequence numbers - Event sequence numbers: monotonically increasing integers - client event sequence number to sync across client sessions (never exposed to the sync backend) ### Sync heads - The latest event in a eventlog is referred to as the "head" (similar to how Git refers to the latest commit as the "head"). - Given that LiveStore does hierarchical syncing between the client session, the client leader and the sync backend, there are three heads (i.e. the client session head, the client leader head, and the sync backend head). ## Sync backend The sync backend acts as the global authority and determines the total order of events ("causality"). It's responsible for storing and querying events and for notifying clients when new events are available. ### Requirements for sync backend - Needs to provide an efficient way to query an ordered list of events given a starting event ID (often referred to as cursor). - Ideally provides a "reactivity" mechanism to notify clients when new events are available (e.g. via WebSocket, HTTP long-polling, etc). - Alternatively, the client can periodically query for new events which is less efficient. ## Clients - Each client initially chooses a random `clientId` as its globally unique ID - LiveStore uses a 6-char nanoid - In the unlikely event of a collision which is detected by the sync backend the first time a client tries to push, the client chooses a new random `clientId`, patches the local events with the new `clientId`, and tries again. ### Client sessions - Each client has at least one client session - Client sessions within the same client share local data - In web adapters: multiple tabs/windows can be different sessions within the same client - Sessions are identified by a `sessionId` which can persist (e.g., across tab reloads in web) - For adapters which support multiple client sessions (e.g. web), LiveStore also supports local syncing across client sessions (e.g. across browser tabs or worker threads) - Client session events are not synced to the sync backend ## Auth (authentication & authorization) - TODO - Provide basic example - Encryption ## Advanced ### Sequence diagrams #### Pulling events (without unpushed events) ```d2 ...@../../../../src/content/base.d2 shape: sequence_diagram Client: { label: "Client" } SyncBackend: { label: "Sync Backend" } Client -> SyncBackend: "`pull` request\n(head_cursor)" SyncBackend -> SyncBackend: "Get new events\n(since head_cursor)" SyncBackend -> Client: "New events" Client -> Client: "Client is in sync" ``` #### Pushing events ```d2 ...@../../../../src/content/base.d2 shape: sequence_diagram Client: { label: "Client" } SyncBackend: { label: "Sync Backend" } Client -> Client: "Commit events" Client -> SyncBackend: "`push` request\n(new_local_events)" SyncBackend -> SyncBackend: "Validate & persist" SyncBackend -> Client: "Push success" Client -> Client: "Client is in sync" ``` ### Rebasing ### Merge conflicts - Merge conflict handling isn't implemented yet (see [this issue](https://github.com/livestorejs/livestore/issues/253)). - Merge conflict detection and resolution will be based on the upcoming [facts system functionality](https://github.com/livestorejs/livestore/issues/254). ### Compaction - Compaction isn't implemented yet (see [this issue](https://github.com/livestorejs/livestore/issues/136)) - Compaction will be based on the upcoming [facts system functionality](https://github.com/livestorejs/livestore/issues/254). ### Partitioning - Currently LiveStore assumes a 1:1 mapping between an eventlog and a SQLite database. - In the future, LiveStore aims to support multiple eventlogs (see [this issue](https://github.com/livestorejs/livestore/issues/255)). ## Design decisions / trade-offs - Require a central sync backend to enforce a global total order of events. - This means LiveStore can't be used in a fully decentralized/P2P manner. - Do rebasing on the client side (instead of on the sync backend). This allows the user to have more control over the rebase process. ## Notes - Rich text data is best handled via CRDTs (see [#263](https://github.com/livestorejs/livestore/issues/263)) ## Further reading - Distributed Systems lecture series by Martin Kleppmann: [YouTube playlist](https://www.youtube.com/playlist?list=PLeKd45zvjcDFUEv_ohr_HdUFe97RItdiB) / [lecture notes](https://www.cl.cam.ac.uk/teaching/2122/ConcDisSys/dist-sys-notes.pdf) # [Store](https://dev.docs.livestore.dev/building-with-livestore/store/) The `Store` is the most common way to interact with LiveStore from your application code. It provides a way to query data, commit events, and subscribe to data changes. ## Creating a store For how to create a store in React, see the [React integration docs](/framework-integrations/react-integration). The following example shows how to create a store manually: ## `reference/store/create-store.ts` ```ts filename="reference/store/create-store.ts" const adapter = makeAdapter({ storage: { type: 'fs' }, // sync: { backend: makeWsSync({ url: '...' }) }, }) export const bootstrap = async () => { const store = await createStorePromise({ schema, adapter, storeId: 'some-store-id', }) return store } ``` ### `reference/store/schema.ts` ```ts filename="reference/store/schema.ts" const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), }, }), } as const const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), } as const const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) => tables.todos.insert({ id, text, completed: false }), ), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) export const storeTables = tables export const storeEvents = events ``` ## Using a store ### Querying data ## `reference/store/query-data.ts` ```ts filename="reference/store/query-data.ts" /** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet shows query result */ // ---cut--- declare const store: Store const todos = store.query(storeTables.todos) console.log(todos) ``` ### Subscribing to data ## `reference/store/subscribe.ts` ```ts filename="reference/store/subscribe.ts" declare const store: Store const unsubscribe = store.subscribe(storeTables.todos, (todos) => { console.log(todos) }) unsubscribe() ``` ### Committing events ## `reference/store/commit-event.ts` ```ts filename="reference/store/commit-event.ts" declare const store: Store store.commit(storeEvents.todoCreated({ id: '1', text: 'Buy milk' })) ``` ### Streaming events Currently only events confirmed by the sync backend are supported. ## `reference/store/stream-events.ts` ```ts filename="reference/store/stream-events.ts" declare const store: Store // Run once for await (const event of store.events()) { console.log('event from leader', event) } // Continuos stream const iterator = store.events()[Symbol.asyncIterator]() try { while (true) { const { value, done } = await iterator.next() if (done) break console.log('event from stream:', value) } } finally { await iterator.return?.() } ``` ### Shutting down a store LiveStore provides two APIs for shutting down a store: ## `reference/store/shutdown.ts` ```ts filename="reference/store/shutdown.ts" /** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet demonstrates shutdown helpers */ // ---cut--- declare const store: Store const effectShutdown = Effect.gen(function* () { yield* Effect.log('Shutting down store') yield* store.shutdown() }) const shutdownWithPromise = async () => { await store.shutdownPromise() } ``` ## Effect integration For applications using [Effect](https://effect.website), LiveStore provides a type-safe way to access stores through the Effect layer system via `makeStoreContext()`. ### Creating a typed store context Use `makeStoreContext()` to create a typed context that preserves your schema types: ## `reference/store/effect/make-store-context.ts` ```ts filename="reference/store/effect/make-store-context.ts" // ---cut--- // Define a typed store context with your schema export const TodoStore = Store.Tag(schema, 'todos') // Create a layer to initialize the store const adapter = makeAdapter({ storage: { type: 'fs' } }) export const TodoStoreLayer = TodoStore.layer({ adapter, batchUpdates: (cb) => cb(), // For Node.js; use React's unstable_batchedUpdates in React apps }) ``` ### `reference/store/effect/schema.ts` ```ts filename="reference/store/effect/schema.ts" const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), }, }), } as const const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), todoCompleted: Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ id: Schema.String }), }), } as const const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) => tables.todos.insert({ id, text, completed: false }), ), [events.todoCompleted.name]: defineMaterializer(events.todoCompleted, ({ id }) => tables.todos.update({ completed: true }).where({ id }), ), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) export const storeTables = tables export const storeEvents = events ``` The factory takes your schema type as a generic parameter and returns a `StoreContext` with: - `Tag` - Context tag for dependency injection - `Layer` - Creates a layer that initializes the store - `DeferredTag` - For async initialization patterns - `DeferredLayer` - Layer providing the deferred context - `fromDeferred` - Layer that waits for deferred initialization ### Using the store in Effect services Access the store in Effect code with full type safety and autocomplete: ## `reference/store/effect/usage-in-service.ts` ```ts filename="reference/store/effect/usage-in-service.ts" // ---cut--- // Access the store in Effect code with full type safety const _todoService = Effect.gen(function* () { // Yield the store directly (it's a Context.Tag) const { store } = yield* TodoStore // Query with autocomplete for tables const todos = store.query(storeTables.todos.select()) // Commit events store.commit(storeEvents.todoCreated({ id: '1', text: 'Buy milk' })) return todos }) // Or use static accessors for a more functional style const _todoServiceAlt = Effect.gen(function* () { // Query using static accessor const todos = yield* TodoStore.query(storeTables.todos.select()) // Commit using static accessor yield* TodoStore.commit(storeEvents.todoCreated({ id: '1', text: 'Buy milk' })) return todos }) ``` ### Layer composition Compose store layers with your application services: ## `reference/store/effect/layer-composition.ts` ```ts filename="reference/store/effect/layer-composition.ts" // ---cut--- // Define services that depend on the store class TodoService extends Effect.Service()('TodoService', { effect: Effect.gen(function* () { const { store } = yield* TodoStore const createTodo = (id: string, text: string) => Effect.sync(() => store.commit(storeEvents.todoCreated({ id, text }))) const completeTodo = (id: string) => Effect.sync(() => store.commit(storeEvents.todoCompleted({ id }))) return { createTodo, completeTodo } as const }), dependencies: [TodoStoreLayer], }) {} // Compose everything into a main layer const MainLayer = Layer.mergeAll(TodoStoreLayer, TodoService.Default) // Use in your application const program = Effect.gen(function* () { const todoService = yield* TodoService yield* todoService.createTodo('1', 'Learn Effect') yield* todoService.completeTodo('1') }) // Provide MainLayer when running (OtelTracer is also required) void program.pipe(Effect.provide(MainLayer)) ``` ### Multiple stores with Effect Each store gets a unique context tag, allowing multiple stores in the same Effect context: ## `reference/store/effect/multiple-stores.ts` ```ts filename="reference/store/effect/multiple-stores.ts" // For demonstration, we'll use the same schema for both stores const settingsSchema = mainSchema // ---cut--- // Define multiple typed store contexts const MainStore = Store.Tag(mainSchema, 'main') const SettingsStore = Store.Tag(settingsSchema, 'settings') // Each store has its own layer const adapter = makeAdapter({ storage: { type: 'fs' } }) const MainStoreLayer = MainStore.layer({ adapter, batchUpdates: (cb) => cb() }) const SettingsStoreLayer = SettingsStore.layer({ adapter, batchUpdates: (cb) => cb() }) // Compose layers together const _AllStoresLayer = Layer.mergeAll(MainStoreLayer, SettingsStoreLayer) // Both stores available in Effect code const _program = Effect.gen(function* () { const { store: mainStore } = yield* MainStore const { store: settingsStore } = yield* SettingsStore // Each store is independently typed return { mainStore, settingsStore } }) ``` For more Effect patterns including Effect Atom integration, see the [Effect patterns](/patterns/effect) page. ## Multiple stores You can create and use multiple stores in the same app. This can be useful when breaking up your data model into smaller pieces. ## Development/debugging helpers A store instance also exposes a `_dev` property that contains some helpful methods for development. For convenience you can access a store on `globalThis`/`window` like via `__debugLiveStore.default._dev` (`default` is the store id): ```ts // Download the SQLite database __debugLiveStore.default._dev.downloadDb() // Download the eventlog database __debugLiveStore.default._dev.downloadEventlogDb() // Reset the store __debugLiveStore.default._dev.hardReset() // See the current sync state __debugLiveStore.default._dev.syncStates() ``` # [Custom elements](https://dev.docs.livestore.dev/framework-integrations/custom-elements/) LiveStore can be used with custom elements/web components. ## Example See [examples](/examples) for a complete example. ## `reference/custom-elements/main.ts` ```ts filename="reference/custom-elements/main.ts" const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker, }) const store = await createStorePromise({ schema, adapter, storeId: 'custom-elements-demo' }) const visibleTodos$ = queryDb(tables.todos.where({ deletedAt: null })) class TodoListElement extends HTMLElement { private list: HTMLUListElement private input: HTMLInputElement constructor() { super() const shadow = this.attachShadow({ mode: 'open' }) this.input = document.createElement('input') this.input.placeholder = 'What needs to be done?' this.list = document.createElement('ul') this.list.style.listStyle = 'none' this.list.style.padding = '0' this.list.style.margin = '16px 0 0' this.input.addEventListener('keydown', (event) => { if (event.key === 'Enter' && this.input.value.trim()) { store.commit(events.todoCreated({ id: crypto.randomUUID(), text: this.input.value.trim() })) this.input.value = '' } }) shadow.append(this.input, this.list) } connectedCallback(): void { this.renderTodos(Array.from(store.query(tables.todos.where({ deletedAt: null })))) store.subscribe(visibleTodos$, (todos) => this.renderTodos(todos)) } private renderTodos(todos: ReadonlyArray): void { const nodes = Array.from(todos, (todo) => { const item = document.createElement('li') item.textContent = todo.text item.style.cursor = 'pointer' item.addEventListener('click', () => { store.commit(todo.completed ? events.todoUncompleted({ id: todo.id }) : events.todoCompleted({ id: todo.id })) }) const deleteButton = document.createElement('button') deleteButton.type = 'button' deleteButton.textContent = '✕' deleteButton.style.marginLeft = '8px' deleteButton.addEventListener('click', (event) => { event.stopPropagation() store.commit(events.todoDeleted({ id: todo.id, deletedAt: new Date() })) }) const row = document.createElement('div') row.style.display = 'flex' row.style.alignItems = 'center' row.appendChild(item) row.appendChild(deleteButton) const wrapper = document.createElement('li') wrapper.appendChild(row) return wrapper }) this.list.replaceChildren(...nodes) } } customElements.define('todo-list', TodoListElement) ``` ### `reference/custom-elements/livestore/schema.ts` ```ts filename="reference/custom-elements/livestore/schema.ts" export const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text({ default: '' }), completed: State.SQLite.boolean({ default: false }), deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }), }, }), uiState: State.SQLite.clientDocument({ name: 'uiState', schema: Schema.Struct({ newTodoText: Schema.String }), default: { id: SessionIdSymbol, value: { newTodoText: '' } }, }), } export const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), todoCompleted: Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ id: Schema.String }), }), todoUncompleted: Events.synced({ name: 'v1.TodoUncompleted', schema: Schema.Struct({ id: Schema.String }), }), todoDeleted: Events.synced({ name: 'v1.TodoDeleted', schema: Schema.Struct({ id: Schema.String, deletedAt: Schema.Date }), }), uiStateSet: tables.uiState.set, } const materializers = State.SQLite.materializers(events, { 'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text, completed: false }), 'v1.TodoCompleted': ({ id }) => tables.todos.update({ completed: true }).where({ id }), 'v1.TodoUncompleted': ({ id }) => tables.todos.update({ completed: false }).where({ id }), 'v1.TodoDeleted': ({ id, deletedAt }) => tables.todos.update({ deletedAt }).where({ id }), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) ``` # [React integration for LiveStore](https://dev.docs.livestore.dev/framework-integrations/react-integration/) While LiveStore is framework agnostic, the `@livestore/react` package provides a first-class integration with React. ## Features - High performance - Fine-grained reactivity (using LiveStore's signals-based reactivity system) - Instant, synchronous query results (without the need for `useEffect` and `isLoading` checks) - Supports multiple store instances - Transactional state transitions (via `batchUpdates`) - Also supports Expo / React Native via `@livestore/adapter-expo` ## Core Concepts When using LiveStore in React, you'll primarily interact with these fundamental components: - [**`StoreRegistry`**](#new-storeregistryconfig) - Manages store instances with automatic caching and disposal - [**``**](#storeregistryprovider) - React context provider that supplies the registry to components - [**`useStore()`**](#usestoreoptions) - Suspense-enabled hook for accessing store instances Stores are cached by their `storeId` and automatically disposed after being unused for a configurable duration (`unusedCacheTime`). ## `reference/framework-integrations/react/multi-store/minimal.tsx` ```tsx filename="reference/framework-integrations/react/multi-store/minimal.tsx" const issueStoreOptions = (issueId: string) => storeOptions({ storeId: `issue-${issueId}`, schema, adapter: makeInMemoryAdapter(), }) export function App() { const [storeRegistry] = useState(() => new StoreRegistry({ defaultOptions: { batchUpdates } })) return ( ) } function IssueView() { const store = useStore(issueStoreOptions('abc123')) const [issue] = store.useQuery(queryDb(tables.issue.select())) return
{issue?.title}
} ``` ### `reference/framework-integrations/react/multi-store/schema.ts` ```ts filename="reference/framework-integrations/react/multi-store/schema.ts" // Event definitions export const events = { issueCreated: Events.synced({ name: 'v1.IssueCreated', schema: Schema.Struct({ id: Schema.String, title: Schema.String, status: Schema.Literal('todo', 'done'), }), }), issueStatusChanged: Events.synced({ name: 'v1.IssueStatusChanged', schema: Schema.Struct({ id: Schema.String, status: Schema.Literal('todo', 'done'), }), }), } // State definition export const tables = { issue: State.SQLite.table({ name: 'issue', columns: { id: State.SQLite.text({ primaryKey: true }), title: State.SQLite.text(), status: State.SQLite.text(), }, }), } const materializers = State.SQLite.materializers(events, { 'v1.IssueCreated': ({ id, title, status }) => tables.issue.insert({ id, title, status }), 'v1.IssueStatusChanged': ({ id, status }) => tables.issue.update({ status }).where({ id }), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) ``` ## Setting Up ### 1. Configure the Store Create a store configuration file that exports a custom hook wrapping [`useStore()`](#usestoreoptions): ## `reference/framework-integrations/react/store.ts` ```ts filename="reference/framework-integrations/react/store.ts" const adapter = makeInMemoryAdapter() export const useAppStore = () => useStore({ storeId: 'app-root', schema, adapter, batchUpdates, }) ``` The [`useStore()`](#usestoreoptions) hook accepts store configuration options and returns a store instance. It suspends while the store is loading, so components using it need to be wrapped in a `Suspense` boundary. ### 2. Set Up the Registry Create a [`StoreRegistry`](#new-storeregistryconfig) and provide it via [``](#storeregistryprovider). Wrap in a `` to handle loading states and a `` to handle errors: ## `reference/framework-integrations/react/provider.tsx` ```tsx filename="reference/framework-integrations/react/provider.tsx" export const Root: FC<{ children: ReactNode }> = ({ children }) => { const [storeRegistry] = useState(() => new StoreRegistry()) return ( Something went wrong}> Loading LiveStore...}> {children} ) } ``` ### 3. Use the Store Components can now access the store via your custom hook: ## `reference/framework-integrations/react/use-store.tsx` ```tsx filename="reference/framework-integrations/react/use-store.tsx" export const MyComponent: FC = () => { const store = useAppStore() useEffect(() => { store.commit(events.todoCreated({ id: '1', text: 'Hello, world!' })) }, [store]) return
...
} ``` ## Querying Data Use [`store.useQuery()`](#storeusequeryqueryable) to subscribe to reactive queries: ## `reference/framework-integrations/react/use-query.tsx` ```tsx filename="reference/framework-integrations/react/use-query.tsx" const query$ = queryDb(tables.todos.where({ completed: true }).orderBy('id', 'desc'), { label: 'completedTodos', }) export const CompletedTodos: FC = () => { const store = useAppStore() const todos = store.useQuery(query$) return (
{todos.map((todo) => (
{todo.text}
))}
) } ``` ## Client Documents Use [`store.useClientDocument()`](#storeuseclientdocumenttable-id-options) for client-specific state: ## `reference/framework-integrations/react/use-client-document.tsx` ```tsx filename="reference/framework-integrations/react/use-client-document.tsx" export const TodoItem: FC<{ id: string }> = ({ id }) => { const store = useAppStore() const [todo, updateTodo] = store.useClientDocument(tables.uiState, id) return ( ) } ``` ## Advanced Patterns ### Multiple Stores You can have multiple stores within a single React application. This is useful for: - **Partial data synchronization** - Load only the data you need, when you need it - **Multi-tenant applications** - Separate stores for each workspace, organization, or team (like Slack workspaces or Linear teams) Use the [`storeOptions()`](#storeoptionsoptions) helper for type-safe, reusable configurations, and then create multiple instances for the same store configuration by using different `storeId` values: ## `reference/framework-integrations/react/multi-store/store.ts` ```ts filename="reference/framework-integrations/react/multi-store/store.ts" // Define reusable store configuration with storeOptions() // This helper provides type safety and can be reused across your app export const issueStoreOptions = (issueId: string) => storeOptions({ storeId: `issue-${issueId}`, schema, adapter: makeInMemoryAdapter(), }) ``` ## `reference/framework-integrations/react/multi-store/IssueView.tsx` ```tsx filename="reference/framework-integrations/react/multi-store/IssueView.tsx" export function IssueView({ issueId }: { issueId: string }) { // useStore() suspends the component until the store is loaded // If the same store was already loaded, it returns immediately const issueStore = useStore(issueStoreOptions(issueId)) // Query data from the store const [issue] = issueStore.useQuery(queryDb(tables.issue.select().where({ id: issueId }))) if (!issue) return
Issue not found
return (

{issue.title}

Status: {issue.status}

) } // Wrap with Suspense and ErrorBoundary for loading and error states export function IssueViewWithSuspense({ issueId }: { issueId: string }) { return ( Error loading issue}> Loading issue...}> ) } ``` Each store instance is completely isolated with its own data, event log, and synchronization state. ### Preloading Stores When you know a store will be needed soon, preload it in advance to warm up the cache: ## `reference/framework-integrations/react/multi-store/PreloadedIssue.tsx` ```tsx filename="reference/framework-integrations/react/multi-store/PreloadedIssue.tsx" export function PreloadedIssue({ issueId }: { issueId: string }) { const [showIssue, setShowIssue] = useState(false) const storeRegistry = useStoreRegistry() // Preload the store when the user hovers (before they click) const handleMouseEnter = () => { storeRegistry.preload({ ...issueStoreOptions(issueId), unusedCacheTime: 10_000, // Optionally override options }) } return (
{!showIssue ? ( ) : ( Error loading issue
}> Loading issue...}>
)} ) } ``` ### StoreId Guidelines When creating `storeId` values: - **Valid characters** - Only alphanumeric characters, underscores (`_`), and hyphens (`-`) are allowed (regex: `/^[a-zA-Z0-9_-]+$/`) - **Globally unique** - Prefer globally unique IDs (e.g., nanoid) to prevent collisions - **Use namespaces** - Prefix with the entity type (e.g., `workspace-abc123`, `issue-456`) to avoid collisions and easier identification when debugging - **Keep them stable** - The same entity should always use the same `storeId` across renders - **Sanitize user input** - If incorporating user data, validate/sanitize to prevent injection attacks - **Document your conventions** - Document special IDs like `user-current` as they're part of your API contract ### Logging You can customize the logger and log level for debugging: ## `reference/framework-integrations/react/store-with-logging.ts` ```ts filename="reference/framework-integrations/react/store-with-logging.ts" const adapter = makeInMemoryAdapter() // ---cut--- export const useAppStore = () => useStore({ storeId: 'app-root', schema, adapter, batchUpdates, // Optional: swap the logger implementation logger: Logger.prettyWithThread('app'), // Optional: set minimum log level (use LogLevel.None to disable) logLevel: LogLevel.Info, }) ``` Use `LogLevel.None` to disable logging entirely. ## API Reference ### `storeOptions(options)` Helper for defining reusable store options with full type inference. Returns options that can be passed to [`useStore()`](#usestoreoptions) or [`storeRegistry.preload()`](#storeregistrypreloadoptions). Options: - `storeId` - Unique identifier for this store instance - `schema` - The LiveStore schema - `adapter` - The platform adapter - `unusedCacheTime?` - Time in ms to keep unused stores in cache (default: `60_000` in browser, `Infinity` in non-browser environments) - `batchUpdates?` - Function for batching React updates (recommended) - `boot?` - Function called when the store is loaded - `onBootStatus?` - Callback for boot status updates - `context?` - User-defined context for dependency injection - `syncPayload?` - Payload sent to sync backend (e.g., auth tokens) - `syncPayloadSchema?` - Schema for type-safe sync payload validation - `confirmUnsavedChanges?` - Register beforeunload handler (default: `true`, web only) - `logger?` - Custom logger implementation - `logLevel?` - Log level (e.g., `LogLevel.Info`, `LogLevel.Debug`, `LogLevel.None`) - `otelOptions?` - OpenTelemetry configuration (`{ tracer, rootSpanContext }`) - `disableDevtools?` - Whether to disable devtools (`boolean | 'auto'`, default: `'auto'`) - `debug?` - Debug options (`{ instanceId?: string }`) ### `useStore(options)` Returns a store instance augmented with hooks ([`store.useQuery()`](#storeusequeryqueryable) and [`store.useClientDocument()`](#storeuseclientdocumenttable-id-options)) for reactive queries. - Suspends until the store is loaded. - Throws an error if loading fails. - Store gets cached by its `storeId` in the [`StoreRegistry`](#new-storeregistryconfig). Multiple calls with the same `storeId` return the same store instance. - Store is cached as long as it's being used, and after `unusedCacheTime` expires (default `60_000` ms in browser, `Infinity` in non-browser) - Default store options can be configured in [`StoreRegistry`](#new-storeregistryconfig) constructor. - Store options are only applied when the store is loaded. Subsequent calls with different options will not affect the store if it's already loaded and cached in the registry. ### `store.commit(...events)` / `store.commit(txnFn)` Commits events to the store. Supports multiple calling patterns: - `store.commit(...events)` - Commit one or more events - `store.commit(txnFn)` - Commit events via a transaction function - `store.commit(options, ...events)` - Commit with options - `store.commit(options, txnFn)` - Options with transaction function Options: - `skipRefresh` - Skip refreshing reactive queries after commit (advanced) ### `store.useQuery(queryable)` Subscribes to a reactive query. Re-renders the component when the result changes. - Takes any `Queryable`: `QueryBuilder`, `LiveQueryDef`, `SignalDef`, or `LiveQuery` instance - Returns the query result ### `store.useClientDocument(table, id?, options?)` React.useState-like hook for client-document tables. - Returns `[row, setRow, id, query$]` tuple - Works only with tables defined via `State.SQLite.clientDocument()` - If the table has a default id, the `id` argument is optional ### `new StoreRegistry(config?)` Creates a registry that coordinates store loading, caching, and retention. Config: - `defaultOptions?` - Default options that are applied to all stores when they are loaded.: - `batchUpdates?` - Function for batching React updates - `unusedCacheTime?` - Cache time for unused stores - `disableDevtools?` - Whether to disable devtools - `confirmUnsavedChanges?` - beforeunload confirmation - `otelOptions?` - OpenTelemetry configuration - `debug?` - Debug options - `runtime?` - Effect runtime for registry operations ### `` React context provider that makes a [`StoreRegistry`](#new-storeregistryconfig) available to descendant components. Props: - `storeRegistry` - The registry instance ### `useStoreRegistry(override?)` Hook that returns the [`StoreRegistry`](#new-storeregistryconfig) provided by the nearest [``](#storeregistryprovider) ancestor, or the `override` if provided. ### `storeRegistry.preload(options)` Loads a store (without suspending) to warm up the cache. Returns a Promise that resolves when loading completes. This is a fire-and-forget operation useful for warming up the cache. ## Framework-Specific Notes ### Vite LiveStore works with Vite out of the box. ### Tanstack Start LiveStore works with Tanstack Start out of the box. #### Provider Placement When using LiveStore with TanStack Start, place [``](#storeregistryprovider) in the correct location to avoid remounting on navigation. :::caution **Do NOT place `` inside `shellComponent`**. The `shellComponent` can be re-rendered on navigation, causing LiveStore to remount and show the loading screen on every page transition. ::: Use the `component` prop on `createRootRoute` for ``: ```tsx export const Route = createRootRoute({ shellComponent: RootShell, // HTML structure only - NO state or providers component: RootComponent, // App shell - StoreRegistryProvider goes HERE }) // HTML document shell - keep this stateless function RootShell({ children }: { children: React.ReactNode }) { return ( {children} ) } // App shell - persists across SPA navigation function RootComponent() { const [storeRegistry] = useState(() => new StoreRegistry()) return ( Loading LiveStore...}> ) } ``` TanStack Start's `shellComponent` is designed for SSR HTML streaming and may be re-evaluated on server requests during navigation. When `` is placed there, the WebSocket connection is re-established and all LiveStore state is re-initialized on each navigation. If you see the loading screen on every navigation, check your server logs for multiple "Launching WebSocket" messages. ### Expo / React Native LiveStore has a first-class integration with Expo / React Native via `@livestore/adapter-expo`. See the [Expo Adapter documentation](/platform-adapters/expo-adapter). ### Next.js Given various Next.js limitations, LiveStore doesn't yet work with Next.js out of the box. ### Complete Example See the Multi-Store example for a complete working application demonstrating various multi-store patterns. ## Technical Notes - `@livestore/react` uses `React.useState()` under the hood for `useQuery()` / `useClientDocument()` to bind LiveStore's reactivity to React's reactivity. Some libraries use `React.useSyncExternalStore()` for similar purposes but `React.useState()` is more efficient for LiveStore's architecture. - `@livestore/react` supports React Strict Mode. # [Svelte integration](https://dev.docs.livestore.dev/framework-integrations/svelte-integration/) LiveStore's Svelte bindings expose a single helper, `createStore`, which binds `store.query` into Svelte's reactivity. When you call `store.query` inside markup or `$effect`, query results automatically re-run when LiveStore emits updates, and requests are cancelled on teardown via Svelte's abort signal. ## Example ## `reference/framework-integrations/svelte/create-store.svelte` ```svelte filename="reference/framework-integrations/svelte/create-store.svelte"
    {#each store.query(todos$) as todo (todo.id)}
  • {todo.text}
  • {/each}
``` ## Usage notes - `createStore` is async; instantiate it where you have access to the adapter (e.g. route load, `onMount`, or a top-level module when running in the browser). - `store.query` opts into Svelte reactivity when called inside `$effect` or markup. Outside reactive contexts, you can still use `store.subscribe` directly. - Works with the Web adapter out of the box. For SSR routes, construct the store on the client since `@livestore/adapter-web` is browser-only. See the Svelte TodoMVC example for a complete app. # [Vue integration for LiveStore](https://dev.docs.livestore.dev/framework-integrations/vue-integration/) The [vue-livestore](https://github.com/slashv/vue-livestore) package provides integration with Vue. It's currently in beta but aims to match feature parity with the React integration. ## API ### `` In order to use LiveStore with Vue, you need to wrap your application in a ``. ## `reference/framework-integrations/vue/provider.vue` ```vue filename="reference/framework-integrations/vue/provider.vue" ``` ### `reference/framework-integrations/vue/schema.ts` ```ts filename="reference/framework-integrations/vue/schema.ts" export { events, schema, tables } from '../react/schema.ts' ``` ### useStore ## `reference/framework-integrations/vue/use-store.ts` ```ts filename="reference/framework-integrations/vue/use-store.ts" export const createTodo = () => { const { store } = useStore() store.commit(events.todoCreated({ id: crypto.randomUUID(), text: 'Eat broccoli' })) } ``` ### useQuery ## `reference/framework-integrations/vue/use-query.vue` ```vue filename="reference/framework-integrations/vue/use-query.vue" ``` ### useClientDocument **[!] The interface for useClientDocument is experimental and might change** Since it's more common in Vue to work with a single writable ref (as compared to state, setState in React) the useClientDocument composable for Vue tries to make that easier by directly returning a collection of refs. The current implementation destructures all client state variables into the return object which allows directly binding to v-model or editing the .value reactivly. ## `reference/framework-integrations/vue/use-client-document.vue` ```vue filename="reference/framework-integrations/vue/use-client-document.vue" ``` ## Usage with ... ### Vite LiveStore and vue-livestore works with Vite out of the box. ### Nuxt.js Works out of the box with Nuxt if SSR is disabled by just wrapping the main content in a LiveStoreProvider. Example repo upcoming. ## Technical notes - Vue-livestore uses the provider component pattern similar to the React integration. In Vue the plugin pattern is more common but it isn't clear that that's the most suitable structure for LiveStore in Vue. We might switch to the plugin pattern if we later find that more suitable especially with regards to Nuxt support and supporting multiple stores. # [Solid integration](https://dev.docs.livestore.dev/framework-integrations/solid-integration/) ## Example See [examples](/examples) for a complete example. ## `reference/solid-integration/livestore/store.ts` ```ts filename="reference/solid-integration/livestore/store.ts" const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker, }) export const store = await getStore({ adapter, schema, storeId: 'default', }) ``` ### `reference/solid-integration/livestore/schema.ts` ```ts filename="reference/solid-integration/livestore/schema.ts" export const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text({ default: '' }), completed: State.SQLite.boolean({ default: false }), deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }), }, }), uiState: State.SQLite.clientDocument({ name: 'uiState', schema: Schema.Struct({ newTodoText: Schema.String, filter: Schema.Literal('all', 'active', 'completed') }), default: { id: SessionIdSymbol, value: { newTodoText: '', filter: 'all' } }, }), } export const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), todoCompleted: Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ id: Schema.String }), }), todoUncompleted: Events.synced({ name: 'v1.TodoUncompleted', schema: Schema.Struct({ id: Schema.String }), }), todoDeleted: Events.synced({ name: 'v1.TodoDeleted', schema: Schema.Struct({ id: Schema.String, deletedAt: Schema.Date }), }), todoClearedCompleted: Events.synced({ name: 'v1.TodoClearedCompleted', schema: Schema.Struct({ deletedAt: Schema.Date }), }), uiStateSet: tables.uiState.set, } const materializers = State.SQLite.materializers(events, { 'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text, completed: false }), 'v1.TodoCompleted': ({ id }) => tables.todos.update({ completed: true }).where({ id }), 'v1.TodoUncompleted': ({ id }) => tables.todos.update({ completed: false }).where({ id }), 'v1.TodoDeleted': ({ id, deletedAt }) => tables.todos.update({ deletedAt }).where({ id }), 'v1.TodoClearedCompleted': ({ deletedAt }) => tables.todos.update({ deletedAt }).where({ completed: true }), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) ``` ## `reference/solid-integration/MainSection.tsx` ```tsx filename="reference/solid-integration/MainSection.tsx" /** biome-ignore-all lint/a11y/noLabelWithoutControl: TODO 🫠 */ /** @jsxImportSource solid-js */ export const MainSection: Component = () => { const todos = query(visibleTodos$, [] as (typeof tables.todos.Type)[]) const todoItems = () => todos() ?? ([] as (typeof tables.todos.Type)[]) const toggleTodo = ({ id, completed }: typeof tables.todos.Type) => store()?.commit(completed ? events.todoUncompleted({ id }) : events.todoCompleted({ id })) const deleteTodo = (id: string) => store()?.commit(events.todoDeleted({ id, deletedAt: new Date() })) return (
    {(todo: typeof tables.todos.Type) => (
  • toggleTodo(todo)} />
  • )}
) } ``` ### `reference/solid-integration/livestore/queries.ts` ```ts filename="reference/solid-integration/livestore/queries.ts" export const uiState$ = queryDb(tables.uiState.get(), { label: 'uiState' }) export const visibleTodos$ = queryDb( (get) => { const { filter } = get(uiState$) return tables.todos.where({ deletedAt: null, completed: filter === 'all' ? undefined : filter === 'completed', }) }, { label: 'visibleTodos' }, ) ``` ### Logging You can control logging for Solid's runtime helpers via optional options passed to `getStore`: ## `reference/solid-integration/store-logging.ts` ```ts filename="reference/solid-integration/store-logging.ts" // ---cut--- const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker, }) const _store = await getStore({ schema, adapter, storeId: 'default', // Optional: swap logger and minimum log level logger: Logger.prettyWithThread('window'), logLevel: LogLevel.Info, // use LogLevel.None to disable logs }) ``` # [Expo](https://dev.docs.livestore.dev/getting-started/expo/) export const CODE = { babelConfig: babelConfigCode, metroConfig: metroConfigCode, } {/* We're adjusting the package to use the dev version on the dev branch */} export const manualInstallDepsStr = [ '@livestore/devtools-expo' + versionNpmSuffix, '@livestore/adapter-expo' + versionNpmSuffix, '@livestore/livestore' + versionNpmSuffix, '@livestore/react' + versionNpmSuffix, '@livestore/sync-cf/client' + versionNpmSuffix, '@livestore/peer-deps' + versionNpmSuffix, 'expo-sqlite', ].join(' ') ### Prerequisites - Recommended: Bun 1.2 or higher - Node.js {MIN_NODE_VERSION} or higher To use [LiveStore](/) with [Expo](https://docs.expo.dev/), ensure your project has the [New Architecture](https://docs.expo.dev/guides/new-architecture/) enabled. This is required for transactional state updates. ### Option A: Quick start For a quick start we recommend using our template app following the steps below. For existing projects see [Existing project setup](#existing-project-setup). 1. **Set up project from template** Replace `livestore-app` with your desired app name. 2. **Install dependencies** It's strongly recommended to use `bun` or `pnpm` for the simplest and most reliable dependency setup (see [note on package management](/misc/package-management) for more details). ```bash bun install ``` ```bash pnpm install --node-linker=hoisted ``` Make sure to use `--node-linker=hoisted` when installing dependencies in your project or add it to your `.npmrc` file. ``` # .npmrc nodeLinker=hoisted ``` Hopefully Expo will also support non-hoisted setups in the future. ```bash npm install ``` When using `yarn`, make sure you're using Yarn 4 or higher with the `node-modules` linker. ```bash yarn set version stable yarn config set nodeLinker node-modules yarn install ``` Pro tip: You can use [direnv](https://direnv.net/) to manage environment variables. 3. **Run the app** In a new terminal, start the Cloudflare Worker (for the sync backend): ### Option B: Existing project setup \{#existing-project-setup\} 1. **Install dependencies** 2. **Add Vite meta plugin to babel config file** LiveStore Devtools uses Vite. This plugin emulates Vite's `import.meta.env` functionality. In your `babel.config.js` file, add the plugin as follows: 3. **Update Metro config** Add the following code to your `metro.config.js` file: ## Define your schema Create a file named `schema.ts` inside the `src/livestore` folder. This file defines your LiveStore schema consisting of your app's event definitions (describing how data changes), derived state (i.e. SQLite tables), and materializers (how state is derived from events). Here's an example schema: ## `getting-started/expo/livestore/schema.ts` ```ts filename="getting-started/expo/livestore/schema.ts" export const tables = { todos: State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text({ default: '' }), completed: State.SQLite.boolean({ default: false }), deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }), }, }), uiState: State.SQLite.clientDocument({ name: 'uiState', schema: Schema.Struct({ newTodoText: Schema.String, filter: Schema.Literal('all', 'active', 'completed') }), default: { id: SessionIdSymbol, value: { newTodoText: '', filter: 'all' } }, }), } export const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), todoCompleted: Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ id: Schema.String }), }), todoUncompleted: Events.synced({ name: 'v1.TodoUncompleted', schema: Schema.Struct({ id: Schema.String }), }), todoDeleted: Events.synced({ name: 'v1.TodoDeleted', schema: Schema.Struct({ id: Schema.String, deletedAt: Schema.Date }), }), todoClearedCompleted: Events.synced({ name: 'v1.TodoClearedCompleted', schema: Schema.Struct({ deletedAt: Schema.Date }), }), uiStateSet: tables.uiState.set, } const materializers = State.SQLite.materializers(events, { 'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text, completed: false }), 'v1.TodoCompleted': ({ id }) => tables.todos.update({ completed: true }).where({ id }), 'v1.TodoUncompleted': ({ id }) => tables.todos.update({ completed: false }).where({ id }), 'v1.TodoDeleted': ({ id, deletedAt }) => tables.todos.update({ deletedAt }).where({ id }), 'v1.TodoClearedCompleted': ({ deletedAt }) => tables.todos.update({ deletedAt }).where({ completed: true }), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) ``` ## Configure the store Create a `store.ts` file in the `src/livestore` folder. This file configures the store adapter and exports a custom hook that components will use to access the store. The `useStore()` hook accepts store configuration options (schema, adapter, store ID) and returns a store instance. It suspends while the store is loading, so make sure to use a `Suspense` boundary to handle the loading state. ## `getting-started/expo/livestore/store.ts` ```ts filename="getting-started/expo/livestore/store.ts" const syncUrl = 'https://example.org/sync' const adapter = makePersistedAdapter({ sync: { backend: makeWsSync({ url: syncUrl }) }, }) export const useAppStore = () => useStore({ storeId: 'expo-todomvc', schema, adapter, batchUpdates, boot: (store) => { if (store.query(tables.todos.count()) === 0) { store.commit(events.todoCreated({ id: crypto.randomUUID(), text: 'Make coffee' })) } }, }) ``` ## Set up the store registry To enable store management throughout your app, create a `StoreRegistry` and provide it with a ``. The registry manages store instance lifecycles (loading, caching, disposal). Wrap the provider in a `Suspense` boundary to handle the loading state for when the store is loading. ## `getting-started/expo/Root.tsx` ```tsx filename="getting-started/expo/Root.tsx" const AppContent: FC = () => ( ) export const Root: FC = () => { const [storeRegistry] = useState(() => new StoreRegistry()) return ( Loading LiveStore...}> ) } ``` ### `getting-started/expo/components/ListTodos.tsx` ```tsx filename="getting-started/expo/components/ListTodos.tsx" export const ListTodos: FC = () => { const store = useAppStore() const todos = store.useQuery(visibleTodos$) const toggleTodo = useCallback( ({ id, completed }: typeof tables.todos.Type) => { store.commit(completed ? events.todoUncompleted({ id }) : events.todoCompleted({ id })) }, [store], ) const clearCompleted = () => store.commit(events.todoClearedCompleted({ deletedAt: new Date() })) return ( {todos.map((todo) => ( {todo.text} {todo.completed ? 'Completed' : 'Pending'}