Skip to main content

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 concernSDK surfaceHost responsibility
Runtime and clientpolyfillMedplumWebAPIs, ExpoClientStorage, OvokProviderEnvironment values, auth storage, app startup
Theme and layoutThemeProvider, useAppTheme, Tab, SettingList, PickerSheetBrand overrides, navigation, screen semantics
AuthenticationSignIn, Register, ResetPassword, ProfileForm, useSessionRoutes, tenant configuration, error copy, account policy
Device measurementBTProvider, IntegratedDevices, SUPPORTED_DEVICES, BTDeviceListDevice picker, result persistence, patient association
Health importDataSync from @ovok/native/data-syncRequested types, observation codes, permission education
Forms and recordsQuestionnaireForm, ObservationDetail, MeasurementListResource queries, validation policy, navigation
Background workbackgroundSync, Android scheduling helpers, AppleHealthSync.backgroundDeliveryDurable credentials, idempotent upload, user consent
DiagnosticsconfigureOvokTelemetry, configureOvokLogger, Bluetooth errorsExporters, 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:

  • QuestionnaireForm renders FHIR questionnaires, conditional items, required-field validation, paged navigation, and either app-owned or SDK-managed submission.
  • ContentCard, ContentList, and ContentDetail render app-owned content and optional FHIR Composition fields; they do not fetch content.
  • Journal, LanguageSwitcher, Tab, SettingList, PickerSheet, and ProgressBar provide focused building blocks for app screens.
  • BreathingExercise owns a timed inhale/hold/exhale context; the host owns completion persistence and navigation.
  • PDFViewer and OptimizedImage handle native rendering/loading behavior; the host supplies URLs, retry UI, permissions, and empty states.
  • The ui subpath adds themeable app-owned flows such as PairingFlow, HealthImportCard, ManualEntryForm, EcgStripViewer, UrineTestResult, BackgroundSyncSetting, BluetoothStateNotice, SupportedDevicesCatalogList, and MeasurementTrendChart. 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.backgroundSync with durable storage and an idempotent onResult.
  • Android Health Connect: the background-sync scheduling and headless-task helpers.
  • Apple HealthKit: AppleHealthSync.backgroundDelivery with 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.