Getting started with LiveStore + React
Prerequisites
Section titled “Prerequisites”- Recommended: Bun 1.2 or higher
- Node.js 23.0.0 or higher
Option A: Quick start
Section titled “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.
-
Set up project from template
Terminal window bunx tiged --mode=git git@github.com:livestorejs/livestore/examples/standalone/web-todomvc-sync-cf livestore-appTerminal window pnpm dlx tiged --mode=git git@github.com:livestorejs/livestore/examples/standalone/web-todomvc-sync-cf livestore-appTerminal window npx tiged --mode=git git@github.com:livestorejs/livestore/examples/standalone/web-todomvc-sync-cf livestore-appReplace
livestore-app
with your desired app name. -
Install dependencies
It’s strongly recommended to use
bun
orpnpm
for the simplest and most reliable dependency setup (see note on package management for more details).Terminal window bun installTerminal window pnpm installTerminal window npm installPro tip: You can use direnv to manage environment variables.
-
Run dev environment
Terminal window bun devTerminal window pnpm devTerminal window npm run dev -
Open browser
Open
http://localhost:60000
in your browser.You can also open the devtools by going to
http://localhost:60000/_livestore
.
Option B: Existing project setup
Section titled “Option B: Existing project setup”-
Install dependencies
Terminal window bun install @livestore/livestore @livestore/wa-sqlite@1.0.5-dev.2 @livestore/adapter-web @livestore/react @livestore/utils @livestore/peer-deps @livestore/devtools-viteTerminal window pnpm add @livestore/livestore @livestore/wa-sqlite@1.0.5-dev.2 @livestore/adapter-web @livestore/react @livestore/utils @livestore/peer-deps @livestore/devtools-viteTerminal window npm install @livestore/livestore @livestore/wa-sqlite@1.0.5-dev.2 @livestore/adapter-web @livestore/react @livestore/utils @livestore/peer-deps @livestore/devtools-vite -
Update Vite config
Add the following code to your
vite.config.js
file:vite.config.js /* eslint-disable unicorn/no-process-exit */import { spawn } from 'node:child_process'import { livestoreDevtoolsPlugin } from '@livestore/devtools-vite'import react from '@vitejs/plugin-react'import { defineConfig } from 'vite'export default defineConfig({server: {port: process.env.PORT ? Number(process.env.PORT) : 60_001,},worker: { format: 'es' },plugins: [react(),livestoreDevtoolsPlugin({ schemaPath: './src/livestore/schema.ts' }),// Running `wrangler dev` as part of `vite dev` needed for `@livestore/sync-cf`{name: 'wrangler-dev',configureServer: async (server) => {const wrangler = spawn('./node_modules/.bin/wrangler', ['dev', '--port', '8787'], {stdio: ['ignore', 'inherit', 'inherit'],})const shutdown = () => {if (wrangler.killed === false) {wrangler.kill()}process.exit(0)}server.httpServer?.on('close', shutdown)process.on('SIGTERM', shutdown)process.on('SIGINT', shutdown)wrangler.on('exit', (code) => console.error(`wrangler dev exited with code ${code}`))},},],})
Define Your Schema
Section titled “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:
import { Events, makeSchema, Schema, SessionIdSymbol, State } from '@livestore/livestore'
// 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 })
Create the LiveStore Worker
Section titled “Create the LiveStore Worker”Create a file named livestore.worker.ts
inside the src/livestore
folder. This file will contain the LiveStore web worker. When importing this file, make sure to add the ?worker
extension to the import path to ensure that Vite treats it as a worker file.
import { makeWorker } from '@livestore/adapter-web/worker'import { makeCfSync } from '@livestore/sync-cf'
import { schema } from './livestore/schema.js'
makeWorker({ schema, sync: { backend: makeCfSync({ url: import.meta.env.VITE_LIVESTORE_SYNC_URL }), initialSyncOptions: { _tag: 'Blocking', timeout: 5000 }, },})
Add the LiveStore Provider
Section titled “Add the LiveStore Provider”To make the LiveStore available throughout your app, wrap your app’s root component with the LiveStoreProvider
component from @livestore/react
. This provider manages your app’s data store, loading, and error states.
Here’s an example:
import { makePersistedAdapter } from '@livestore/adapter-web'import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'import { LiveStoreProvider } from '@livestore/react'import { FPSMeter } from '@overengineering/fps-meter'import type React from 'react'import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
import { Footer } from './components/Footer.js'import { Header } from './components/Header.js'import { MainSection } from './components/MainSection.js'import LiveStoreWorker from './livestore.worker?worker'import { schema } from './livestore/schema.js'import { getStoreId } from './util/store-id.js'
const AppBody: React.FC = () => ( <section className="todoapp"> <Header /> <MainSection /> <Footer /> </section>)
const storeId = getStoreId()
const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker,})
export const App: React.FC = () => ( <LiveStoreProvider schema={schema} adapter={adapter} renderLoading={(_) => <div>Loading LiveStore ({_.stage})...</div>} batchUpdates={batchUpdates} storeId={storeId} syncPayload={{ authToken: 'insecure-token-change-me' }} > <div style={{ top: 0, right: 0, position: 'absolute', background: '#333' }}> <FPSMeter height={40} /> </div> <AppBody /> </LiveStoreProvider>)
Commit events
Section titled “Commit events”After wrapping your app with the LiveStoreProvider
, you can use the useStore
hook from any component to commit events.
Here’s an example:
import { useStore } from '@livestore/react'import React from 'react'
import { uiState$ } from '../livestore/queries.js'import { events } from '../livestore/schema.js'
export const Header: React.FC = () => { const { store } = useStore() const { newTodoText } = store.useQuery(uiState$)
const updatedNewTodoText = (text: string) => store.commit(events.uiStateSet({ newTodoText: text }))
const todoCreated = () => store.commit( events.todoCreated({ id: crypto.randomUUID(), text: newTodoText }), events.uiStateSet({ newTodoText: '' }), )
return ( <header className="header"> <h1>TodoMVC</h1> <input className="new-todo" placeholder="What needs to be done?" autoFocus={true} value={newTodoText} onChange={(e) => updatedNewTodoText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { todoCreated() } }} ></input> </header> )}
Queries
Section titled “Queries”To retrieve data from the database, first define a query using queryDb
from @livestore/livestore
. Then, execute the query with the useQuery
hook from @livestore/react
.
Consider abstracting queries into a separate file to keep your code organized, though you can also define them directly within components if preferred.
Here’s an example:
import { queryDb } from '@livestore/livestore'import { useStore } from '@livestore/react'import React from 'react'
import { uiState$ } from '../livestore/queries.js'import { events, tables } from '../livestore/schema.js'
const visibleTodos$ = queryDb( (get) => { const { filter } = get(uiState$) return tables.todos.where({ deletedAt: null, completed: filter === 'all' ? undefined : filter === 'completed', }) }, { label: 'visibleTodos' },)
export const MainSection: React.FC = () => { const { store } = useStore()
const toggleTodo = React.useCallback( ({ id, completed }: typeof tables.todos.Type) => store.commit(completed ? events.todoUncompleted({ id }) : events.todoCompleted({ id })), [store], )
const visibleTodos = store.useQuery(visibleTodos$)
return ( <section className="main"> <ul className="todo-list"> {visibleTodos.map((todo) => ( <li key={todo.id}> <div className="state"> <input type="checkbox" className="toggle" checked={todo.completed} onChange={() => toggleTodo(todo)} /> <label>{todo.text}</label> <button className="destroy" onClick={() => store.commit(events.todoDeleted({ id: todo.id, deletedAt: new Date() }))} ></button> </div> </li> ))} </ul> </section> )}