On this page

Building a React Application

A Medplum React application combines a FHIR client, authentication state, and components for reading and editing healthcare data. This guide connects those pieces into an application you can extend.

Before you start
  • Complete the React setup, including the providers and CSS imports.
  • Configure a ClientApplication for your app's sign-in flow.
  • Use a test project with synthetic data while developing the examples.

The examples below use the signed-in user's access. Follow them in order to handle authentication, display a patient search, and save a new patient.

Choose your starting point​

Starting pointUse it when
Hello WorldYou want a small application with sign-in, patient pages, and resource forms.
Medplum ProviderYou want to customize an existing clinical application with patient charts and operational workflows.
Add Medplum to an existing appYou already have routing and a design system and want to add FHIR data and selected components.

The packages have distinct roles:

  • @medplum/core: MedplumClient, FHIR requests, and shared data utilities.
  • @medplum/react-hooks: authentication context and data hooks, usable with your own UI library.
  • @medplum/react: FHIR controls and clinical components built with Mantine.

Use the same Medplum release across these packages. Requests flow from the UI through the shared client to the server:

  sequenceDiagram
    participant UI as React UI
    participant Client as MedplumClient
    participant API as Medplum API
    UI->>Client: Hook or SDK call
    Client->>API: Authenticated FHIR request
    Note over API: Authorize and validate
    API-->>Client: Result or OperationOutcome
    Client-->>UI: Data or error
    UI->>UI: Render or refresh the screen

Set up the application boundary​

Create a stable MedplumClient instance and pass it to MedplumProvider. Recreating it during each render also recreates client state such as caches and subscriptions. Browser applications use a public client ID; server-side client secrets belong in a trusted backend.

The installation guide shows the providers and CSS imports. Inside that boundary, wait for authentication to initialize before deciding whether to show the signed-in application:

import { Text } from '@mantine/core';
import { useMedplum, useMedplumProfile } from '@medplum/react-hooks';
import type { JSX, ReactNode } from 'react';
import { Fragment } from 'react';

export function AuthenticationBoundary(props: { signIn: ReactNode; children: ReactNode }): JSX.Element {
  const medplum = useMedplum();
  const profile = useMedplumProfile();

  if (medplum.isLoading()) {
    return <Text role="status">Loading your session...</Text>;
  }
  if (!profile) {
    return <>{props.signIn}</>;
  }

  return <Fragment key={medplum.getProjectMembership()?.id}>{props.children}</Fragment>;
}

Render your sign-in flow in signIn and your application's pages as children. Keying the application by the current membership remounts its local state when that membership changes.

Enforce access on the server

The boundary chooses which screen to show. Configure the membership's access policy to enforce which resources the user can read and write.

See Authentication for choosing a sign-in flow and Client Lifecycle Events for profile and storage initialization.

Search and display data​

Use useSearchResources when a component needs a page of resources. It returns the results, a loading flag, and an OperationOutcome. Handle loading, failure, and an empty result separately so that users can tell whether a search completed.

import { Alert, Group, List, Loader, Stack, Text, TextInput } from '@mantine/core';
import { isOk } from '@medplum/core';
import { ResourceName } from '@medplum/react';
import { useSearchResources } from '@medplum/react-hooks';
import type { JSX } from 'react';
import { useState } from 'react';

export function PatientSearch(): JSX.Element {
  const [name, setName] = useState('');
  const [patients, loading, outcome] = useSearchResources('Patient', {
    ...(name ? { name } : {}),
    _count: '20',
    _sort: '-_lastUpdated',
  });

  let results: JSX.Element;
  // Before the search effect runs, loading is false and both patients and outcome are undefined.
  if (loading || (!patients && !outcome)) {
    results = (
      <Group role="status">
        <Loader size="sm" aria-hidden="true" />
        <Text>Loading patients...</Text>
      </Group>
    );
  } else if (outcome && !isOk(outcome)) {
    results = (
      <Alert color="red" title="Unable to load patients">
        Check your connection and access, then try again.
      </Alert>
    );
  } else if (!patients?.length) {
    results = <Text>No matching patients.</Text>;
  } else {
    results = (
      <Stack gap="xs">
        <Text>First page of matching patients</Text>
        <List>
          {patients.map((patient) => (
            <List.Item key={patient.id}>
              <ResourceName value={patient} />
            </List.Item>
          ))}
        </List>
      </Stack>
    );
  }

  return (
    <Stack component="section">
      <TextInput label="Patient name" value={name} onChange={(event) => setName(event.currentTarget.value)} />
      {results}
    </Stack>
  );
}

This component shows the first 20 matching patients. For a paginated application, use useSearch to retain the full search Bundle and its links, or use SearchControl for a standard search interface. See Paginated Search before building your own pagination.

The search hooks debounce query changes by default. Their enabled option can defer a query until required context exists. For example, wait for a selected patient before searching that patient's records.

Choose the right data interface​

NeedInterface
Resolve a resource reference for displayuseResource(reference, setOutcome)
Render one page of matching resourcesuseSearchResources(resourceType, query)
Inspect search metadata or follow paging linksuseSearch(resourceType, query) or medplum.search()
Read a known resource for an operationmedplum.readResource(resourceType, id)
Save a resourcemedplum.createResource(), updateResource(), or patchResource()

useResource can resolve a reference from the client's cache. It does not establish a live subscription. Likewise, search results need an explicit refresh strategy when another screen or user changes the data.

Build forms around a save operation​

ResourceForm renders fields from a FHIR resource definition and calls onSubmit with the edited resource. Your application owns the save request, the error state, and what happens after success.

import { Fieldset, Text } from '@mantine/core';
import { normalizeOperationOutcome } from '@medplum/core';
import type { OperationOutcome, Patient, Resource } from '@medplum/fhirtypes';
import { ResourceForm } from '@medplum/react';
import { useMedplum } from '@medplum/react-hooks';
import type { JSX } from 'react';
import { useRef, useState } from 'react';

const newPatient: Patient = { resourceType: 'Patient' };

export function CreatePatient(props: { onCreated: (patient: Patient) => void }): JSX.Element {
  const medplum = useMedplum();
  const [outcome, setOutcome] = useState<OperationOutcome>();
  const [saving, setSaving] = useState(false);
  const savingRef = useRef(false);

  async function save(resource: Resource): Promise<void> {
    if (savingRef.current || resource.resourceType !== 'Patient') {
      return;
    }
    // Block repeated submissions before React renders the disabled fieldset.
    savingRef.current = true;
    setSaving(true);
    setOutcome(undefined);
    try {
      const saved = await medplum.createResource(resource);
      // The callback should navigate away or reset this form to avoid resubmitting the same patient.
      props.onCreated(saved);
    } catch (error) {
      setOutcome(normalizeOperationOutcome(error));
    } finally {
      savingRef.current = false;
      setSaving(false);
    }
  }

  return (
    <Fieldset legend="New patient" disabled={saving}>
      <ResourceForm defaultValue={newPatient} outcome={outcome} onSubmit={save} />
      {saving && <Text role="status">Saving patient...</Text>}
    </Fieldset>
  );
}

This example creates a new Patient and calls onCreated with the saved record. Use that callback to navigate to the patient or refresh the parent list. After success, unmount CreatePatient or reset it by changing its React key if it stays on screen; otherwise, submitting the retained values creates another Patient.

For ingestion or a workflow that may retry a business operation, define an identifier and use conditional writes to avoid duplicate records.

For an edit screen, start with the full resource and preserve its fields when saving. Use version-aware updates when concurrent edits matter. The server validates the request; showing a form or having a TypeScript type does not establish conformance to a FHIR profile. See Profiles.

Keep screens current​

After a successful write, update the screen from the saved resource or deliberately reload its data. The SDK's cache and the component's React state have different lifetimes. A write completing successfully does not mean every mounted list has rerun its query.

When a workflow needs a fresh read, use the SDK's request options, for example:

const patient = await medplum.readResource('Patient', patientId, { cache: 'reload' });

For changes made by other users, use useSubscription with narrowly scoped criteria. Refresh authoritative state when a notification arrives and after reconnecting. Notifications received while connected are useful signals; your screen also needs a way to recover changes missed while disconnected.

Choose components by workflow​

Start with the smallest component that owns the interaction you need. See Choosing React Components for a map from FHIR field controls to complete workspaces, with public examples you can adapt.

Check the complete workflow​

Test with representative project memberships and data before rollout:

  • Search: show loading, no matches, multiple pages, and a failed request.
  • Save: display validation errors, prevent repeated submission while saving, and refresh the screen after success.
  • Authentication: recover from an expired session and clear local state when switching project memberships.
  • Access and concurrency: verify restricted memberships and handle an update made by another user.