React Native
@voltro/react-native — the credential-free mobile plumbing: registerDevice + the _voltro_devices table, defineDeepLink + its matcher, useBackgroundSync, and offline-first client defaults + connection status.
The React-client bindings are import-safe in React Native — every DOM touch
in @voltro/client is typeof window-guarded, so nothing crashes at import —
and the client runtime now builds on RN: @voltro/client's
buildApiRuntime
constructs the ApiHandle (runtime + subscription cache + rpc client) over a
WebSocket you inject, so RN passes its own globalThis.WebSocket and gets
the same client stack the web app uses, without pulling in @voltro/web.
Still open before the loop is proven end-to-end on a device: the device boot
itself — everything here is unit-tested without a simulator, so booting a real
Metro runtime is the remaining verification — plus *.deepLink.ts codegen
discovery (until it lands, register links via matchFirstDeepLink(links, url)),
push sender adapters (APNs / FCM need per-tenant credentials), and
native-module bindings (camera, biometrics, secure token storage need a native
runtime). @voltro/react-native ships the
mobile-specific plumbing around that, limited to the parts that need no
per-tenant credentials and no native runtime: device registration,
background-sync scheduling, offline-first defaults, a connection-status surface,
and the deep-link declaration shape.
Building the client on React Native
startMobileApis() connects every api your app declares and keeps them
connected — over the same supervisor the web client uses, not a mobile copy
of it: exponential backoff, generation tracking, and the stale-seed gate that
must never carry one subject's rows into the next one's screens.
import { startMobileApis, toApiHandles } from '@voltro/react-native'
import { mobileApis } from './.framework/mobileApis.generated' // from `voltro codegen`
const apis = mobileApis(() => 'ws://192.168.1.20:4000/ws')
const supervisor = startMobileApis({
apis,
onChange: (clients) => setHandles(toApiHandles(apis, clients)),
})
// later: supervisor.dispose() — or supervisor.reconnect() after a sign-inbuildApiRuntime() from @voltro/client is the layer underneath, if you want
one connection without supervision:
import { buildApiRuntime } from '@voltro/client'
const built = await buildApiRuntime({
name: 'app',
wsUrl: 'ws://192.168.1.20:4000/ws', // your machine's LAN address
group: rpcGroup, // from codegen
// RN provides a global WebSocket; the web client injects a tracked one.
webSocketConstructor: (url, protocols) => new globalThis.WebSocket(url, protocols as string[]),
})
// built = { runtime, cache, client, errorBus } → an ApiHandle for the providerThe api binding is generated
voltro codegen reads the app's voltro.mobile.ts and writes
.framework/mobileApis.generated.ts — which apis this app talks to, and where
each one's rpc group and descriptors come from. The template's pnpm ios /
pnpm start scripts run it, so there is no separate step.
// voltro.mobile.ts
export default {
apis: { app: { package: '@acme/api' } },
}Only the BINDING is generated. The procedure types ride the import of the api
package's own rpcGroup, so a schema change needs no regeneration here.
localhost on a phone is the phone
The ws URL is a runtime parameter, never baked into the generated file. A device
pointed at ws://localhost:4000/ws connects to itself, times out and retries
forever — which reads as a broken framework rather than a wrong host.
resolveDevWsUrl() takes the LAN host Expo already knows:
import Constants from 'expo-constants'
import { resolveDevWsUrl } from '@voltro/react-native'
const wsUrl = process.env.EXPO_PUBLIC_API_WS_URL
?? resolveDevWsUrl(Constants.expoConfig?.hostUri, 4000)Scaffold a mobile app.
voltro create-project acme --api=api-backend --mobile(orvoltro add-app mobile --template mobile-app) scaffolds an Expo app that consumes your api with the same typed hooks. Expo owns Metro (expo start/expo run:ios), notvoltro dev. See themobile-apptemplate.
The package root is RN-safe — no node:*, no @voltro/database, and React
is reached only through the hooks (an optional peer). The _voltro_devices table
declaration is server-side and lives at @voltro/react-native/schema.
Persisted stores on a device
defineStore({ persist }) reads during RENDER, and a render cannot await — so a
device's async storage cannot back it directly. createAsyncStoragePersistence()
hydrates the keys into memory once, then serves reads from memory and writes
through asynchronously.
import AsyncStorage from '@react-native-async-storage/async-storage'
import { setStoreStorage } from '@voltro/client'
import { createAsyncStoragePersistence } from '@voltro/react-native'
const persistence = createAsyncStoragePersistence({ storage: AsyncStorage })
await persistence.hydrate() // BEFORE the first render
setStoreStorage(persistence.provider)hydrate() must be awaited before rendering. An app that renders first shows
empty state and flickers into the saved state a frame later; the template holds
the Expo splash screen until it resolves. Writes to one key coalesce per tick, so
a store written on every keystroke costs one round trip, and a failed write is
reported through onError rather than thrown into the set() that caused it.
A storage with no getAllKeys() and no explicit keys: [...] is a REFUSAL, not
an empty hydration — an empty cache is indistinguishable from a first run, which
is the hardest persistence bug there is to attribute.
Device registration
A device is registered after the OS issues its push token (APNs on iOS, FCM
on Android, Web Push on web). registerDevice() normalises a raw input into a
row and upserts it through whatever transport the app already has — a generated
mutation caller or a plain fetch — so the package stays free of transport
coupling.
import { registerDevice } from '@voltro/react-native'
// `userId`/`tenantId` are stamped SERVER-side from the authenticated request —
// never trusted from the client. `locale`/`timezone` default from the device.
await registerDevice(
(row) => api.mutate('registerDevice', row),
{ deviceToken, platform: 'ios' },
)Registration is idempotent: the row is stored in _voltro_devices with a unique
key of (platform, token), so re-registering the same token updates the row in
place instead of inserting a duplicate. A rotated token is a new
registration; reaping the stale one is the sender adapter's job (a seam), not the
client's.
The _voltro_devices table
The table declaration is a server-side entry — it imports the @voltro/database
column DSL, so it is deliberately off the RN-safe root. Contribute it to your
schema and it migrates like any framework table (the _voltro_* prefix rides the
declarative differ on voltro dev / voltro db apply, on every dialect):
// schema/devices.ts — add the framework device table to your app's schema.
export { devicesTable } from '@voltro/react-native/schema'It carries tenant + user scope, platform, token, locale, timezone,
optional appVersion/metadata, and a lastSeenAt rotation clock. It is unique
on (platform, token) and indexed on userId — the hot read path for fanning a
push out to every device of a user.
Deep links: defineDeepLink + the matcher
defineDeepLink({ pattern, handler }) is the descriptor a deep-link file
declares; its pure matcher turns /orders/:id + /orders/42 into { id: '42' }.
The params are inferred from the :name segments, so handler type-checks
against exactly the params the pattern declares.
import { defineDeepLink } from '@voltro/react-native'
export default defineDeepLink({
pattern: '/orders/:id',
handler: ({ id }) => navigateTo(`/orders/${id}`),
})The matcher is pure — no navigation, no side effects — and normalises scheme + host away, so a universal link, an App Link, and a custom-scheme URL all match the same path-only pattern:
import { dispatchDeepLink, matchDeepLink } from '@voltro/react-native'
import orderLink from './orders.deepLink'
matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' }
matchDeepLink('/orders/:id', '/orders/42/edit') // → null
// Until `*.deepLink.ts` file discovery lands, register links by hand —
// declaration order wins, so list more-specific patterns first.
const params = dispatchDeepLink([orderLink], 'myapp://orders/42')
params?.id // '42' — and the winning handler has already runUse dispatchDeepLink for a TABLE, runDeepLink for one descriptor.
matchFirstDeepLink also exists and only inspects: because a table is a
heterogeneous array, the descriptor it returns has an erased pattern, so its
handler's declared params (Record<string, never>) reject the params returned
alongside it. Matching and invoking in two steps therefore does not typecheck —
which is why the dispatching version exists rather than being left to every
caller to cast around.
Seam — file discovery. Wiring
*.deepLink.tsinto codegen (so the router auto-collects every declared link) is one additive file, landing after the current release settles. The descriptor shape above is final, so register links viadispatchDeepLink()until then.
Background sync
useBackgroundSync(onSync, options) owns three triggers — an interval timer, a
"returned to foreground" subscription, and a manual sync() — over a pure
shouldSync policy (single-flight, foreground-gated, interval-gated). The OS
background-fetch registration itself stays the app's; this hook is only the
interval/foreground state machine.
import { useBackgroundSync } from '@voltro/react-native'
function SyncIndicator() {
const { status, lastSyncAt, sync } = useBackgroundSync(
() => api.refetchAll(),
{ intervalMs: 60_000, syncOnForeground: true },
)
return <button onClick={sync}>Sync ({status})</button>
}onSync may be async — a rejection is captured into status: 'error' +
lastError, a resolution into status: 'success' + lastSyncAt.
Offline-first defaults + connection status
offlineFirstDefaults is the mobile posture as a value you spread into your
client config: local-first ON, optimistic mutations, sync-on-foreground, a
5-minute cadence, and a retry backoff schedule. useMobileConnectionStatus()
surfaces a connected | degraded | offline status.
Where "online" comes from is injected, not detected. The default source reads
navigator.onLine, which React Native does not have — so on a device it answers
"online" forever, airplane mode included. Pass netInfoOnlineSource(NetInfo):
import NetInfo from '@react-native-community/netinfo'
import { netInfoOnlineSource, offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'
// Module scope: an inline call is a new object every render, and the hook would
// resubscribe on each one.
const onlineSource = netInfoOnlineSource(NetInfo)
const config = { ...offlineFirstDefaults, url }
function ConnectionPill() {
const { status, reportFailure, reportSuccess } = useMobileConnectionStatus({ onlineSource })
return <span data-status={status}>{status}</span>
}isInternetReachable is believed only when it is a boolean. NetInfo reports
null while its probe is outstanding, and reading that as false flashes
"offline" on every cold start and every network change — so a null falls back
to the link-layer isConnected.
What's shipped vs. a seam
This package ships the credential-free plumbing above. The parts that need external credentials or a native runtime are flagged as deliberate seams — not built here:
| Seam | Why it is not in this package |
|---|---|
| APNs / FCM sender adapters | Need per-tenant Apple Developer / Firebase credentials — genuinely external, managed via provider provisioning. Registration stores the token; sending to it is the seam. |
| Native module bindings (camera, biometrics, secure token storage) | Need a native runtime this TS package cannot provide. |
| Swift / Kotlin SDK generators | Built + golden-tested — voltro build api --target swift|kotlin emits a native SDK package. What is deferred is compiling the emitted package (swiftc / Gradle): that is a mobile-CI step, no cross-language toolchain lives in the framework repo. |
Universal-links / App-Links file automation (apple-app-site-association, assetlinks.json) |
A deployment-layer concern, not a client primitive. |
*.deepLink.ts codegen discovery |
One additive file after the release settles; the descriptor shape is final, so register links via dispatchDeepLink() today. |
| Booting on a device | Everything above is unit-tested without a simulator, and a simulator is the only thing that can prove the loop runs under Metro. That is an Expo/EAS CI step — the framework repository has no iOS/Android toolchain, and we say so rather than implying coverage we do not have. |