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.
-
Set up project from template
Terminal window bunx tiged --mode=git git@github.com:livestorejs/livestore/examples/standalone/expo-todomvc my-appReplace
my-app
with your desired app name. -
Install dependencies
It’s strongly recommended to use
bun
orpnpm
for the simplest and most reliable dependency setup (see note on package management for more details).Terminal window bun installTerminal window pnpm install --node-linker=hoistedMake sure to use
--node-linker=hoisted
when installing dependencies in your project or add it to your.npmrc
file..npmrc nodeLinker=hoistedHopefully Expo will also support non-hoisted setups in the future.
Terminal window npm installWhen using
yarn
, make sure you’re using Yarn 4 or higher with thenode-modules
linker.Terminal window yarn set version stableyarn config set nodeLinker node-modulesyarn installPro tip: You can use direnv to manage environment variables.
-
Run the app
bun ios
orbun android
pnpm ios
orpnpm android
npm run ios
ornpm run android
yarn ios
oryarn android
Option B: Existing project setup
-
Install dependencies
Terminal window bunx expo install @livestore/devtools-expo @livestore/adapter-expo @livestore/livestore @livestore/react @livestore/utils @livestore/peer-deps expo-sqlite -
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
pnpm add -D babel-plugin-transform-vite-meta-env
yarn add -D babel-plugin-transform-vite-meta-env
npm install --save-dev 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'],}} -
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-metroconst { 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:
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 alivestore
folder and inside it create a file namedschema.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 atodos
.
Here’s an example:
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:
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_IDconst 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:
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:
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
.
This will open the devtools in a new tab in your default browser.
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:
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:
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)