Skip to content

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.

  1. Set up project from template

    Terminal window
    bunx tiged --mode=git git@github.com:livestorejs/livestore/examples/standalone/web-linearlite my-app

    Replace my-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 for more details).

    Terminal window
    bun install

    Pro tip: You can use direnv to manage environment variables.

  3. Run dev environment

    Terminal window
    bun dev
  4. 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

  1. Install dependencies

    Terminal window
    bun add @livestore/livestore @livestore/wa-sqlite @livestore/adapter-web @livestore/react @livestore/utils @livestore/peer-deps @livestore/devtools-vite
  2. Update Vite config

    Add the following code to your vite.config.js file:

    vite.config.js
    // @ts-check
    import { 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/config
    export 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:

src/livestore/events.ts
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 a livestore folder and inside it create a file named schema.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 a todos.

Here’s an example:

src/livestore/schema.ts
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.

src/livestore/livestore.worker.ts
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:

src/Root.tsx
/* 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:

src/components/Header.tsx
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:

src/components/MainSection.tsx
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>
)
}