This is the full developer documentation for LiveStore. ## Notes - Most LiveStore APIs are synchronous and don't need `await` # Start of LiveStore documentation # [API reference](https://main--livestore-docs-dev.netlify.app/api/) The API reference is autogenerated from the source code. # [Complex UI state](https://main--livestore-docs-dev.netlify.app/building-with-livestore/complex-ui-state/) LiveStore is a great fit for building apps with complex UI state. # [CRUD](https://main--livestore-docs-dev.netlify.app/building-with-livestore/crud/) ## CRUD # [Data modeling](https://main--livestore-docs-dev.netlify.app/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. # [Debugging a LiveStore app](https://main--livestore-docs-dev.netlify.app/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 # [Devtools](https://main--livestore-docs-dev.netlify.app/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. # [Events](https://main--livestore-docs-dev.netlify.app/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/main/packages/@livestore/common/src/schema/LiveStoreEvent) and [`EventSequenceNumber`](https://github.com/livestorejs/livestore/tree/main/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+) # [AI agent](https://main--livestore-docs-dev.netlify.app/building-with-livestore/examples/ai-agent/) LiveStore is a great fit for building AI agents. TODO: actually write this section # [Todo app with shared workspaces](https://main--livestore-docs-dev.netlify.app/building-with-livestore/examples/todo-workspaces/) Let's consider a fairly common application scenario: An app (in this case a todo app) with shared workspaces. For the sake of this guide, we'll keep things simple but you should be able to nicely extend this to a more complex app. ## Requirements - There are multiple independent todo workspaces - Each workspace is initially created by a single user - Users can join the workspace by knowing the workspace id and get read and write access - For simplicity, the user identity is chosen when the app initially starts (i.e. a username) but in a real app this would be handled by a proper auth setup ## Data model - We are splitting up our data model into two kinds of stores (with respective eventlogs and SQLite databases): The `workspace` store and the `user` store. ### `workspace` store (one per workspace) For the `workspace` store we have the following events: - `workspaceCreated` - `todoAdded` - `todoCompleted` - `todoDeleted` - `userJoined` And the following state model: - `workspace` table (with a single row for the workspace itself) - `todo` table (with one row per todo item) - `member` table (with one row per user who has joined the workspace) ### `user` store (one per user) For the `user` store we have the following events: - `workspaceCreated` - `workspaceJoined` And the following state model: - `user` table (with a single row for the user itself) Note that the `workspaceCreated` event is used both in the `workspace` and the `user` store. This is because each eventlog should be "self-sufficient" and not rely on other eventlogs to be present to fulfill its purpose. ## Schemas **User store:** ## `data-modeling/todo-workspaces/user.schema.ts` ```ts filename="data-modeling/todo-workspaces/user.schema.ts" // Emitted when this user creates a new workspace const workspaceCreated = Events.synced({ name: 'v1.WorkspaceCreated', schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }), }) // Emitted when this user joins an existing workspace const workspaceJoined = Events.synced({ name: 'v1.WorkspaceJoined', schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }), }) export const userEvents = { workspaceCreated, workspaceJoined } // Table to store basic user info // Contains only one row as this store is per-user. const userTable = State.SQLite.table({ name: 'user', columns: { // Assuming username is unique and used as the identifier username: State.SQLite.text({ primaryKey: true }), }, }) // Table to track which workspaces this user is part of const userWorkspacesTable = State.SQLite.table({ name: 'userWorkspaces', columns: { workspaceId: State.SQLite.text({ primaryKey: true }), name: State.SQLite.text(), // Could add role/permissions here later }, }) export const userTables = { user: userTable, userWorkspaces: userWorkspacesTable } const materializers = State.SQLite.materializers(userEvents, { // When the user creates or joins a workspace, add it to their workspace table 'v1.WorkspaceCreated': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }), 'v1.WorkspaceJoined': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }), }) const state = State.SQLite.makeState({ tables: userTables, materializers }) export const schema = makeSchema({ events: userEvents, state }) ``` **Workspace store:** ## `data-modeling/todo-workspaces/workspace.schema.ts` ```ts filename="data-modeling/todo-workspaces/workspace.schema.ts" // Emitted when a new workspace is created (originates this store) const workspaceCreated = Events.synced({ name: 'v1.WorkspaceCreated', schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String, createdByUsername: Schema.String, }), }) // Emitted when a todo item is added to this workspace const todoAdded = Events.synced({ name: 'v1.TodoAdded', schema: Schema.Struct({ todoId: Schema.String, text: Schema.String }), }) // Emitted when a todo item is marked as completed const todoCompleted = Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ todoId: Schema.String }), }) // Emitted when a todo item is deleted (soft delete) const todoDeleted = Events.synced({ name: 'v1.TodoDeleted', schema: Schema.Struct({ todoId: Schema.String, deletedAt: Schema.Date }), }) // Emitted when a new user joins this workspace const userJoined = Events.synced({ name: 'v1.UserJoined', schema: Schema.Struct({ username: Schema.String }), }) export const workspaceEvents = { workspaceCreated, todoAdded, todoCompleted, todoDeleted, userJoined } // Table for the workspace itself (only one row as this store is per-workspace) const workspaceTable = State.SQLite.table({ name: 'workspace', columns: { workspaceId: State.SQLite.text({ primaryKey: true }), name: State.SQLite.text(), createdByUsername: State.SQLite.text(), }, }) // Table for the todo items in this workspace const todosTable = State.SQLite.table({ name: 'todos', columns: { todoId: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), // Using soft delete by adding a deletedAt timestamp deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }), }, }) // Table for members of this workspace const membersTable = State.SQLite.table({ name: 'members', columns: { username: State.SQLite.text({ primaryKey: true }), // Could add role/permissions here later }, }) export const workspaceTables = { workspace: workspaceTable, todos: todosTable, members: membersTable } const materializers = State.SQLite.materializers(workspaceEvents, { 'v1.WorkspaceCreated': ({ workspaceId, name, createdByUsername }) => [ workspaceTables.workspace.insert({ workspaceId, name, createdByUsername }), // Add the creator as the first member workspaceTables.members.insert({ username: createdByUsername }), ], 'v1.TodoAdded': ({ todoId, text }) => workspaceTables.todos.insert({ todoId, text }), 'v1.TodoCompleted': ({ todoId }) => workspaceTables.todos.update({ completed: true }).where({ todoId }), 'v1.TodoDeleted': ({ todoId, deletedAt }) => workspaceTables.todos.update({ deletedAt }).where({ todoId }), 'v1.UserJoined': ({ username }) => workspaceTables.members.insert({ username }), }) const state = State.SQLite.makeState({ tables: workspaceTables, materializers }) export const schema = makeSchema({ events: workspaceEvents, state }) ``` ## Store configuration Now that we've defined our schemas, let's configure the stores: **Workspace store:** ## `data-modeling/todo-workspaces/workspace.store.ts` ```ts filename="data-modeling/todo-workspaces/workspace.store.ts" const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker, sharedWorker, }) // Define workspace store configuration // Each workspace gets its own isolated store instance export const workspaceStoreOptions = (workspaceId: string) => storeOptions({ storeId: `workspace-${workspaceId}`, schema, adapter, unusedCacheTime: 60_000, // Keep in memory for 60 seconds after last use }) ``` **User store:** ## `data-modeling/todo-workspaces/user.store.ts` ```ts filename="data-modeling/todo-workspaces/user.store.ts" const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker, sharedWorker, }) // Hook to access the current user's store export const useCurrentUserStore = () => useStore({ storeId: 'user-current', // Backend should resolve this to the authenticated user's store schema, adapter, unusedCacheTime: Number.POSITIVE_INFINITY, // Keep user store in memory indefinitely }) ``` ## Set up the store registry Create a [`StoreRegistry`](/framework-integrations/react-integration#new-storeregistryconfig) and provide it to your React app: ## `data-modeling/todo-workspaces/App.tsx` ```tsx filename="data-modeling/todo-workspaces/App.tsx" export const App = ({ children }: { children: ReactNode }) => { const [storeRegistry] = useState( () => new StoreRegistry({ defaultOptions: { batchUpdates, }, }), ) return {children} } ``` ## Accessing stores Use the [`useStore()`](/framework-integrations/react-integration#usestoreoptions) hook to access specific workspace instances: ## `data-modeling/todo-workspaces/Workspace.tsx` ```tsx filename="data-modeling/todo-workspaces/Workspace.tsx" // Component that accesses a specific workspace store export const Workspace = ({ workspaceId }: { workspaceId: string }) => { const userStore = useCurrentUserStore() const workspaceStore = useStore(workspaceStoreOptions(workspaceId)) // Check if this workspace exists in user's workspace list const [knownWorkspace] = userStore.useQuery(queryDb(userTables.userWorkspaces.select().where({ workspaceId }))) // Query workspace data const [workspace] = workspaceStore.useQuery(queryDb(workspaceTables.workspace.select().limit(1))) const todos = workspaceStore.useQuery(queryDb(workspaceTables.todos.select())) // Workspace not in user's list → truly doesn't exist if (knownWorkspace == null) return
Workspace not found
// Workspace is in user's list but not yet initialized → loading state if (workspace == null) return
Loading workspace...
const addTodo = useCallback( (text: string) => { workspaceStore.commit( workspaceEvents.todoAdded({ todoId: `todo-${Date.now()}`, text, }), ) }, [workspaceStore], ) const addNewTodo = useCallback(() => addTodo('New todo'), [addTodo]) return (

{workspace.name}

Created by: {workspace.createdByUsername}

Store ID: {workspaceStore.storeId}

Todos ({todos.length})

    {todos.map((todo) => (
  • {todo.text} {todo.completed === true ? '✓' : ''}
  • ))}
) } ``` ## Loading multiple workspaces To display all workspaces for a user, first load the user store to get their workspace list, then dynamically load each workspace: ## `data-modeling/todo-workspaces/WorkspaceList.tsx` ```tsx filename="data-modeling/todo-workspaces/WorkspaceList.tsx" const workspaceListErrorFallback =
Error loading workspaces
const workspaceListLoadingFallback =
Loading workspaces...
export const WorkspaceList = () => { const userStore = useCurrentUserStore() // Query all workspaces this user belongs to const workspaces = userStore.useQuery(queryDb(userTables.userWorkspaces.select())) return (

My Workspaces

{workspaces.length === 0 ? (

No workspaces yet

) : (
    {workspaces.map((w) => (
  • ))}
)}
) } ``` ## Creating new workspaces To create a new workspace, you commit the `workspaceCreated` event to the user store: ## `data-modeling/todo-workspaces/CreateWorkspace.tsx` ```tsx filename="data-modeling/todo-workspaces/CreateWorkspace.tsx" // Component for creating a new workspace export const CreateWorkspace = () => { const userStore = useCurrentUserStore() const navigate = useNavigate() const createStore = useCallback( (formData: FormData) => { const name = formData.get('name') as string if (name.trim() === '') return const workspaceId = nanoid() userStore.commit(userEvents.workspaceCreated({ workspaceId, name })) navigate({ to: '/workspace/$workspaceId', params: { workspaceId } }) }, [navigate, userStore], ) return (
) } ``` Your backend should react to this event and create the corresponding workspace store. The exact mechanism for how the backend creates the workspace store depends on your infrastructure. For an implementation example using Cloudflare Durable Objects, check out the `web-email-client` example. ### Handling the loading state There's a timing consideration: the browser might connect to the workspace store before it's created and initialized with data. When this happens, [`useStore()`](/framework-integrations/react-integration#usestoreoptions) resolves (the store exists), but queries may return empty results. To distinguish between "still loading" and "workspace doesn't exist", check the user store first. Notice how the `` component [above](#accessing-stores) queries `userTables.userWorkspaces` before checking the workspace data: - If the workspace is not in the user's list → truly doesn't exist - If the workspace is in the user's list, but workspace data is empty → loading state :::note[Future improvement] We're working on improving this experience. See [#822](https://github.com/livestorejs/livestore/issues/822) for the discussion. ::: ## Further notes To make this app more production-ready, we might want to do the following: - Use a proper auth setup to enforce a trusted user identity - Introduce a proper user invite process - Introduce access levels (e.g. read-only, read-write) - Introduce end-to-end encryption ### Individual todo stores for complex data If each todo item has a lot of data (e.g. think of a GitHub/Linear issue with lots of details), it might make sense to split up each todo item into its own store. This would create **3 store types** instead of 2: - **User stores** (one per user) - unchanged - **Workspace stores** (one per workspace) - only basic todo metadata - **Todo stores** (one per todo item) - rich todo data Your app would then have **N + M + K stores** total (N workspaces + M users + K todo items). This pattern improves performance by only loading detailed todo data when specifically viewing that item, and prevents large todos from slowing down workspace syncing. # [Turn-based game](https://main--livestore-docs-dev.netlify.app/building-with-livestore/examples/turnbased-game/) LiveStore is a great fit for turn-based games. In this guide we'll look at a simple turn-based game and how to model it in LiveStore. General idea: Let server enforce the logic that each player only commits one action per turn. TODO: write rest of guide # [OpenTelemetry](https://main--livestore-docs-dev.netlify.app/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 const provider = new WebTracerProvider({ spanProcessors: url !== undefined ? [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() const suspenseFallback =
Loading...
// ---cut--- const useAppStore = () => useStore({ storeId: 'otel-demo', schema, adapter, batchUpdates, otelOptions: { tracer }, }) export const App: FC = () => { const [storeRegistry] = useState(() => new StoreRegistry()) return ( ) } 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 }), createdAt: State.SQLite.datetime(), }, }), 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' } }, }), } as const export const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String, createdAt: Schema.Date }), }), } as const const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text, createdAt }) => tables.todos.insert({ id, text, completed: false, createdAt }), ), }) 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://main--livestore-docs-dev.netlify.app/building-with-livestore/production-checklist/) ## Production checklist TBD # [Reactivity system](https://main--livestore-docs-dev.netlify.app/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" // ---cut--- 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 === true ? { 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" // ---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 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" const todos$ = queryDb(tables.todos.orderBy('createdAt', 'desc'), { label: 'todos' }) export const TodoList: FC = () => { const store = useAppStore() const todos = store.useQuery(todos$) return
{todos.length} items
} ``` ### `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, }) ``` #### Solid ## `reference/reactivity-system/solid-component.tsx` ```tsx filename="reference/reactivity-system/solid-component.tsx" declare const store: Store & { useQuery: (query: LiveQueryDef) => () => T } declare const state$: LiveQueryDef export const MyComponent = () => { const value = store.useQuery(state$) 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" export const todos$ = ({ showCompleted }: { showCompleted: boolean }) => queryDb( () => { return tables.todos.where(showCompleted === true ? { completed: true } : {}) }, { label: 'todos$', deps: [showCompleted === true ? 'true' : 'false'], }, ) export const MyComponent: FC<{ showCompleted: boolean }> = ({ showCompleted }) => { const store = useAppStore() 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://main--livestore-docs-dev.netlify.app/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`. ``` # [Materializers](https://main--livestore-docs-dev.netlify.app/building-with-livestore/state/materializers/) Materializers are functions that allow you to write to your database in response to events. Materializers are executed in the order of the events in the eventlog. ## Example ## `reference/state/materializers/example.ts` ```ts filename="reference/state/materializers/example.ts" export const todos = State.SQLite.table({ name: 'todos', columns: { id: State.SQLite.text({ primaryKey: true }), text: State.SQLite.text(), completed: State.SQLite.boolean({ default: false }), previousIds: State.SQLite.json({ schema: Schema.Array(Schema.String), nullable: true, }), }, }) export const table1 = State.SQLite.table({ name: 'settings', columns: { id: State.SQLite.text({ primaryKey: true }), someVal: State.SQLite.integer({ default: 0 }), }, }) export const table2 = State.SQLite.table({ name: 'preferences', columns: { id: State.SQLite.text({ primaryKey: true }), otherVal: State.SQLite.text({ default: 'default' }), }, }) export const events = { todoCreated: Events.synced({ name: 'todoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String, completed: Schema.Boolean.pipe(Schema.optional), }), }), userPreferencesUpdated: Events.synced({ name: 'userPreferencesUpdated', schema: Schema.Struct({ userId: Schema.String, theme: Schema.String }), }), factoryResetApplied: Events.synced({ name: 'factoryResetApplied', schema: Schema.Struct({}), }), } as const export const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text, completed }) => todos.insert({ id, text, completed: completed ?? false }), ), [events.userPreferencesUpdated.name]: defineMaterializer(events.userPreferencesUpdated, ({ userId, theme }) => { console.log(`User ${userId} updated theme to ${theme}.`) return [] }), [events.factoryResetApplied.name]: defineMaterializer(events.factoryResetApplied, () => [ table1.update({ someVal: 0 }), table2.update({ otherVal: 'default' }), // Raw SQL is also supported via { sql, bindValues } { sql: 'DELETE FROM todos', bindValues: {} }, ]), }) ``` ## Reading from the database in materializers Sometimes it can be useful to query your current state when executing a materializer. This can be done by using `ctx.query` in your materializer function. ## `reference/state/materializers/with-query.ts` ```ts filename="reference/state/materializers/with-query.ts" const events = { todoCreated: Events.synced({ name: 'todoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String, completed: Schema.Boolean.pipe(Schema.optional), }), }), } as const export const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text, completed }, ctx) => { const previousIds = ctx.query(todos.select('id')) // ctx.query also supports raw SQL via { query, bindValues } const existingTodos = ctx.query({ query: 'SELECT id FROM todos', bindValues: {} }) return todos.insert({ id: `${existingTodos.length}-${id}`, text, completed: completed ?? false, previousIds }) }), }) ``` ## Transactional behaviour A materializer is always executed in a transaction. This transaction applies to: - All database write operations returned by the materializer. - Any `ctx.query` calls made within the materializer, ensuring a consistent view of the data. Materializers can return: - A single database write operation. - An array of database write operations. - `void` (i.e., no return value) if no database modifications are needed. - An `Effect` that resolves to one of the above (e.g., `Effect.succeed(writeOp)` or `Effect.void`). The `context` object passed to each materializer provides `query` for database reads and `event` for the full event details. ## Error handling If a materializer function throws an error, or if an `Effect` returned by a materializer fails, the entire transaction for that event will be rolled back. This means any database changes attempted by that materializer for the failing event will not be persisted. The error will be logged, and the system will typically halt or flag the event as problematic, depending on the specific LiveStore setup. If the error happens on the client which tries to commit the event, the event will never be committed and pushed to the sync backend. In the future there will be ways to configure the error-handling behaviour, e.g. to allow skipping an incoming event when a materializer fails in order to avoid the app getting stuck. However, skipping events might also lead to diverging state across clients and should be used with caution. ## Best practices ### Side-effect free / deterministic It's strongly recommended to make sure your materializers are side-effect free and deterministic. This also implies passing in all necessary data via the event payload. Example: ## `reference/state/materializers/deterministic.ts` ```ts filename="reference/state/materializers/deterministic.ts" declare const store: Store export const nondeterministicEvents = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ text: Schema.String }), }), } as const export const nondeterministicMaterializers = State.SQLite.materializers(nondeterministicEvents, { [nondeterministicEvents.todoCreated.name]: defineMaterializer(nondeterministicEvents.todoCreated, ({ text }) => todos.insert({ id: randomUUID(), text }), ), }) store.commit(nondeterministicEvents.todoCreated({ text: 'Buy groceries' })) export const deterministicEvents = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }), } as const export const deterministicMaterializers = State.SQLite.materializers(deterministicEvents, { [deterministicEvents.todoCreated.name]: defineMaterializer(deterministicEvents.todoCreated, ({ id, text }) => todos.insert({ id, text }), ), }) store.commit(deterministicEvents.todoCreated({ id: nanoid(), text: 'Buy groceries' })) ``` # [SQL queries](https://main--livestore-docs-dev.netlify.app/building-with-livestore/state/sql-queries/) ## Query builder LiveStore also provides a small query builder for the most common queries. The query builder automatically derives the appropriate result schema internally. ## `reference/state/sql-queries/query-builder.ts` ```ts filename="reference/state/sql-queries/query-builder.ts" const table = State.SQLite.table({ name: 'my_table', columns: { id: State.SQLite.text({ primaryKey: true }), name: State.SQLite.text(), tags: State.SQLite.json({ schema: Schema.Array(Schema.String), default: [] }), }, }) // Read queries table.select('name') table.where('name', '=', 'Alice') table.where({ name: 'Alice' }) table.orderBy('name', 'desc').offset(10).limit(10) table.count().where('name', 'LIKE', '%Ali%') // JSON array containment queries // NOTE: These use SQLite's json_each() which cannot be indexed table.where({ tags: { op: 'JSON_CONTAINS', value: 'important' } }) table.where({ tags: { op: 'JSON_NOT_CONTAINS', value: 'archived' } }) // Write queries table.insert({ id: '123', name: 'Bob' }) table.update({ name: 'Alice' }).where({ id: '123' }) table.delete().where({ id: '123' }) // Upserts (insert or update on conflict) table.insert({ id: '123', name: 'Charlie' }).onConflict('id', 'replace') table.insert({ id: '456', name: 'Diana' }).onConflict('id', 'update', { name: 'Diana Updated' }) ``` ## Raw SQL queries LiveStore supports arbitrary SQL queries on top of SQLite. In order for LiveStore to handle the query results correctly, you need to provide the result schema. ## `reference/state/sql-queries/raw-sql.ts` ```ts filename="reference/state/sql-queries/raw-sql.ts" // ---cut--- const table = State.SQLite.table({ name: 'my_table', columns: { id: State.SQLite.text({ primaryKey: true }), name: State.SQLite.text(), }, }) const filtered$ = queryDb({ query: sql`select * from my_table where name = 'Alice'`, schema: Schema.Array(table.rowSchema), }) const count$ = queryDb({ query: sql`select count(*) as count from my_table`, schema: Schema.Struct({ count: Schema.Number }).pipe(Schema.pluck('count'), Schema.Array, Schema.headOrElse()), }) ``` ## JSON array containment For JSON array columns, you can use `JSON_CONTAINS` and `JSON_NOT_CONTAINS` operators to check if an array contains (or doesn't contain) a specific value: ```ts // Find items with a specific tag table.where({ tags: { op: 'JSON_CONTAINS', value: 'important' } }) // Find items without a specific tag table.where({ tags: { op: 'JSON_NOT_CONTAINS', value: 'archived' } }) ``` :::caution[Performance consideration] These operators use SQLite's `json_each()` table-valued function which **cannot be indexed** and requires a full table scan. For large tables with frequent lookups, consider denormalizing the data into a separate indexed table. ::: ## Best practices - Query results should be treated as immutable/read-only - For queries which could return many rows, it's recommended to paginate the results - Usually both via paginated/virtualized rendering as well as paginated queries - You'll get best query performance by using a `WHERE` clause over an indexed column combined with a `LIMIT` clause. Avoid `OFFSET` as it can be slow on large tables - For very large/complex queries, it can also make sense to implement incremental view maintenance (IVM) for your queries - You can for example do this by have a separate table which is a materialized version of your query results which you update manually (and ideally incrementally) as the underlying data changes. # [SQLite in LiveStore](https://main--livestore-docs-dev.netlify.app/building-with-livestore/state/sqlite/) LiveStore heavily uses SQLite as its default state/read model. ## Implementation notes - LiveStore relies on the following SQLite extensions to be available: `-DSQLITE_ENABLE_BYTECODE_VTAB -DSQLITE_ENABLE_SESSION -DSQLITE_ENABLE_PREUPDATE_HOOK` - [bytecode](https://www.sqlite.org/bytecodevtab.html) - [session](https://www.sqlite.org/sessionintro.html) (incl. preupdate) - For web / node adapter: - LiveStore uses [a fork](https://github.com/livestorejs/wa-sqlite) of the [wa-sqlite](https://github.com/rhashimoto/wa-sqlite) SQLite WASM library. - Write‑ahead logging (WAL) is currently not supported/enabled for the web adapter using OPFS (AccessHandlePoolVFS). The underlying VFS does not support WAL reliably in this setup; we disable it until it’s safe to use. See our tracking issue and upstream notes: - LiveStore: https://github.com/livestorejs/livestore/issues/258 - wa‑sqlite examples (comparison table shows WAL unsupported for AccessHandlePoolVFS): https://github.com/rhashimoto/wa-sqlite/blob/master/src/examples/README.md - Related discussion on single‑connection OPFS and locking: https://github.com/rhashimoto/wa-sqlite/discussions/81 - In the future LiveStore might use a non‑WASM build for Node/Bun/Deno/etc. - For Expo adapter: - LiveStore uses the official expo-sqlite library which supports LiveStore's SQLite requirements. - LiveStore uses the `session` extension to enable efficient database rollback which is needed when the eventlog is rolled back as part of a rebase. An alternative implementation strategy would be to rely on snapshotting (i.e. periodically create database snapshots and roll back to the latest snapshot + applied missing mutations). ## Default tables LiveStore operates two SQLite databases by default: a state database (your materialized tables) and an event log database (the immutable event stream and sync metadata). In addition to your own application tables, LiveStore creates a small set of internal tables in each database. ### State database - `__livestore_schema` - Tracks the schema hash and last update time per materialized table. Used for migrations and compatibility checks. - `__livestore_schema_event_defs` - Tracks the schema hash and last update time per event definition. Used to detect incompatible event schema changes during rematerialization. - `__livestore_session_changeset` - Stores SQLite session changeset blobs keyed by event sequence numbers. Used to efficiently roll back and re‑apply state during rebases. - Your application tables - All tables you define via `State.SQLite.table(...)` live in the state database. ### Eventlog database - `eventlog` - Append‑only table containing all events (sequence numbers, parent links, event name, encoded args, client/session IDs, schema hash, optional sync metadata). Used to reconstruct state and for sync. - `__livestore_sync_status` - Stores the current head and optional backend identity for synchronization bookkeeping. Note: The event log database’s use of SQLite is an implementation detail. It is not a public interface and is not intended for direct reads or writes. Query state via your materialized tables and LiveStore APIs; do not depend on the event log database layout or mutate it directly. # [SQLite state schema](https://main--livestore-docs-dev.netlify.app/building-with-livestore/state/sqlite-schema/) LiveStore provides a schema definition language for defining your database tables and mutation definitions using explicit column configurations. LiveStore automatically migrates your database schema when you change your schema definitions. > **Alternative Approach**: You can also define tables using [Effect Schema with annotations](/building-with-livestore/state/sqlite-schema-effect) for type-safe schema definitions. ### Example ## `reference/state/sqlite-schema/schema.ts` ```ts filename="reference/state/sqlite-schema/schema.ts" // You can model your state as SQLite tables (https://docs.livestore.dev/reference/state/sqlite-schema) 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 }), }, }), // Client documents can be used for local-only state (e.g. form inputs) 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' } }, }), } // Events describe data changes (https://docs.livestore.dev/reference/events) 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, } // Materializers are used to map events to state (https://docs.livestore.dev/reference/state/materializers) 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 }) ``` ## Defining tables Define SQLite tables using explicit column definitions: ## `reference/state/sqlite-schema/columns/table-basic.ts` ```ts filename="reference/state/sqlite-schema/columns/table-basic.ts" export const userTable = State.SQLite.table({ name: 'users', columns: { id: State.SQLite.text({ primaryKey: true }), email: State.SQLite.text(), name: State.SQLite.text(), age: State.SQLite.integer({ default: 0 }), isActive: State.SQLite.boolean({ default: true }), metadata: State.SQLite.json({ nullable: true }), }, indexes: [{ name: 'idx_users_email', columns: ['email'], isUnique: true }], }) ``` Use the optional `indexes` array to declare secondary indexes or enforce uniqueness (set `isUnique: true`). ## Column types You can use these column types when defining tables: ### Core SQLite column types - `State.SQLite.text`: A text field, returns `string`. - `State.SQLite.integer`: An integer field, returns `number`. - `State.SQLite.real`: A real field (floating point number), returns `number`. - `State.SQLite.blob`: A blob field (binary data), returns `Uint8Array`. ### Higher level column types - `State.SQLite.boolean`: An integer field that stores `0` for `false` and `1` for `true` and returns a `boolean`. - `State.SQLite.json`: A text field that stores a stringified JSON object and returns a decoded JSON value. - `State.SQLite.datetime`: A text field that stores dates as ISO 8601 strings and returns a `Date`. - `State.SQLite.datetimeInteger`: A integer field that stores dates as the number of milliseconds since the epoch and returns a `Date`. ### Custom column schemas You can also provide a custom schema for a column which is used to automatically encode and decode the column value. ### Example: JSON-encoded struct ## `reference/state/sqlite-schema/columns/json-struct.ts` ```ts filename="reference/state/sqlite-schema/columns/json-struct.ts" export const UserMetadata = Schema.Struct({ petName: Schema.String, favoriteColor: Schema.Literal('red', 'blue', 'green'), }) export const userTable = State.SQLite.table({ name: 'user', columns: { id: State.SQLite.text({ primaryKey: true }), name: State.SQLite.text(), metadata: State.SQLite.json({ schema: UserMetadata }), }, }) ``` ### Schema migrations Migration strategies: - `auto`: Automatically migrate the database to the newest schema and rematerializes the state from the eventlog. - `manual`: Manually migrate the database to the newest schema. ## Client documents - Meant for convenience - Client-only - Goal: Similar ease of use as `React.useState()` - When schema changes in a non-backwards compatible way, previous events are dropped and the state is reset - Don't use client documents for sensitive data which must not be lost - Implies - Table with `id` and `value` columns - `${MyTable}Set` event + materializer (which are auto-registered) ### Basic usage ## `reference/state/sqlite-schema/columns/client-document-basic.tsx` ```tsx filename="reference/state/sqlite-schema/columns/client-document-basic.tsx" export const readUiState = (store: Store): { newTodoText: string; filter: 'all' | 'active' | 'completed' } => store.query(tables.uiState.get()) export const setNewTodoText = (store: Store, newTodoText: string): void => { store.commit(tables.uiState.set({ newTodoText })) } export const UiStateFilter: React.FC = () => { const store = useAppStore() const [state, setState] = store.useClientDocument(tables.uiState) const showActive = React.useCallback(() => { setState({ filter: 'active' }) }, [setState]) const showAll = React.useCallback(() => { setState({ filter: 'all' }) }, [setState]) return (
) } ``` ### KV-style client document Sometimes you want a simple key-value store for arbitrary values without partial merging. You can model this by using `Schema.Any` as the value schema. With `Schema.Any`, updates fully replace the stored value (no partial merge semantics). ## `reference/state/sqlite-schema/columns/client-document-kv.tsx` ```tsx filename="reference/state/sqlite-schema/columns/client-document-kv.tsx" export const kv = State.SQLite.clientDocument({ name: 'Kv', schema: Schema.Any, default: { value: null }, }) export const readKvValue = (store: Store, id: string): unknown => store.query(kv.get(id)) export const setKvValue = (store: Store, id: string, value: unknown): void => { store.commit(kv.set(value, id)) } export const KvViewer: FC<{ id: string }> = ({ id }) => { const store = useAppStore() const [value, setValue] = store.useClientDocument(kv, id) const handleClick = useCallback(() => { setValue('hello') }, [setValue]) return ( ) } ``` ## Column types You can use these column types: #### Core SQLite column types - `State.SQLite.text`: A text field, returns `string`. - `State.SQLite.integer`: An integer field, returns `number`. - `State.SQLite.real`: A real field (floating point number), returns `number`. - `State.SQLite.blob`: A blob field (binary data), returns `Uint8Array`. #### Higher level column types - `State.SQLite.boolean`: An integer field that stores `0` for `false` and `1` for `true` and returns a `boolean`. - `State.SQLite.json`: A text field that stores a stringified JSON object and returns a decoded JSON value. - `State.SQLite.datetime`: A text field that stores dates as ISO 8601 strings and returns a `Date`. - `State.SQLite.datetimeInteger`: A integer field that stores dates as the number of milliseconds since the epoch and returns a `Date`. #### Custom column schemas You can also provide a custom schema for a column which is used to automatically encode and decode the column value. #### Example: JSON-encoded struct ## Best practices ### Column configuration - Use appropriate SQLite column types for your data (text, integer, real, blob) - Set `primaryKey: true` for primary key columns - Use `nullable: true` for columns that can contain NULL values - Provide meaningful `default` values where appropriate - Add unique constraints via table `indexes` using `isUnique: true` ### Schema design - Choose column types that match your data requirements - Use custom schemas with `State.SQLite.json()` for complex data structures - Group related table definitions in the same module - Use descriptive table and column names ### General practices - It's usually recommend to **not distinguish** between app state vs app data but rather keep all state in LiveStore. - This means you'll rarely use `React.useState()` when using LiveStore - In some cases for "fast changing values" it can make sense to keep a version of a state value outside of LiveStore with a reactive setter for React and a debounced setter for LiveStore to avoid excessive LiveStore mutations. Cases where this can make sense can include: - Text input / rich text editing - Scroll position tracking, resize events, move/drag events - ... # [SQLite state schema (Effect schema)](https://main--livestore-docs-dev.netlify.app/building-with-livestore/state/sqlite-schema-effect/) LiveStore supports defining SQLite tables using Effect Schema with annotations for database constraints. This approach provides strong type safety, composability, and automatic type mapping from TypeScript to SQLite. > **Note**: This approach will become the default once Effect Schema v4 is released. See [livestore#382](https://github.com/livestorejs/livestore/issues/382) for details. > > For the traditional column-based approach, see [SQLite State Schema](/building-with-livestore/state/sqlite-schema). ## Basic usage Define tables using Effect Schema with database constraint annotations: ## `reference/state/sqlite-schema/effect/basic.ts` ```ts filename="reference/state/sqlite-schema/effect/basic.ts" const UserSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), email: Schema.String.pipe(State.SQLite.withUnique), name: Schema.String, age: Schema.Int.pipe(State.SQLite.withDefault(0)), isActive: Schema.Boolean.pipe(State.SQLite.withDefault(true)), metadata: Schema.optional( Schema.Record({ key: Schema.String, value: Schema.Unknown, }), ), }).annotations({ title: 'users' }) export const userTable = State.SQLite.table({ schema: UserSchema }) ``` ## Schema annotations You can annotate schema fields with database constraints: ### Primary keys ## `reference/state/sqlite-schema/effect/primary-key.ts` ```ts filename="reference/state/sqlite-schema/effect/primary-key.ts" const _schema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), // Other fields... }) ``` **Important**: Primary key columns cannot be nullable. This will throw an error: ## `reference/state/sqlite-schema/effect/primary-key-nullable.ts` ```ts filename="reference/state/sqlite-schema/effect/primary-key-nullable.ts" // ❌ This will throw an error at runtime because primary keys cannot be nullable const _badSchema = Schema.Struct({ id: Schema.NullOr(Schema.String).pipe(State.SQLite.withPrimaryKey), }) ``` ### Auto-Increment ## `reference/state/sqlite-schema/effect/auto-increment.ts` ```ts filename="reference/state/sqlite-schema/effect/auto-increment.ts" const _schema = Schema.Struct({ id: Schema.Int.pipe(State.SQLite.withPrimaryKey, State.SQLite.withAutoIncrement), // Other fields... }) ``` ### Default values ## `reference/state/sqlite-schema/effect/default-values.ts` ```ts filename="reference/state/sqlite-schema/effect/default-values.ts" const _schema = Schema.Struct({ status: Schema.String.pipe(State.SQLite.withDefault('active')), createdAt: Schema.String.pipe(State.SQLite.withDefault('CURRENT_TIMESTAMP')), count: Schema.Int.pipe(State.SQLite.withDefault(0)), }) ``` ### Unique constraints ## `reference/state/sqlite-schema/effect/unique-constraints.ts` ```ts filename="reference/state/sqlite-schema/effect/unique-constraints.ts" const _schema = Schema.Struct({ email: Schema.String.pipe(State.SQLite.withUnique), username: Schema.String.pipe(State.SQLite.withUnique), }) ``` Unique annotations automatically create unique indexes. ### Custom column types Override the automatically inferred SQLite column type: ## `reference/state/sqlite-schema/effect/custom-column-types.ts` ```ts filename="reference/state/sqlite-schema/effect/custom-column-types.ts" const _schema = Schema.Struct({ // Store a number as text instead of real version: Schema.Number.pipe(State.SQLite.withColumnType('text')), // Store binary data as blob data: Schema.Uint8Array.pipe(State.SQLite.withColumnType('blob')), }) ``` ### Combining annotations Annotations can be chained together: ## `reference/state/sqlite-schema/effect/combining-annotations.ts` ```ts filename="reference/state/sqlite-schema/effect/combining-annotations.ts" const _schema = Schema.Struct({ id: Schema.Int.pipe(State.SQLite.withPrimaryKey, State.SQLite.withAutoIncrement), email: Schema.String.pipe(State.SQLite.withUnique, State.SQLite.withColumnType('text')), }) ``` ## Table naming You can specify table names in several ways: ### Using schema annotations ## `reference/state/sqlite-schema/effect/table-name-annotations.ts` ```ts filename="reference/state/sqlite-schema/effect/table-name-annotations.ts" // Using title annotation const UserSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), name: Schema.String, }).annotations({ title: 'users' }) export const userTable = State.SQLite.table({ schema: UserSchema }) // Using identifier annotation const PostSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), title: Schema.String, }).annotations({ identifier: 'posts' }) export const postTable = State.SQLite.table({ schema: PostSchema }) ``` ### Explicit name ## `reference/state/sqlite-schema/effect/table-name-explicit.ts` ```ts filename="reference/state/sqlite-schema/effect/table-name-explicit.ts" const UserSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), name: Schema.String, }) export const userTable = State.SQLite.table({ name: 'users', schema: UserSchema, }) ``` **Note**: Title annotation takes precedence over identifier annotation. ## Type mapping Effect Schema types are automatically mapped to SQLite column types: | Schema Type | SQLite Type | TypeScript Type | |-------------|-------------|-----------------| | `Schema.String` | `text` | `string` | | `Schema.Number` | `real` | `number` | | `Schema.Int` | `integer` | `number` | | `Schema.Boolean` | `integer` | `boolean` | | `Schema.Date` | `text` | `Date` | | `Schema.BigInt` | `text` | `bigint` | | Complex types (Struct, Array, etc.) | `text` (JSON encoded) | Decoded type | | `Schema.optional(T)` | Nullable column | `T \| undefined` | | `Schema.NullOr(T)` | Nullable column | `T \| null` | ## Advanced examples ### Complex schema with multiple constraints ## `reference/state/sqlite-schema/effect/advanced-product.ts` ```ts filename="reference/state/sqlite-schema/effect/advanced-product.ts" const ProductSchema = Schema.Struct({ id: Schema.Int.pipe(State.SQLite.withPrimaryKey, State.SQLite.withAutoIncrement), sku: Schema.String.pipe(State.SQLite.withUnique), name: Schema.String, price: Schema.Number.pipe(State.SQLite.withDefault(0)), category: Schema.Literal('electronics', 'clothing', 'books'), metadata: Schema.optional( Schema.Struct({ weight: Schema.Number, dimensions: Schema.Struct({ width: Schema.Number, height: Schema.Number, depth: Schema.Number, }), }), ), isActive: Schema.Boolean.pipe(State.SQLite.withDefault(true)), createdAt: Schema.Date.pipe(State.SQLite.withDefault('CURRENT_TIMESTAMP')), }).annotations({ title: 'products' }) export const productTable = State.SQLite.table({ schema: ProductSchema }) ``` ### Working with Schema.Class ## `reference/state/sqlite-schema/effect/schema-class.ts` ```ts filename="reference/state/sqlite-schema/effect/schema-class.ts" class User extends Schema.Class('User')({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), email: Schema.String.pipe(State.SQLite.withUnique), name: Schema.String, age: Schema.Int, }) {} export const userTable = State.SQLite.table({ name: 'users', schema: User, }) ``` ### Custom indexes ## `reference/state/sqlite-schema/effect/custom-indexes.ts` ```ts filename="reference/state/sqlite-schema/effect/custom-indexes.ts" const PostSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), title: Schema.String, authorId: Schema.String, createdAt: Schema.Date, }).annotations({ title: 'posts' }) export const postTable = State.SQLite.table({ schema: PostSchema, indexes: [ { name: 'idx_posts_author', columns: ['authorId'] }, { name: 'idx_posts_created', columns: ['createdAt'] }, ], }) ``` ## Best Practices ### Schema Design - Always use `withPrimaryKey` for primary key columns - never combine it with nullable types - Use `Schema.optional()` for truly optional fields that can be undefined - Use `Schema.NullOr()` for fields that can explicitly be set to null - Leverage schema annotations like `title` or `identifier` to avoid repeating table names - Group related schemas in the same module for better organization ### Type safety - Let TypeScript infer table types rather than explicitly typing them - Use Effect Schema's refinements and transformations for data validation - Prefer Effect Schema's built-in types (`Schema.Int`, `Schema.Date`) over generic types where appropriate ### Performance - Be mindful of complex types stored as JSON - they can impact query performance - Use appropriate indexes for frequently queried columns - Consider using `withColumnType` to optimize storage for specific use cases ## When to Use This Approach **Use Effect Schema-based tables when:** - You already have Effect Schema definitions to reuse - You prefer Effect Schema's composability and transformations - Your schemas are shared across different parts of your application - You want automatic type mapping and strong type safety - You plan to migrate to Effect Schema v4 when it becomes available **Consider column-based tables when:** - You need precise control over SQLite column types - You're migrating from existing SQLite schemas - You prefer explicit column configuration - You're not already using Effect Schema extensively in your project # [Store](https://main--livestore-docs-dev.netlify.app/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" // ---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 === true) 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" // ---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. ## Sync Status LiveStore provides APIs to monitor the synchronization status between the client session and the leader thread. This is useful for displaying sync indicators or performing health checks. ### SyncStatus type ```ts type SyncStatus = { localHead: string // e.g., "e5.2" or "e5.2r1" upstreamHead: string // e.g., "e3" pendingCount: number // Number of events pending sync isSynced: boolean // true when pendingCount === 0 } ``` ### `store.syncStatus()` Returns the current sync status synchronously. ```ts const status = store.syncStatus() if (!status.isSynced) { console.log(`${status.pendingCount} events pending sync`) } ``` ### `store.subscribeSyncStatus(callback)` Subscribes to sync status changes. The callback is invoked immediately with the current status and whenever the sync state changes. ```ts const unsubscribe = store.subscribeSyncStatus((status) => { updateUI(status.isSynced ? 'Synced' : 'Syncing...') }) // Later, stop listening unsubscribe() ``` ### `store.syncStatusStream()` Returns an Effect Stream of sync status updates. For Effect-based workflows. ```ts store.syncStatusStream().pipe( Stream.tap((status) => Effect.log(`Sync: ${status.isSynced}`)), Stream.runDrain, ) ``` For React, see [`store.useSyncStatus()`](/framework-integrations/react-integration#storeusesyncstatus). ## 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() ``` # [Syncing](https://main--livestore-docs-dev.netlify.app/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)). ## Backend Reset Detection When a sync backend is reset (e.g., deleting `.wrangler/state` for Cloudflare, or resetting Postgres), clients that have cached data locally may not know about this reset. LiveStore detects backend resets using a unique `backendId` that is generated when the backend is first created. ### How it works 1. When a sync backend is initialized, it generates a unique `backendId` 2. Clients store this `backendId` locally alongside their eventlog 3. On subsequent sync operations, the client sends its stored `backendId` to verify the backend identity 4. If the backend has been reset, it will have a new `backendId`, causing a mismatch ### Configuring the behavior You can configure how LiveStore handles backend identity changes using the `onBackendIdMismatch` option: ```ts const store = await makeStore({ // ... other options sync: { backend: yourSyncBackend, onBackendIdMismatch: 'reset', // 'reset' | 'shutdown' | 'ignore' } }) ``` **Options:** - **`'reset'`** (default): Clear local storage (eventlog and state databases) and shutdown. The app will need to restart and will sync fresh data from the backend. This is the recommended option for development. - **`'shutdown'`**: Shutdown without clearing local storage. On restart, the client will still have stale data and encounter the same error. - **`'ignore'`**: Log the error and continue running. The client will show stale data but keep running (effectively offline mode). ### Common scenarios This feature is particularly useful during development when: - The sync backend state is deleted (e.g., `.wrangler/state` for Cloudflare) - Running with a `--reset` flag - Schema changes require re-backfilling data - Running multiple services (CLI and web UI) that need to stay in sync after a reset ## 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) # [LiveStore CLI](https://main--livestore-docs-dev.netlify.app/building-with-livestore/tools/cli/) The LiveStore CLI provides tools for creating new projects and integrating with AI assistants through MCP (Model Context Protocol). :::caution[Experimental - Not Production Ready] The LiveStore CLI is an experimental preview and not ready for production use. APIs, commands, and functionality may change significantly. Use for development and evaluation purposes only. ::: ## Installation You can use the LiveStore CLI in several ways: ```bash # Recommended: Use bunx (no installation needed) bunx @livestore/cli --help # Alternative options: npm install -g @livestore/cli # Global install npm install -D @livestore/cli # Project install npx @livestore/cli --help # Use with npx ``` ## Commands ### `livestore new-project` Create a new LiveStore project from available examples. ```bash # Interactive selection livestore new-project # Specify example and path livestore new-project --example web-todomvc my-project # Use specific branch livestore new-project --branch main ``` ### `livestore mcp` MCP server tools for AI assistant integration. See [MCP Integration](/building-with-livestore/tools/mcp) for details. ```bash # Start MCP server livestore mcp # Available subcommands livestore mcp coach # AI coaching assistant (requires API key env var) livestore mcp tools # Development tools server # Coach command requires API key - check implementation for specific variable name # Example: OPENAI_API_KEY=your_key livestore mcp coach ``` ### `livestore sync` Import and export events from the sync backend. Useful for backup, migration, and debugging. #### Export Export all events from the sync backend to a JSON file: ```bash livestore sync export \ --config livestore-cli.config.ts \ --store-id my-store \ events.json ``` **Example output:** ``` Exporting events from LiveStore... Config: livestore-cli.config.ts Store ID: my-store Output: events.json Connecting to sync backend... ✓ Connected to sync backend: @livestore/cf-sync Pulling events from sync backend... Pulled 127 events Exported 127 events to /path/to/events.json ``` **Options:** - `--config, -c` (required) - Path to a config module that exports `schema` and `syncBackend` - `--store-id, -i` (required) - Store identifier - `--client-id` - Client identifier for the sync connection (default: `cli-export`) - Large exports load data in memory; for very large stores run this on a machine with sufficient RAM. #### Import Import events from a JSON file to the sync backend: ```bash livestore sync import \ --config livestore-cli.config.ts \ --store-id my-store \ events.json ``` **Example output:** ``` Importing events to LiveStore... Config: livestore-cli.config.ts Store ID: my-store Input: events.json Reading import file... Found 127 events in export file Checking for existing events... Connecting to sync backend... ✓ Connected to sync backend: @livestore/cf-sync Pushing events to sync backend... Pushed 100/127 events Pushed 127/127 events Successfully imported 127 events ``` **Options:** - `--config, -c` (required) - Path to a config module that exports `schema` and `syncBackend` - `--store-id, -i` (required) - Store identifier - `--client-id` - Client identifier for the sync connection (default: `cli-import`) - `--force, -f` - Force import even if store ID in the file doesn't match - `--dry-run` - Validate the import file without actually importing - Large imports are memory-intensive because the JSON is loaded fully before validation/push. **Note:** The sync backend must be empty when importing. The import will fail if events already exist. ### Config file Both MCP and sync commands require a config file (conventionally named `livestore-cli.config.ts`) that exports: ## `reference/cli/config.ts` ```ts filename="reference/cli/config.ts" // Re-export your app's schema (adjust path to your project) export { schema } from './schema.ts' // Provide a sync backend constructor export const syncBackend = makeWsSync({ url: process.env.LIVESTORE_SYNC_URL ?? 'ws://localhost:8787', }) // Optionally, pass an auth payload (must be JSON-serializable) export const syncPayload = { authToken: process.env.LIVESTORE_SYNC_AUTH_TOKEN, } ``` ### `reference/cli/schema.ts` ```ts filename="reference/cli/schema.ts" const events = {} 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 }), }, }), } const state = State.SQLite.makeState({ tables, materializers: {} }) export const schema = makeSchema({ events, state }) ``` ## Global options - `--verbose` - Enable verbose logging - `--help` - Show command help # [MCP integration](https://main--livestore-docs-dev.netlify.app/building-with-livestore/tools/mcp/) LiveStore includes MCP (Model Context Protocol) integration that allows AI assistants like Claude to access LiveStore documentation, examples, and development tools. :::caution[Experimental] The MCP integration is experimental and under active development. Features may change. ::: For installation and general CLI usage, see the [LiveStore CLI](/building-with-livestore/tools/cli) documentation. ## What is MCP? MCP (Model Context Protocol) is a standard for providing AI assistants with access to external resources and tools. LiveStore's MCP server gives AI assistants access to: - LiveStore documentation and guides - Schema examples for common app types - Development tools and utilities ## Usage Start the MCP server: ```bash bunx @livestore/cli mcp ``` ## Available commands ### `bunx @livestore/cli mcp coach` Starts an AI coaching assistant with access to LiveStore documentation and best practices. ### `bunx @livestore/cli mcp tools` Provides development tools and utilities for working with LiveStore projects. ### LiveStore Runtime Tools - `livestore_instance_connect` - Connects a single in-process LiveStore instance by dynamically importing a module that exports `schema` and a `syncBackend` factory (and optionally `syncPayload`). - Notes: - Only one instance can be active at a time; connecting again shuts down and replaces the previous instance. - Reconnecting creates a fresh, in-memory client database. The visible state is populated by your backend's initial sync. Until sync completes, queries may return empty or partial results. - Module contract (generic example): ## `reference/mcp/module-contract.ts` ```ts filename="reference/mcp/module-contract.ts" export { schema } from './schema.ts' export const syncBackend = makeWsSync({ url: process.env.LIVESTORE_SYNC_URL ?? 'ws://localhost:8787' }) export const syncPayload = { authToken: process.env.LIVESTORE_SYNC_AUTH_TOKEN ?? 'insecure-token-change-me' } ``` ### `reference/mcp/schema.ts` ```ts filename="reference/mcp/schema.ts" const events = { entityCreated: Events.synced({ name: 'v1.EntityCreated', schema: Schema.Struct({ id: Schema.String, title: Schema.String, createdAt: Schema.DateFromString, }), }), } const tables = { entities: State.SQLite.table({ name: 'entities', columns: { id: State.SQLite.text({ primaryKey: true }), title: State.SQLite.text({ default: '' }), createdAt: State.SQLite.text({ default: '' }), }, }), } const materializers = State.SQLite.materializers(events, { 'v1.EntityCreated': ({ id, title, createdAt }) => tables.entities.insert({ id, title, createdAt: createdAt.toISOString() }), }) const state = State.SQLite.makeState({ tables, materializers }) export const schema = makeSchema({ events, state }) ``` - Params example: `{ "configPath": "livestore-cli.config.ts", "storeId": "" }` - Returns example: `{ "storeId": "", "clientId": "client-123", "sessionId": "session-abc", "schemaInfo": { "tableNames": ["..."], "eventNames": ["..."] } }` - `livestore_instance_query` - Executes raw SQL against the client database (read-only). - Notes: - SQLite dialect; use valid SQLite syntax. - `bindValues` must be an array (positional `?`) or a record (named `$key`). Do not pass stringified JSON. - Params example (positional): `{ "sql": "SELECT * FROM my_table WHERE userId = ?", "bindValues": ["u1"] }` - Params example (named): `{ "sql": "SELECT * FROM my_table WHERE userId = $userId", "bindValues": { "userId": "u1" } }` - Returns example: `{ "rows": [{ "col": "value" }], "rowCount": 1 }` - `livestore_instance_commit_events` - Commits one or more events defined by your connected schema. - Notes: - Use the canonical event name declared in your schema (e.g., `v1.EntityCreated`). - `args` must be a non-stringified JSON object matching the event schema. Date fields typically accept ISO 8601 strings. - Params example: `{ "events": [{ "name": "v1.EntityCreated", "args": { "id": "e1", "title": "Hello", "createdAt": "2024-01-01T00:00:00.000Z" } }] }` - Returns example: `{ "committed": 1 }` - `livestore_instance_status` - Reports instance/runtime info. - Returns example (connected): `{ "_tag": "connected", "storeId": "", "clientId": "client-123", "sessionId": "session-abc", "tableCounts": { "my_table": 12 } }` - Returns example (not connected): `{ "_tag": "disconnected" }` - `livestore_instance_disconnect` - Disconnects the current LiveStore instance and releases resources. - Returns: `{ "_tag": "disconnected" }` ### Sync export/import tools These tools connect directly to the sync backend (without creating a full LiveStore instance) to export or import events. Useful for backup, migration, and debugging. - `livestore_sync_export` - Exports all events from a sync backend to JSON data. - Notes: - Connects directly to the sync backend and pulls all events. - Returns the export data as a JSON object that can be saved or passed to import. - Params example: `{ "configPath": "livestore-cli.config.ts", "storeId": "my-store" }` - Returns example: `{ "storeId": "my-store", "eventCount": 127, "exportedAt": "2024-01-15T10:30:00.000Z", "data": { "version": 1, "storeId": "my-store", "events": [...] } }` - `livestore_sync_import` - Imports events from export data to a sync backend. - Notes: - The sync backend must be empty before importing. - Use `force: true` to import even if the store ID in the data doesn't match. - Use `dryRun: true` to validate the import without actually importing. - Params example: `{ "configPath": "livestore-cli.config.ts", "storeId": "my-store", "data": { "version": 1, "storeId": "my-store", "events": [...] } }` - Params with options: `{ "configPath": "...", "storeId": "...", "data": {...}, "force": true, "dryRun": true }` - Returns example: `{ "storeId": "my-store", "eventCount": 127, "dryRun": false }` ## Local Cloudflare sync (dev) Run a local Cloudflare sync backend: 1. Start the sync worker (wrangler): - `cd tests/integration/src/tests/adapter-cloudflare/fixtures` - `wrangler dev` - You should see an info page at `http://localhost:8787/`. 2. Start the MCP server in another terminal: - `bunx @livestore/cli mcp server` 3. From your MCP client (e.g., Claude Desktop), call tools: - Use your own config file path and storeId. The contrib repo provides an example: [`examples/cf-chat/livestore-cli.config.ts`](https://github.com/livestorejs/livestore-contrib/tree/main/examples/cf-chat/livestore-cli.config.ts). - Connect: `livestore_instance_connect` with `{ "configPath": "livestore-cli.config.ts", "storeId": "" }` - Commit: `livestore_instance_commit_events` with `[ { "name": "v1.EntityCreated", "args": { "id": "e1", "title": "Hello", "createdAt": "2024-01-01T00:00:00.000Z" } } ]` - Query: `livestore_instance_query` with `{ "sql": "SELECT * FROM my_table ORDER BY createdAt DESC LIMIT 5" }` - Status: `livestore_instance_status` - Disconnect: `livestore_instance_disconnect` ## Adding to Claude To use with Claude Desktop, add the MCP server to your Claude configuration: ```json { "mcpServers": { "livestore": { "command": "bunx", "args": ["@livestore/cli", "mcp"] } } } ``` ## Available resources The MCP server provides access to: - **Documentation**: Overview, features, getting started guides - **Architecture**: Technical design and principles - **Schema Examples**: Pre-built schemas for todo, blog, e-commerce, and social apps - **Development Tools**: Project scaffolding and utilities This enables AI assistants to provide context-aware help with LiveStore development. # [Examples](https://main--livestore-docs-dev.netlify.app/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 → # [Cloudflare Durable Objects examples](https://main--livestore-docs-dev.netlify.app/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://main--livestore-docs-dev.netlify.app/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 → # [Node adapter examples](https://main--livestore-docs-dev.netlify.app/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://main--livestore-docs-dev.netlify.app/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 → # [Custom elements](https://main--livestore-docs-dev.netlify.app/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 === true ? 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://main--livestore-docs-dev.netlify.app/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/minimal.tsx` ```tsx filename="reference/framework-integrations/react/minimal.tsx" const issueStoreOptions = (issueId: string) => storeOptions({ storeId: `issue-${issueId}`, schema, adapter: makeInMemoryAdapter(), }) export const App = () => { const [storeRegistry] = useState(() => new StoreRegistry({ defaultOptions: { batchUpdates } })) return ( ) } const IssueView = () => { const store = useStore(issueStoreOptions('abc123')) const [issue] = store.useQuery(queryDb(tables.issue.select())) return
{issue?.title}
} ``` ### `reference/framework-integrations/react/issue.schema.ts` ```ts filename="reference/framework-integrations/react/issue.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): 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/App.tsx` ```tsx filename="reference/framework-integrations/react/App.tsx" const appErrorFallback =
Something went wrong
const appLoadingFallback =
Loading LiveStore...
export const App = ({ children }: { children: ReactNode }) => { const [storeRegistry] = useState(() => new StoreRegistry({ defaultOptions: { batchUpdates } })) return ( {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!', createdAt: new Date() })) }, [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) const handleClick = useCallback(() => { updateTodo({ newTodoText: 'Hello, world!' }) }, [updateTodo]) 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/issue.store.ts` ```ts filename="reference/framework-integrations/react/issue.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/IssueView.tsx` ```tsx filename="reference/framework-integrations/react/IssueView.tsx" const issueErrorFallback =
Error loading issue
const issueLoadingFallback =
Loading issue...
export const 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 == null) return
Issue not found
return (

{issue.title}

Status: {issue.status}

) } // Wrap with Suspense and ErrorBoundary for loading and error states export const IssueViewWithSuspense = ({ issueId }: { issueId: string }) => { return ( ) } ``` 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/PreloadedIssue.tsx` ```tsx filename="reference/framework-integrations/react/PreloadedIssue.tsx" const preloadedIssueErrorFallback =
Error loading issue
const preloadedIssueLoadingFallback =
Loading issue...
export const PreloadedIssue = ({ issueId }: { issueId: string }) => { const [showIssue, setShowIssue] = useState(false) const storeRegistry = useStoreRegistry() // Preload the store when the user hovers (before they click) const handleMouseEnter = useCallback(() => { storeRegistry.preload({ ...issueStoreOptions(issueId), unusedCacheTime: 10_000, // Optionally override options }) }, [issueId, storeRegistry]) const handleClick = useCallback(() => { setShowIssue(true) }, []) return (
{showIssue == null ? ( ) : ( )}
) } ``` ### 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 this store in cache after it becomes unused (default: `60_000` in browser, `Infinity` in non-browser environments). Overrides the registry-level default when set. - `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 ### `store.useSyncStatus()` React hook that subscribes to sync status changes. Re-renders the component when sync status changes. ```tsx function SyncIndicator() { const store = useStore(storeOptions) const status = store.useSyncStatus() return {status.isSynced ? '✓ Synced' : `Syncing (${status.pendingCount} pending)...`} } ``` For the `SyncStatus` type and non-React APIs (`syncStatus()`, `subscribeSyncStatus()`, `syncStatusStream()`), see the [Store documentation](/building-with-livestore/store#sync-status). ### `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. # [Solid integration](https://main--livestore-docs-dev.netlify.app/framework-integrations/solid-integration/) ## Example See [examples](/examples) for a complete example. ## `reference/framework-integrations/solid/livestore/store.ts` ```ts filename="reference/framework-integrations/solid/livestore/store.ts" const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker, }) export const useAppStore = () => useStore({ adapter, schema, storeId: 'default', }) ``` ### `reference/framework-integrations/solid/livestore/schema.ts` ```ts filename="reference/framework-integrations/solid/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/framework-integrations/solid/MainSection.tsx` ```tsx filename="reference/framework-integrations/solid/MainSection.tsx" /** @jsxImportSource solid-js */ let currentStore: ReturnType | undefined const handleToggle = (event: Event & { currentTarget: HTMLInputElement }) => { const store = currentStore?.() if (store === undefined) return const id = event.currentTarget.dataset.todoId const completed = event.currentTarget.dataset.todoCompleted if (id === undefined || completed === undefined) return store.commit(completed === 'true' ? events.todoUncompleted({ id }) : events.todoCompleted({ id })) } const handleDelete = (event: MouseEvent & { currentTarget: HTMLButtonElement }) => { const store = currentStore?.() if (store === undefined) return const id = event.currentTarget.dataset.todoId if (id === undefined) return store.commit(events.todoDeleted({ id, deletedAt: new Date() })) } export const MainSection: Component = () => { const store = useAppStore() currentStore = store const todos = store.useQuery(visibleTodos$) const todoItems = () => todos() ?? ([] as (typeof tables.todos.Type)[]) return (
    {(todo: typeof tables.todos.Type) => (
  • )}
) } ``` ### `reference/framework-integrations/solid/livestore/queries.ts` ```ts filename="reference/framework-integrations/solid/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/framework-integrations/solid/store-logging.ts` ```ts filename="reference/framework-integrations/solid/store-logging.ts" // ---cut--- const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker, }) export const useAppStore = () => useStore({ schema, adapter, storeId: 'default', // Optional: swap logger and minimum log level logger: Logger.prettyWithThread('window'), logLevel: LogLevel.Info, // use LogLevel.None to disable logs }) ``` # [Svelte integration](https://main--livestore-docs-dev.netlify.app/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://main--livestore-docs-dev.netlify.app/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', createdAt: new Date() })) } ``` ### 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. # [Expo](https://main--livestore-docs-dev.netlify.app/getting-started/expo/) export const CODE = { babelConfig: `module.exports = (api) => { api.cache(true) return { presets: [['babel-preset-expo', { unstable_transformImportMeta: true }]], plugins: ['babel-plugin-transform-vite-meta-env', '@babel/plugin-syntax-import-attributes'], } } `, metroConfig: `// Learn more https://docs.expo.io/guides/customizing-metro const { getDefaultConfig } = require('expo/metro-config') const { addLiveStoreDevtoolsMiddleware } = require('@livestore/devtools-expo') const path = require('node:path') /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname) // Needed for monorepo setup (can be removed in standalone projects) if (process.env.MONOREPO_ROOT) { config.watchFolders = [path.resolve(process.env.MONOREPO_ROOT)] } addLiveStoreDevtoolsMiddleware(config, { schemaPath: './src/livestore/schema.ts', viteConfig: (viteConfig) => { viteConfig.server.fs ??= {} viteConfig.server.fs.strict = false viteConfig.optimizeDeps ??= {} viteConfig.optimizeDeps.force = true return viteConfig }, }) module.exports = config `, } {/* We're adjusting package versions when the docs are built from a dev prerelease */} 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" /* oxlint-disable react/style-prop-object */ const suspenseFallback = Loading LiveStore... const appContentStyle = { flex: 1, gap: 24, padding: 24 } const safeAreaStyle = { flex: 1 } const AppContent: FC = () => ( ) export const Root: FC = () => { const [storeRegistry] = useState(() => new StoreRegistry()) return ( ) } ``` ### `getting-started/expo/components/ListTodos.tsx` ```tsx filename="getting-started/expo/components/ListTodos.tsx" const listContainerStyle = { flex: 1, gap: 16 } satisfies ViewStyle const listContentContainerStyle = { gap: 12 } satisfies ViewStyle const todoContainerStyle = { borderRadius: 12, borderColor: '#d4d4d8', borderWidth: 1, padding: 16, gap: 8, } satisfies ViewStyle const todoTitleStyle = { fontSize: 16, fontWeight: '600' } satisfies TextStyle const todoActionRowStyle = { flexDirection: 'row', gap: 12 } satisfies ViewStyle export const ListTodos: FC = () => { const store = useAppStore() const todos = store.useQuery(visibleTodos$) const toggleTodo = useCallback( ({ id, completed }: typeof tables.todos.Type) => { store.commit(completed === true ? events.todoUncompleted({ id }) : events.todoCompleted({ id })) }, [store], ) const clearCompleted = useCallback(() => { store.commit(events.todoClearedCompleted({ deletedAt: new Date() })) }, [store]) return ( {todos.map((todo) => ( ))}