Build an app with the SDK
This tutorial shows how the SDK pieces fit together in a real mobile application. It is intentionally app-owned at the edges: the SDK supplies rendering, native adapters, protocol handling, and typed state, while the host chooses navigation, patient selection, persistence, upload policy, and analytics.
The composition map
| App concern | SDK surface | Host responsibility |
|---|---|---|
| Runtime and client | polyfillMedplumWebAPIs, ExpoClientStorage, OvokProvider | Environment values, auth storage, app startup |
| Theme and layout | ThemeProvider, useAppTheme, Tab, SettingList, PickerSheet | Brand overrides, navigation, screen semantics |
| Authentication | SignIn, Register, ResetPassword, ProfileForm, useSession | Routes, tenant configuration, error copy, account policy |
| Device measurement | BTProvider, IntegratedDevices, SUPPORTED_DEVICES, BTDeviceList | Device picker, result persistence, patient association |
| Health import | DataSync from @ovok/native/data-sync | Requested types, observation codes, permission education |
| Forms and records | QuestionnaireForm, ObservationDetail, MeasurementList | Resource queries, validation policy, navigation |
| Background work | backgroundSync, Android scheduling helpers, AppleHealthSync.backgroundDelivery | Durable credentials, idempotent upload, user consent |
| Diagnostics | configureOvokTelemetry, configureOvokLogger, Bluetooth errors | Exporters, redaction policy, user-facing recovery |
1. Mount the app shell
Create the client and mount the shared providers once. Keep the client and BLE manager outside component renders so they retain their native identity across navigation.
import { OvokClient, OvokProvider } from "@ovok/core";
import {
DEFAULT_COLORS,
DEFAULT_MULTIPLIERS,
ExpoClientStorage,
ThemeProvider,
polyfillMedplumWebAPIs,
} from "@ovok/native";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { KeyboardProvider } from "react-native-keyboard-controller";
polyfillMedplumWebAPIs();
const client = new OvokClient({
baseUrl: process.env.EXPO_PUBLIC_OVOK_BASE_URL,
fhirUrlPath: "/fhir",
storage: new ExpoClientStorage(),
});
export function AppProviders({ children }) {
return (
<KeyboardProvider>
<OvokProvider client={client}>
<ThemeProvider
theme={{
colors: DEFAULT_COLORS,
dark: false,
spacingMultiplier: DEFAULT_MULTIPLIERS.spacing,
borderRadiusMultiplier: DEFAULT_MULTIPLIERS.borderRadius,
}}
>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</ThemeProvider>
</OvokProvider>
</KeyboardProvider>
);
}
OvokProvider is from @ovok/core, not from the native package. Add the navigation
provider used by the app inside this shell, and add SafeAreaProvider when the app's
navigation setup requires it. BottomSheetModalProvider is required by
PickerSheet and ManuallyRequestSheet; it is imported directly from
@gorhom/bottom-sheet.
2. Add authentication and session state
Start with the SDK's complete auth screens. The compound children can be rearranged or styled without replacing their validation, MFA, rate-limit, and client integration.
import { SignIn } from "@ovok/native";
export function LoginScreen({ onSignedIn }) {
return (
<SignIn>
<SignIn.Header>
<SignIn.Header.Title />
<SignIn.Header.Description />
</SignIn.Header>
<SignIn.EmailForm
loginType="Patient"
tenantCode={process.env.EXPO_PUBLIC_TENANT_CODE}
onSuccess={onSignedIn}
onError={reportAuthError}
>
<SignIn.EmailForm.Inputs />
<SignIn.EmailForm.ForgotPassword />
<SignIn.EmailForm.SigninButton />
</SignIn.EmailForm>
<SignIn.RegisterLink onPress={openRegistration} />
</SignIn>
);
}
Use Register, ResetPassword, ProfileForm, LogoutButton, and
DeleteAccountButton for the other account flows. Use useSession for route guards
and SessionList for a security screen. The host should decide where to navigate on
success, how to explain a rate limit, and whether account deletion requires an extra
confirmation step.
3. Add Bluetooth measurement
Choose an exact catalog entry when the app supports a known device family. Use
SUPPORTED_DEVICES to render the picker and pass its acceptedDevices to the provider.
import { BleManager } from "react-native-ble-plx";
import { BTProvider } from "@ovok/native/bt-management";
import { SUPPORTED_DEVICES } from "@ovok/native/bt-device";
const bleManager = new BleManager();
const bloodPressure = SUPPORTED_DEVICES.find(
(entry) => entry.name === "Viatom BP2",
);
export function MeasurementFlow({ children, saveResult }) {
if (!bloodPressure) {
return null;
}
return (
<BTProvider
bleManager={bleManager}
acceptedDevices={bloodPressure.acceptedDevices}
onDeviceFound={(device) => device.connect()}
onResult={({ id, deviceData, data }) => {
void saveResult({ id, deviceData, data });
}}
onError={({ error, deviceData }) => {
reportBluetoothError(error, deviceData);
}}
>
{children}
</BTProvider>
);
}
Render BTDeviceList or SupportedDeviceList for selection, and use BTDeviceInstruction
or the @ovok/native/bt-device-instructions entry point when a pairing manual is part
of onboarding. onDeviceFound can connect immediately for a single-device flow; when
several peripherals may match, configure onDeviceSelectionRequired and let the user
choose before calling select(device).
For devices outside the catalog, validate a defineCustomDevice declaration and add it
to the same acceptedDevices tuple. Do not copy a private protocol class into the app.
4. Persist and display results
The Bluetooth callback is deliberately not a database callback. Associate the result
with the active patient, apply the app's retention policy, and upload it through the
client or an app service. Use the stable result id as the backend idempotency key;
history and background delivery can retry the same result.
For a simple list screen, provide the app's queried records to MeasurementList. For a
record detail screen, compose ObservationDetail with DateTime, DataContainer,
SingleCardDataRow, TextData, Resource, and PhotoPreview. Use Tile or DiaryCard
for dashboard summaries; those components render state but do not fetch the measurement.
5. Import Apple Health or Health Connect data
Keep platform authorization outside the generic app shell because the identifiers and native permissions differ:
import { DataSync } from "@ovok/native/data-sync";
import { HKQuantityTypeIdentifier } from "@kingstinct/react-native-healthkit";
export function AppleHealthScreen() {
return (
<DataSync.AppleHealthAuthorizationProvider
readIdentifiers={[HKQuantityTypeIdentifier.heartRate]}
renderManualRequestUI={(requestAccess) => (
<DataSync.ManuallyRequestSheet>
<DataSync.ManuallyRequestSheet.Container>
<DataSync.ManuallyRequestSheet.Title>
Connect Apple Health
</DataSync.ManuallyRequestSheet.Title>
<DataSync.ManuallyRequestSheet.RequestButton
onPress={requestAccess}
>
Continue
</DataSync.ManuallyRequestSheet.RequestButton>
</DataSync.ManuallyRequestSheet.Container>
</DataSync.ManuallyRequestSheet>
)}
skipRequest={false}
>
<DataSync.AppleHealthSync dataToSync={appleDataToSync} />
</DataSync.AppleHealthAuthorizationProvider>
);
}
Use AndroidHealthConnectAuthorizationProvider and AndroidHealthSync on Android.
Map each platform identifier to the correct ObservationCode, render
SyncProgressList from onProgress, and treat failed, offline, and wifi-only
as states the host must explain or retry. Authorization does not imply that every
read type is available; preserve the provider's partial status information.
6. Build forms and content screens
The remaining UI modules are composable presentation surfaces:
QuestionnaireFormrenders FHIR questionnaires, conditional items, required-field validation, paged navigation, and either app-owned or SDK-managed submission.ContentCard,ContentList, andContentDetailrender app-owned content and optional FHIRCompositionfields; they do not fetch content.Journal,LanguageSwitcher,Tab,SettingList,PickerSheet, andProgressBarprovide focused building blocks for app screens.BreathingExerciseowns a timed inhale/hold/exhale context; the host owns completion persistence and navigation.PDFViewerandOptimizedImagehandle native rendering/loading behavior; the host supplies URLs, retry UI, permissions, and empty states.- The
uisubpath adds themeable app-owned flows such asPairingFlow,HealthImportCard,ManualEntryForm,EcgStripViewer,UrineTestResult,BackgroundSyncSetting,BluetoothStateNotice,SupportedDevicesCatalogList, andMeasurementTrendChart. These flows accept callbacks and slots rather than owning navigation or persistence.
7. Add background delivery last
Enable only the background path the app actually needs:
- BLE:
BTProvider.backgroundSyncwith durable storage and an idempotentonResult. - Android Health Connect: the background-sync scheduling and headless-task helpers.
- Apple HealthKit:
AppleHealthSync.backgroundDeliverywith the HealthKit plugin's background configuration.
Background callbacks may run after the UI and in-memory state are gone. Store the minimum credentials needed to restore the client with the background-safe storage adapter, never assume exactly-once delivery, and keep an explicit foreground retry path. Read the background sync guide before enabling native services.
Verification checklist
Before shipping a feature, test the complete boundary rather than only rendering the component:
- fresh install and permission denial;
- signed-out, signed-in, and expired-session states;
- one live Bluetooth reading and one repeated/history reading;
- duplicate result delivery using the same result ID;
- partial HealthKit/Health Connect authorization;
- offline pause and resume;
- app restart during a queued background delivery;
- dark theme, small screens, keyboard, and safe-area insets;
- translated labels and host-provided accessibility semantics;
- a native development build on every platform you ship.
The public API map, Bluetooth guide, health data guide, and component reference are the next pages to consult when a feature needs more detail.