Skip to content

Expo

Prerequisites

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

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

    bun ios or bun android

Option B: Existing project setup

  1. Install dependencies

    Terminal window
    bunx expo 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.

    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

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'
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 }),
})
export const todoEditingStarted = Events.synced({
name: 'v1.TodoEditingStarted',
schema: Schema.Struct({ id: Schema.String }),
})
export const todoEditingFinished = Events.synced({
name: 'v1.TodoEditingFinished',
schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
})

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.ts'
import * as eventsDefs from './events.ts'
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 }),
editing: State.SQLite.boolean({ default: false }),
},
})
const uiState = State.SQLite.clientDocument({
name: 'uiState',
schema: Schema.Struct({ newTodoText: Schema.String, filter: Filter }),
default: {
id: SessionIdSymbol,
value: { newTodoText: '', filter: 'all' as Filter },
},
})
export type Todo = State.SQLite.FromTable.RowDecoded<typeof todos>
export type UiState = typeof uiState.default.value
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 }),
'v1.TodoEditingStarted': ({ id }) => todos.update({ editing: true }).where({ id }),
'v1.TodoEditingFinished': ({ id, text }) => todos.update({ editing: false, text }).where({ id }),
})
const state = State.SQLite.makeState({ tables, materializers })
export const schema = makeSchema({ events, state })

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.

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
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}
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' }))
}
}}
adapter={adapter}
batchUpdates={batchUpdates}
storeId={storeId}
syncPayload={{ authToken: 'insecure-token-change-me' }}
>
<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,
},
})

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/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 { app$ } from '../livestore/queries.ts'
import { events } from '../livestore/schema.ts'
export const NewTodo: React.FC = () => {
const { store } = useStore()
const { newTodoText } = useQuery(app$)
const updatedNewTodoText = (text: string) => store.commit(events.uiStateSet({ newTodoText: text }))
const todoCreated = () =>
store.commit(
events.todoCreated({ id: new Date().toISOString(), 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) => {
console.log(e.nativeEvent.key)
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,
},
})

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/ListTodos.tsx
import { queryDb } from '@livestore/livestore'
import { useQuery } from '@livestore/react'
import React from 'react'
import { FlatList } from 'react-native'
import { app$ } from '../livestore/queries.ts'
import { tables } from '../livestore/schema.ts'
import { Todo } from './Todo.tsx'
const visibleTodos$ = queryDb(
(get) => {
const { filter } = get(app$)
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}
/>
)
}

Devtools

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.

Database location

With Expo Go

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

With development builds

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.

Further notes

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