Skip to content

Expo

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

To use LiveStore with Expo, ensure your project has the New Architecture enabled. This is required for transactional state updates.

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/expo-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 the app

    Terminal window
    bun start

    In a new terminal, start the Cloudflare Worker (for the sync backend):

    Terminal window
    bun wrangler:dev
  1. Install dependencies

    Terminal window
    bun install @livestore/devtools-expo @livestore/adapter-expo @livestore/livestore @livestore/react @livestore/utils @livestore/peer-deps expo-sqlite
  2. Add Vite meta plugin to babel config file

    LiveStore Devtools uses Vite. This plugin emulates Vite’s import.meta.env functionality.

    Terminal window
    bun add -d babel-plugin-transform-vite-meta-env

    In your babel.config.js file, add the plugin as follows:

    babel.config.js
    /* eslint-disable unicorn/prefer-module */
    module.exports = (api) => {
    api.cache(true)
    return {
    presets: ['babel-preset-expo'],
    plugins: ['babel-plugin-transform-vite-meta-env', '@babel/plugin-syntax-import-attributes'],
    }
    }
  3. Update Metro config

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

    metro.config.js
    /* eslint-disable @typescript-eslint/no-require-imports */
    /* eslint-disable unicorn/prefer-module */
    // Learn more https://docs.expo.io/guides/customizing-metro
    const { getDefaultConfig } = require('expo/metro-config')
    const { addLiveStoreDevtoolsMiddleware } = require('@livestore/devtools-expo')
    /** @type {import('expo/metro-config').MetroConfig} */
    const config = getDefaultConfig(__dirname)
    addLiveStoreDevtoolsMiddleware(config, { schemaPath: './src/livestore/schema.ts' })
    // console.log(config)
    module.exports = config

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

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.

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-expo'
import { nanoid } from '@livestore/livestore'
import { LiveStoreProvider } from '@livestore/react'
import { makeCfSync } from '@livestore/sync-cf'
import { StatusBar } from 'expo-status-bar'
import React from 'react'
import { Button, StyleSheet, Text, unstable_batchedUpdates as batchUpdates, View } from 'react-native'
import { Filters } from './components/Filters.tsx'
import { ListTodos } from './components/ListTodos.tsx'
import { Meta } from './components/Meta.tsx'
import { NewTodo } from './components/NewTodo.tsx'
import { events, schema, tables } from './livestore/schema.ts'
const storeId = process.env.EXPO_PUBLIC_LIVESTORE_STORE_ID
const syncUrl = process.env.EXPO_PUBLIC_LIVESTORE_SYNC_URL
const adapter = makePersistedAdapter({
sync: { backend: syncUrl ? makeCfSync({ url: syncUrl }) : undefined },
})
export const Root = () => {
const [, rerender] = React.useState({})
return (
<View style={styles.container}>
<LiveStoreProvider
schema={schema}
adapter={adapter}
storeId={storeId}
syncPayload={{ authToken: 'insecure-token-change-me' }}
renderLoading={(_) => <Text>Loading LiveStore ({_.stage})...</Text>}
renderError={(error: any) => <Text>Error: {error.toString()}</Text>}
renderShutdown={() => {
return (
<View>
<Text>LiveStore Shutdown</Text>
<Button title="Reload" onPress={() => rerender({})} />
</View>
)
}}
boot={(store) => {
if (store.query(tables.todos.count()) === 0) {
store.commit(events.todoCreated({ id: nanoid(), text: 'Make coffee' }))
}
}}
batchUpdates={batchUpdates}
>
<InnerApp />
</LiveStoreProvider>
<StatusBar style="auto" />
</View>
)
}
const InnerApp = () => (
<>
<NewTodo />
<Meta />
<ListTodos />
<Filters />
</>
)
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 60,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 32,
},
})

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/NewTodo.tsx
import { nanoid } from '@livestore/livestore'
import { useQuery, useStore } from '@livestore/react'
import React from 'react'
import { Keyboard, Pressable, StyleSheet, Text, TextInput, TouchableWithoutFeedback, View } from 'react-native'
import { uiState$ } from '../livestore/queries.ts'
import { events } from '../livestore/schema.ts'
export const NewTodo: React.FC = () => {
const { store } = useStore()
const { newTodoText } = useQuery(uiState$)
const updatedNewTodoText = (text: string) => store.commit(events.uiStateSet({ newTodoText: text }))
const todoCreated = () =>
store.commit(events.todoCreated({ id: nanoid(), text: newTodoText }), events.uiStateSet({ newTodoText: '' }))
const addRandom50 = () => {
const todos = Array.from({ length: 50 }, (_, i) => ({ id: nanoid(), text: `Todo ${i}` }))
store.commit(...todos.map((todo) => events.todoCreated(todo)))
}
const reset = () => store.commit(events.todoClearedCompleted({ deletedAt: new Date() }))
const inputRef = React.useRef<TextInput>(null)
return (
<TouchableWithoutFeedback
onPress={() => {
Keyboard.dismiss()
inputRef.current?.blur()
}}
>
<View style={styles.container}>
<TextInput
ref={inputRef}
style={styles.input}
value={newTodoText}
onChangeText={updatedNewTodoText}
onKeyPress={(e) => {
if (e.nativeEvent.key === 'Escape' || e.nativeEvent.key === 'Tab') {
Keyboard.dismiss()
inputRef.current?.blur()
}
}}
onSubmitEditing={todoCreated}
/>
<Pressable onPress={todoCreated}>
<Text style={styles.submit}>Add</Text>
</Pressable>
<Pressable onPress={addRandom50}>
<Text style={styles.submit}>Random (50)</Text>
</Pressable>
<Pressable onPress={reset}>
<Text style={styles.submit}>Clear</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
flexGrow: 0,
flexBasis: 100,
flexShrink: 0,
alignItems: 'center',
padding: 10,
width: 400,
},
input: {
height: 40,
width: 200,
margin: 12,
borderWidth: 1,
borderRadius: 6,
},
submit: {
padding: 4,
// backgroundColor: 'blue',
borderRadius: 6,
fontSize: 12,
},
})

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/ListTodos.tsx
import { queryDb } from '@livestore/livestore'
import { useQuery } from '@livestore/react'
import React from 'react'
import { FlatList } from 'react-native'
import { uiState$ } from '../livestore/queries.ts'
import { tables } from '../livestore/schema.ts'
import { Todo } from './Todo.tsx'
const visibleTodos$ = queryDb(
(get) => {
const { filter } = get(uiState$)
return tables.todos.where({
deletedAt: null,
completed: filter === 'all' ? undefined : filter === 'completed',
})
},
{ label: 'visibleTodos' },
)
export const ListTodos: React.FC = () => {
const visibleTodos = useQuery(visibleTodos$)
return (
<FlatList
data={visibleTodos}
renderItem={({ item }) => <Todo {...item} />}
keyExtractor={(item) => item.id.toString()}
initialNumToRender={20}
maxToRenderPerBatch={20}
/>
)
}

To open the devtools, run the app and from your terminal press shift + m, then select LiveStore Devtools and press Enter.

Expo Terminal Screenshot

This will open the devtools in a new tab in your default browser.

Devtools Browser Screenshot

Use the devtools to inspect the state of your LiveStore database, execute events, track performance, and more.

To open the database in Finder, run the following command in your terminal:

Terminal window
open $(find $(xcrun simctl get_app_container booted host.exp.Exponent data) -path "*/Documents/ExponentExperienceData/*livestore-expo*" -print -quit)/SQLite

For development builds, the app SQLite database is stored in the app’s Library directory.

Example: /Users/<USERNAME>/Library/Developer/CoreSimulator/Devices/<DEVICE_ID>/data/Containers/Data/Application/<APP_ID>/Documents/SQLite/app.db

To open the database in Finder, run the following command in your terminal:

Terminal window
open $(xcrun simctl get_app_container booted [APP_BUNDLE_ID] data)/Documents/SQLite

Replace [APP_BUNDLE_ID] with your app’s bundle ID. e.g. dev.livestore.livestore-expo.

  • LiveStore doesn’t yet support Expo Web (see #130)