Valet

Expo & React Native

Set up Valet in an Expo or React Native project.

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config')
const { withValet } = require('valet-dev/metro')

module.exports = withValet(getDefaultConfig(__dirname))

That single change configures Metro to resolve Valet's package exports and load .wasm assets. The rest of your Valet code -- schema, queries, mutations, and React hooks -- works identically to a web project.

Install

bun add valet-dev

Or with npm:

npm install valet-dev

Metro configuration

The withValet() wrapper does two things:

  1. Enables unstable_enablePackageExports so Metro resolves sub-package imports like 'valet-dev/server', 'valet-dev/react', and 'valet-dev/expo'.
  2. Adds wasm to the asset extensions list so the SQLite WASM backend loads on Expo web.

If you already have a custom Metro config, pass it through withValet last:

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config')
const { withValet } = require('valet-dev/metro')

const config = getDefaultConfig(__dirname)

// Your existing customizations
config.resolver.sourceExts.push('cjs')

module.exports = withValet(config)

Define your schema

Schema definitions use the same API as web projects:

// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
    }).sync({
        filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
        mode: 'full',
    }),
})

Write queries and mutations

// valet/todos.ts
import { defineQuery, defineMutation, v } from 'valet-dev/server'

export const list = defineQuery({
    args: {},
    execution: 'local',
    handler: async (ctx) => {
        return ctx.db.query('todos').collect()
    },
})

export const create = defineMutation({
    args: { title: v.string() },
    handler: async (ctx, args) => {
        return ctx.db.insert('todos', {
            title: args.title,
            completed: 0,
            userId: ctx.auth?.userId ?? '',
        })
    },
})

Run codegen to generate typed hooks:

npx valet-dev codegen

Set up ValetProvider

The generated ValetProvider handles local database initialization, SyncEngine creation, and WebSocket connection. Pass the server URL as a prop:

// App.tsx
import { ValetProvider } from './_generated/valet/react'

export default function App() {
    return (
        <ValetProvider url={process.env.VALET_PROJECT_URL!}>
            <TodoList />
        </ValetProvider>
    )
}

If your sync filters reference ctx.auth, pass an authToken prop:

// App.tsx
import { ValetProvider } from './_generated/valet/react'

export default function App() {
    return (
        <ValetProvider
            url={process.env.VALET_PROJECT_URL!}
            authToken={userToken}
        >
            <TodoList />
        </ValetProvider>
    )
}

Use hooks in components

The generated useQuery and useMutation hooks work the same way on native and web:

// components/TodoList.tsx
import { useQuery, useMutation } from '../_generated/valet/react'
import { api } from '../_generated/valet/api'
import { View, Text, Button, FlatList } from 'react-native'

function TodoList() {
    const { data: todos, isLoading } = useQuery(api.todos.list, {})
    const createTodo = useMutation(api.todos.create)

    if (isLoading) return <Text>Loading...</Text>

    return (
        <View>
            <FlatList
                data={todos}
                keyExtractor={(item) => item._id}
                renderItem={({ item }) => <Text>{item.title}</Text>}
            />
            <Button
                title="Add Todo"
                onPress={() => createTodo.mutate({ title: 'New task' })}
            />
        </View>
    )
}

Platform differences

Valet uses different SQLite backends depending on the platform:

PlatformSQLite Backend
iOSNative SQLite via expo-sqlite
AndroidNative SQLite via expo-sqlite
Expo WebSQLite compiled to WASM

The API is identical across all three platforms. The SyncEngine abstracts the storage layer so your queries, mutations, and sync filters behave the same everywhere.

iOS and Android

On native platforms, Valet uses the device's built-in SQLite through expo-sqlite. No additional setup is required beyond the Metro configuration above.

Expo web

Expo web support uses a WASM-compiled SQLite backend. The withValet() Metro wrapper adds .wasm to the asset extensions so the WASM file loads correctly during bundling.

Offline support

Offline support works natively on mobile. When the device loses connectivity:

  • Local queries (execution: 'local') continue to read from the on-device SQLite database.
  • Mutations execute locally against the SQLite database and queue for sync.
  • When connectivity returns, Valet reconnects, rebases pending mutations against current server state, and flushes the mutation log.

No additional configuration is needed. The SyncEngine, mutation log, and rebase logic are initialized automatically by the generated ValetProvider.

You can monitor sync status in your UI with the usePendingMutationCount and useConnectionState hooks:

// components/SyncStatus.tsx
import {
    useConnectionState,
    usePendingMutationCount,
} from '../_generated/valet/react'
import { Text } from 'react-native'

function SyncStatus() {
    const connectionState = useConnectionState()
    const pendingCount = usePendingMutationCount()

    if (connectionState === 'connected' && pendingCount === 0) {
        return <Text>Synced</Text>
    }

    if (pendingCount > 0) {
        return <Text>{pendingCount} changes pending</Text>
    }

    return <Text>{connectionState}</Text>
}

Running codegen in watch mode

During development, run codegen in watch mode so your types stay up to date as you edit schema and handler files:

npx valet-dev codegen --watch

On this page