Skip to content

3. Read and write todos via LiveStore

Now on to the fun part! In this section, you’ll set up LiveStore so that the todos that you’re creating will persist and survive page refreshes and dev server reloads.

Start by installing the necessary dependencies:

Terminal window
bun add \
@livestore/livestore@0.4.0-dev.14 \
@livestore/wa-sqlite@0.4.0-dev.14 \
@livestore/adapter-web@0.4.0-dev.14 \
@livestore/react@0.4.0-dev.14 \
@livestore/peer-deps@0.4.0-dev.14
Expand to view details about the packages

Here’s an overview of each of these dependencies:

In this step, you’re going to define the schema that LiveStore uses to represent the data of your app. The schema is one of the core concepts of LiveStore.

Your LiveStore-related files typically live in a livestore directory in your app, so create that first:

Terminal window
mkdir src/livestore
touch src/livestore/schema.ts

The schema contains three major components:

  • The table structures of your local SQLite database.
  • The events that can be emitted by your app.
  • The materializers that use the events to alter the state of your database.

Here’s how you define the schema for the current version of the todo app:

src/livestore/schema.ts
import { Events, makeSchema, Schema, State } from '@livestore/livestore'
export const tables = {
todos: State.SQLite.table({
name: 'todos',
columns: {
id: State.SQLite.integer({ primaryKey: true }),
text: State.SQLite.text({ default: '' }),
},
})
}
export const events = {
todoCreated: Events.synced({
name: 'v1.TodoCreated',
schema: Schema.Struct({ id: Schema.Number, text: Schema.String }),
}),
todoDeleted: Events.synced({
name: 'v1.TodoDeleted',
schema: Schema.Struct({ id: Schema.Number }),
}),
}
const materializers = State.SQLite.materializers(events, {
'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text }),
'v1.TodoDeleted': ({ id }) => tables.todos.delete().where({ id: id }),
})
const state = State.SQLite.makeState({ tables, materializers })
export const schema = makeSchema({ events, state })

Here’s a quick summary of the code:

  • tables contains the definitions of your SQLite table structures. It currently defines a single todos table with two columns called id and text.
  • events defines the types of events that your app can emit. It currently defines the todoCreated and todoDeleted events with an attached schema property which defines the shape of the event.
  • materializers are the functions that are invoked for each event. In them, you define what should happen when the event gets fired. Right now, the v1.TodoCreated event results in an insert operation in the database while v1.TodoDeleted will remove a row from the database via a delete operation.

The tables, events and materializers are packaged up into a schema object that’s needed in the parts of your app where you interact with LiveStore.

Configure your React/Vite app to use LiveStore

Section titled “Configure your React/Vite app to use LiveStore”

To now “connect” LiveStore with your React app, you need to:

  1. Create the LiveStore web worker that is responsible for the logic of persisting data on your file system.
  2. Create a LiveStore adapter that enables persistence with local SQLite via OPFS.
  3. Create a store configuration file that exports a custom hook wrapping useStore().
  4. Create and provide a StoreRegistry with <StoreRegistryProvider> in main.tsx.
  5. Update your Vite Config to work with LiveStore.

Let’s get started!

First, create a new file for the LiveStore web worker:

Terminal window
touch src/livestore/livestore.worker.ts

Then, add the following code to it:

import { makeWorker } from '@livestore/adapter-web/worker'
import { schema } from './schema.ts'
makeWorker({ schema })

This is boilerplate code that you’ll almost never need to touch. (In this tutorial, it’ll only be edited once when we introduce syncing data to Cloudflare.)

Next, create a store.ts file that defines your store configuration and a useAppStore() hook:

Terminal window
touch src/livestore/store.ts
src/livestore/store.ts
import { makePersistedAdapter } from '@livestore/adapter-web'
import { useStore } from '@livestore/react'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
import { schema } from './schema.ts'
import LiveStoreWorker from './livestore.worker.ts?worker'
const adapter = makePersistedAdapter({
storage: { type: 'opfs' },
worker: LiveStoreWorker,
sharedWorker: LiveStoreSharedWorker,
})
export const useAppStore = () =>
useStore({
storeId: 'todo-db-tutorial',
schema,
adapter,
batchUpdates,
})

Then, update main.tsx to wrap your <App> component inside the <StoreRegistryProvider>:

src/main.tsx
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StoreRegistry } from '@livestore/livestore'
import { StoreRegistryProvider } from '@livestore/react'
import { Suspense, useState } from 'react'
const Root = () => {
const [storeRegistry] = useState(() => new StoreRegistry())
return (
<Suspense fallback={<div>Loading LiveStore...</div>}>
<StoreRegistryProvider storeRegistry={storeRegistry}>
<App />
</StoreRegistryProvider>
</Suspense>
)
}
createRoot(document.getElementById('root')!).render(<Root />)

Finally, update your Vite Config to enable usage of WebAssembly:

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
cloudflare(),
],
optimizeDeps: {
exclude: ['@livestore/wa-sqlite'],
},
})

Now, your app is set up to start reading and writing data in a local SQLite database via LiveStore. If you run the app via pnpm dev, you’ll briefly see a loading screen (the Suspense fallback) before the todo list UI from the previous steps will appear again:

The todos themselves are not yet persisted because you haven’t modified the logic for managing state in App.tsx. That’s what you’ll do next.

Read and write todos from local SQLite via LiveStore

Section titled “Read and write todos from local SQLite via LiveStore”

The current version of the app still stores the todos in-memory via the state of your <App> component. However, with the basic LiveStore setup in place, you can now move to persisting your todos inside SQLite via LiveStore!

In order to read todos, you need to define a LiveStore query. LiveStore queries are live or reactive, meaning they will automatically update your UI components when the underlying data changes.

Here’s how you can define and use a query to fetch all todos from the local SQLite database using LiveStore inside the <App> component (you don’t need to update your code yet, you’ll get the full snippet at the end of this section):

src/App.tsx
function App() {
const store = useAppStore()
// The trailing `$` is a convention to indicate that this
// variable is a "live query".
const todos$ = queryDb(() => tables.todos.select())
// `todos` is an array containing all the todos from the DB.
// When rendering the component, you can do {todos.map(todo => ...
// and access `todo.text` and `todo.id` as before.
const todos = store.useQuery(todos$)
const [todos, setTodos] = useState<Todo[]>([])
// ... remaining code for the `<App>` component
}

With this change, you’re now reading the todos from LiveStore (where they’re persisted) instead of using React’s ephemeral state.

This was only half of the job though: Right now your code would throw a type error because it still uses setTodos to update the local state. You need to update this to use the todoCreated and todoDeleted events you defined in src/livestore/schema.ts:

src/App.tsx
function App() {
const store = useAppStore()
const todos$ = queryDb(() => tables.todos.select())
const todos = store.useQuery(todos$)
// Commit an event to the `store` instead of
// updating local React state.
const addTodo = () => {
store.commit(
events.todoCreated({ id: Date.now(), text: input }),
)
setInput('')
}
// Commit an event to the `store` instead of
// updating local React state.
const deleteTodo = (id: number) => {
store.commit(
events.todoDeleted({ id }),
)
}
// ... remaining code for the `<App>` component
}

See how the data now will flow through the app unidirectionally with this setup?

Let’s follow it from the first time the app is started:

  1. The <App> component registers the todos$ “live query” with the store.
  2. The query fires initially; the returned todos array is empty.
  3. The <App> component renders an empty list.
  4. A user adds a todo; the todoCreated event is triggered and committed to the DB.
  5. The v1.TodoCreated materializer is invoked and writes the data into the DB.
  6. The todos$ query fires again because the state of the DB changed; the returned todos array now contains the newly created todo.
  7. The <App> component renders a single todo.

Now try it out! Here’s the full code for App.tsx that you can copy and paste:

src/App.tsx
import { useState } from 'react'
import { tables, events } from './livestore/schema'
import { useAppStore } from './livestore/store'
import { queryDb } from '@livestore/livestore'
function App() {
const store = useAppStore()
const todos$ = queryDb(() => tables.todos.select())
const todos = store.useQuery(todos$)
const [input, setInput] = useState('')
const addTodo = () => {
if (input.trim()) {
store.commit(
events.todoCreated({ id: Date.now(), text: input }),
)
setInput('')
}
}
const deleteTodo = (id: number) => {
store.commit(
events.todoDeleted({ id }),
)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
addTodo()
}
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="w-full max-w-lg">
<h1 className="text-5xl font-bold text-gray-800 text-center mb-12">
Todo List
</h1>
<div className="flex gap-3 mb-8">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter a todo..."
className="flex-1 px-4 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={addTodo}
className="px-6 py-2 text-sm font-medium text-white bg-blue-500 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Add
</button>
</div>
<div className="space-y-3">
{todos.map(todo => (
<div
key={todo.id}
className="flex items-center justify-between bg-white px-4 py-3 rounded shadow-sm"
>
<span className="text-gray-700">{todo.text}</span>
<button
onClick={() => deleteTodo(todo.id)}
className="px-4 py-1 text-sm font-medium text-white bg-red-500 rounded hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
>
Delete
</button>
</div>
))}
</div>
{todos.length === 0 && (
<p className="text-center text-gray-400 mt-8">
No todos yet. Add one above!
</p>
)}
</div>
</div>
)
}
export default App

Try adding a few todos and then refresh the browser (or restart your development server):

In addition to the persistence, you can now observe the following behaviour:

  • You can open multiple browser tabs/windows, make edits to the todo list and see live updates when you make changes in one of them.
  • You can stop and restart the local version of your app and the todos will be persisted.
  • If you open the app in an incognito tab or a different browser, the list of todos will be empty again; that’s because the Shared web worker only works in the same browser “session”; incognito tabs and different browsers use a different session.
Expand to learn about browser isolation/sessions

Regular browser tab/windows

  • All regular (i.e. “not incognito”) tabs/windows share the same browser session.
  • The LiveStore shared web worker is shared across all these tabs/windows.
  • OPFS storage is shared across the origin.
  • ✅ Real-time sync works because they’re all using the same LiveStore shared web worker and storage.

Incognito windows

  • Incognito mode creates a separate session from regular browsing.
  • However, multiple incognito tabs/windows opened in the same incognito session still share:
    • The same LiveStore shared web worker instance.
    • The same OPFS storage (within that incognito session).
  • Each incognito session gets isolated storage, but windows within that session are not isolated from each other.

Think of it this way:

  • Regular tabs/windows = Session A (all tabs/windows share the same data).
  • Incognito tabs/windows = Session B (all incognito tabs/windows share the same data).

To get true isolation

If you want completely isolated instances, you’d need to use:

  • Different browser profiles - Chrome profiles are truly isolated.
  • Different browsers - Chrome vs Firefox vs Safari.
  • Different devices (only possible with the deployed app) - Your computer vs your phone.

Now, you can also deploy the app:

Terminal window
bun run deploy

Here’s a little GIF demonstrating the current state of the live updates via browser isolation. On the left, we have two regular Chrome windows, on the right, two Safari windows.

  • When a change is made in one Chrome window, it is automatically reflected in the other Chrome window.
  • Similarly, when a change is made in one Safari window, it is automatically reflected in the other Safari window.

To recap, here’s a visualization of the data flow in the app at this point:

LiveStore















UI State


input: string

UI

updates

setInput

Events

store.commit(event)

DB

materializes

Query

updates

updates

LiveStore















UI State


input: string

UI

updates

setInput

Events

store.commit(event)

DB

materializes

Query

updates

updates

You may have wondered: “If the data is persisted, where is it?”. If the data is somewhere on your file system, you should be able to spin up a database viewer of your choice and inspect the local SQLite DB file.

Unfortunately, that’s not how OPFS works. While the SQLite files do exist somewhere on your file system, they are hidden in the browser’s internals and not easily accessible.

That being said, there is a convenient way how you can actually see the data on your file system: Using LiveStore DevTools!

To start using DevTools, first install the package for the web adapter you’re using:

Terminal window
bun add @livestore/devtools-vite@0.4.0-dev.14

Next, update your Vite Config to add the livestoreDevtoolsPlugin. It should look as follows:

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { cloudflare } from '@cloudflare/vite-plugin'
import { livestoreDevtoolsPlugin } from '@livestore/devtools-vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
cloudflare(),
livestoreDevtoolsPlugin({ schemaPath: './src/livestore/schema.ts' })
],
optimizeDeps: {
exclude: ['@livestore/wa-sqlite'],
},
})

Now, in the developer console of your browser, you can find the LiveStore tab with details about the current LiveStore internals:

There are several tabs in the LiveStore DevTools:

  • Database: Browse the data that’s currently in your DB, send SQL queries, export the database to your file system, and more.
  • Queries: Shows the last results of the currently active live queries.
  • Events: The event log that stores all the events emitted by your app.

If you’re curious, add and delete a few todos via the UI and observe what’s happening in the three tabs.