Skip to content

Getting started with LiveStore + React

  • Recommended: Bun 1.2 or higher
  • Node.js 23.0.0 or higher

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-todomvc-sync-cf livestore-app

    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 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.

  1. 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-vite
  2. 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}`))
    },
    },
    ],
    })

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:

src/livestore/schema.ts
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 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 { 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 },
},
})

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
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>
)

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>
)
}

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>
)
}