React Web
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-linearlite my-appReplace
my-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
-
Install dependencies
Terminal window bun add @livestore/livestore @livestore/wa-sqlite @livestore/adapter-web @livestore/react @livestore/utils @livestore/peer-deps @livestore/devtools-viteTerminal window pnpm add @livestore/livestore @livestore/wa-sqlite @livestore/adapter-web @livestore/react @livestore/utils @livestore/peer-deps @livestore/devtools-viteTerminal window npm install @livestore/livestore @livestore/wa-sqlite @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 // @ts-checkimport { livestoreDevtoolsPlugin } from '@livestore/devtools-vite'import react from '@vitejs/plugin-react'import { defineConfig } from 'vite'const isProdBuild = process.env.NODE_ENV === 'production'// https://vitejs.dev/configexport default defineConfig({server: {port: process.env.PORT ? Number(process.env.PORT) : 60_001,},worker: isProdBuild ? { format: 'es' } : undefined,plugins: [react(), livestoreDevtoolsPlugin({ schemaPath: './src/livestore/schema.ts' })],})
Events
Create a file named events.ts
inside the livestore
folder. This file stores the events your app uses to interact with the database.
Use the Events
and Schema
modules from @livestore/livestore
to define your events.
Here’s an example:
import { Events, Schema } from '@livestore/livestore'
/** * LiveStore embraces event sourcing, so data changes are defined as events * (sometimes referred to as "write model"). Those events are then synced across clients * and materialize to state (i.e. SQLite tables). * * Once your app is in production, please make sure your event definitions evolve in a backwards compatible way. * It's recommended to version event definitions. Learn more: https://next.livestore.dev/docs/reference/events */
export const todoCreated = Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }),})
export const todoCompleted = Events.synced({ name: 'v1.TodoCompleted', schema: Schema.Struct({ id: Schema.String }),})
export const todoUncompleted = Events.synced({ name: 'v1.TodoUncompleted', schema: Schema.Struct({ id: Schema.String }),})
export const todoDeleted = Events.synced({ name: 'v1.TodoDeleted', schema: Schema.Struct({ id: Schema.String, deletedAt: Schema.Date }),})
export const todoClearedCompleted = Events.synced({ name: 'v1.TodoClearedCompleted', schema: Schema.Struct({ deletedAt: Schema.Date }),})
Define your schema
To define the data structure for your app, set up a schema that specifies the tables and fields your app uses.
-
In
src
, create alivestore
folder and inside it create a file namedschema.ts
. This file defines the tables and data structures for your app. -
In
schema.ts
, define a table to represent a data model, such as atodos
.
Here’s an example:
import { makeSchema, Schema, SessionIdSymbol, State } from '@livestore/livestore'
import { Filter } from '../types.js'import * as eventsDefs from './events.js'
/** * LiveStore allows you to freely define your app state as SQLite tables (sometimes referred to as "read model") * and even supports arbitary schema changes without the need for manual schema migrations. * * Your app doesn't directly write to those tables, but instead commits events which are then materialized * into state (i.e. SQLite tables). * * LiveStore doesn't sync tables directly, but syncs events instead which are then materialized into the tables * resulting in the same state. * * See docs to learn more: https://next.livestore.dev/docs/reference/state */
const 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 }), },})
// LiveStore aims to provide a unified state management solution (for synced and client-only state),// so to simplify local-only state management, it also offers a client-only document concept// giving you the convenience of `React.useState` with a derived `.set` event and auto-registered materializer.const uiState = State.SQLite.clientDocument({ name: 'uiState', schema: Schema.Struct({ newTodoText: Schema.String, filter: Filter }), default: { // Using the SessionIdSymbol as default id means the UiState will be scoped per client session (i.e. browser tab). id: SessionIdSymbol, value: { newTodoText: '', filter: 'all' }, },})
export const events = { ...eventsDefs, uiStateSet: uiState.set,}
export const tables = { todos, uiState }
const materializers = State.SQLite.materializers(events, { 'v1.TodoCreated': ({ id, text }) => todos.insert({ id, text, completed: false }), 'v1.TodoCompleted': ({ id }) => todos.update({ completed: true }).where({ id }), 'v1.TodoUncompleted': ({ id }) => todos.update({ completed: false }).where({ id }), 'v1.TodoDeleted': ({ id, deletedAt }) => todos.update({ deletedAt }).where({ id }), 'v1.TodoClearedCompleted': ({ deletedAt }) => todos.update({ deletedAt }).where({ completed: true }),})
const state = State.SQLite.makeState({ tables, materializers })
export const schema = makeSchema({ events, state })
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 { makeTracer } from '../otel.js'import { schema } from './schema.js'
makeWorker({ schema, otelOptions: { tracer: makeTracer('todomvc-worker') } })
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:
/* eslint-disable unicorn/prefer-global-this */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 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/livestore.worker?worker'import { schema } from './livestore/schema.js'import { makeTracer } from './otel.js'
const AppBody: React.FC = () => ( <section className="todoapp"> <Header /> <MainSection /> <Footer /> </section>)
const resetPersistence = import.meta.env.DEV && new URLSearchParams(window.location.search).get('reset') !== null
if (resetPersistence) { const searchParams = new URLSearchParams(window.location.search) searchParams.delete('reset') window.history.replaceState(null, '', `${window.location.pathname}?${searchParams.toString()}`)}
const adapter = makePersistedAdapter({ storage: { type: 'opfs' }, worker: LiveStoreWorker, sharedWorker: LiveStoreSharedWorker, resetPersistence,})
const otelTracer = makeTracer('todomvc-main')
export const App: React.FC = () => ( <LiveStoreProvider schema={schema} renderLoading={(_) => <div>Loading LiveStore ({_.stage})...</div>} adapter={adapter} batchUpdates={batchUpdates} otelOptions={{ tracer: otelTracer }} > <div style={{ top: 0, right: 0, position: 'absolute', background: '#333' }}> <FPSMeter height={40} /> </div> <AppBody /> </LiveStoreProvider>)
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
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> )}