A React Native app calls its API as a page does in direct mode: the typed client, the React Query hooks, server-sent events, WebSocket channels, uploads with their progress, and cancellation. Each is checked on Hermes, in an Expo SDK 57 app, by the example in examples/expo.
In one command
npx @noverax/create-courierIn an Expo app it adds your API at an address that suits the simulator, the emulator and a phone, React Query hooks for it, the mock Studio runs, and a Courier tab that shows it at work. See Create Courier.
Installation
npm install @noverax/courier @noverax/courier-react
npx expo install @tanstack/react-queryAn absolute address
An app has no page, so nothing resolves a path such as /api for it: the API's baseURL is an absolute URL. A path is refused before any request is sent, with a ConfigurationError whose code is URL_NOT_ABSOLUTE.
The computer that serves the API while you work on the app is localhost from the iOS simulator, 10.0.2.2 from the Android emulator, and its address on the network from a phone, which an EXPO_PUBLIC_ variable can carry:
import { Platform } from "react-native"
export const API_URL =
process.env.EXPO_PUBLIC_API_URL ??
(Platform.OS === "android" ? "http://10.0.2.2:4000" : "http://localhost:4000")The API and its client
The API is defined once, with that address, and both callers are built from it: the hooks for components, and plain calls for what a hook does not do.
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { API_URL } from "./address"
type User = { id: number; name: string }
type Stored = { name: string; size: number }
type Message = { text: string }
const { endpoint, stream, channel } = courier()
export const shop = defineApi({
baseURL: API_URL,
deduplicate: true,
groups: {
users: defineGroup({
prefix: "users",
endpoints: {
list: endpoint.get("").response<User[]>(),
search: endpoint.query("search").variables<{ name: string }>().response<User[]>(),
},
}),
files: defineGroup({
prefix: "files",
endpoints: { upload: endpoint.post("").variables<FormData>().response<Stored>() },
}),
live: defineGroup({
prefix: "live",
endpoints: {
ticks: stream.response<{ n: number }>().get("ticks"),
chat: channel.send<Message>().response<Message>().connect("chat"),
},
}),
},
})import { createCaller } from "@noverax/courier"
import { createQueryCaller } from "@noverax/courier-react"
import { QueryClient } from "@tanstack/react-query"
import { shop } from "./api"
export const q = createQueryCaller(shop)
export const client = createCaller(shop)
export const queryClient = new QueryClient()Wrap the app in QueryClientProvider with that client, and a component reads as it would on the web:
import { Text } from "react-native"
import { q } from "./client"
export function Users() {
const users = q.users.useQuery("list")
const found = q.users.useQuery("search", { variables: { name: "a" } })
return <Text>{users.data?.length} users, {found.data?.length} found</Text>
}The user's session
An API that keeps the user's session in cookies says so where it is defined, as it does for a page — see User Sessions. The platform keeps the cookies the API sets and sends them with the calls to its host, as a browser does; a call asks for them with credentials: "include".
What a page reads from document.cookie — the double-submit token a write repeats, a bearer token the API did not set HttpOnly — an app cannot read: React Native keeps its cookies where no script reaches them. It hands a script the Set-Cookie headers of each answer instead, so Courier keeps those cookies from the answers its calls receive, for the host, domain and path they were set for, until they run out or the API deletes them, and repeats them as a page does. A Domain of one label, such as com, would reach every other API of the app and is not taken; a browser refuses every public suffix by its list, co.uk too, which an app does not ship.
A token is kept for as long as the app runs. When the app starts again, the platform still sends the cookie it kept, but no write could repeat it until an answer set it again — so name where the API hands it out, and the first write that finds none asks there itself, with the user's session:
import { createCaller } from "@noverax/courier"
import { courier, defineApi } from "@noverax/courier/define"
import { API_URL } from "./address"
const { endpoint } = courier()
export const account = createCaller(
defineApi({
name: "account",
baseURL: API_URL,
session: {
cookies: ["laravel_session"],
csrf: { cookie: "XSRF-TOKEN", header: "X-XSRF-TOKEN", from: "/sanctum/csrf-cookie" },
},
endpoints: {
signIn: endpoint.post("login").variables<{ email: string; password: string }>(),
rename: endpoint.post("user/name").variables<{ name: string }>(),
},
}),
)from is a path under the API's address, such as "csrf/" for a Django view decorated with ensure_csrf_cookie, or one from the root of its origin, as Laravel Sanctum's is. Only the answers to Courier's own calls hand it the cookies: an answer to a plain fetch does not. See When there is no token yet.
Every call to the host carries its cookies
React Native sends the cookies it keeps for a host with every call to that host, whether the API describes a session or not; a browser sends them to another origin only when the call asks it to.
Streams and channels
useSubscription reads server-sent events as they arrive, which takes a fetch that hands over a response's body as it comes. React Native's own fetch hands it over only once all of it has arrived; Expo replaces it with expo/fetch, which streams. Expo SDK 57 does so for every app unless EXPO_PUBLIC_USE_RN_FETCH is set. A channel needs nothing more: React Native has WebSocket.
import { Button, Text } from "react-native"
import { q } from "./client"
export function Live() {
const ticks = q.live.useSubscription("ticks")
const chat = q.live.useChannel("chat")
return (
<>
<Text>tick {ticks.data?.n}</Text>
<Button title="Say hello" onPress={() => chat.send({ text: "hello" })} />
</>
)
}Uploads from the device
React Native's FormData takes a file on the device by its address, name and type. Courier sends a form holding one through XMLHttpRequest, which reads the file from where it is — expo/fetch refuses such a form — and reports the upload's progress when you ask for it:
import { client } from "./client"
export async function upload(uri: string, onProgress: (share: number) => void) {
const form = new FormData()
/* The DOM's FormData type has no file given by its address. */
form.append("photo", { uri, name: "photo.jpg", type: "image/jpeg" } as unknown as Blob)
return client.files.with({ onUploadProgress: (p) => onProgress(p.percent) }).upload(form)
}A form whose files the app holds as bytes — a Blob — goes with the app's fetch.
Cancellation and timeouts
A call is cancelled through its signal, and rejects with an AbortError:
const controller = new AbortController()
const pending = client.users.with({ signal: controller.signal }).list()
controller.abort()
await pending // rejects with an AbortErrorReact Native's AbortController keeps no reason, and expo/fetch reports a cancelled request as a fetch that failed; either way the call rejects with an AbortError, never with undefined or a network failure. A call whose timeout runs out rejects with an HTTPError of status 0 and the code TIMEOUT, though React Native's fetch reports it as an abort.
deduplicate: true works as it does in a browser, since an app serves one user: a newer read of an endpoint cancels the one before it. See Abort & Deduplication.
Without Expo
React Native without Expo lacks more of the web platform: AbortSignal.any and AbortSignal.timeout, crypto.randomUUID, structuredClone, DOMException, streams and TextDecoder. Courier does without each where it uses it, so calls, uploads, cancellation, timeouts, the cache middleware and channels work as they are. Server-sent events alone need more: a fetch that streams and a TextDecoder, installed as globals before the first subscription.
Working against a mock
Studio runs the mock the app calls while you work, from the project's courier.mocks.ts, and shows each request the app makes. The example's mock serves the shop on port 7020:
npm run server
npx expo startThe first starts Studio with its mock, the second the app. See Mock Servers for what a mock answers and how.