@expo/hub-client
Render live iOS simulators and Android emulators in a React app. Connect through
Expo Device Hub, show the device with
DeviceScreen, and send touch, keyboard, and device controls.
Requirements
- A running Expo Device Hub server. Either start an Expo app that has the
expo-device-hubDevTools plugin installed, or runnpx expo-device-hubstandalone. The provider connects to the Hub's/vendor/serve-simand/vendor/serve-emuroutes. - React 18 or newer.
- A browser to show the video and send input.
Install
npm install @expo/hub-client
Connect to a device
Wrap your screen, controls and metrics in DeviceClientProvider. They share one
connection, and each component reads the client properties it needs.
Add the provider
Wrap the components that need the device connection. The components below all use this provider:
import { DeviceClientProvider } from "@expo/hub-client";
export function LiveSession({ udid }: { udid: string }) {
return (
<DeviceClientProvider
key={udid}
platform="ios"
options={{
baseUrl: "http://localhost:3400/vendor/serve-sim",
device: udid,
streamMode: "webrtc",
}}
>
<Screen />
<Controls />
<Cpu />
</DeviceClientProvider>
);
}
For Android, use platform="android", your serve-emu URL, and an adb serial such as
'emulator-5554' for device.
When using the Expo DevTools plugin, the server URL includes
/_expo/plugins/expo-device-hub before /vendor/serve-sim or /vendor/serve-emu.
streamMode is required: 'mjpeg', 'h264', or 'webrtc'. iOS supports all three.
Android maps a mode it cannot serve to one it can.
Use one provider per session. When switching sessions, set its key to the session ID
so the old connection closes. Set options.enabled to false to disconnect, or use
platform={null} while no device is selected.
Read state and controls
Destructure everything you need from one useDeviceClient() call:
import { useDeviceClient } from "@expo/hub-client";
function Controls() {
const { status, pressButton, rotate, reload } = useDeviceClient();
const connected = status === "streaming";
return (
<div>
<span>{status}</span>
<button disabled={!connected} onClick={() => pressButton("home")}>
Home
</button>
<button disabled={!connected} onClick={rotate}>
Rotate
</button>
<button disabled={!connected} onClick={reload}>
Reload
</button>
</div>
);
}
Status and control changes update this component. FPS, metrics, and log changes do not.
Status can be 'idle', 'connecting', 'reconnecting', 'streaming' or 'error'.
'connecting' means that the screen has not shown a frame yet: show loading UI. It becomes
'error' after 30 seconds without a frame, and the client continues to retry. After the
first frame, a lost stream is 'reconnecting': the last frame stays, so show no loading UI.
On iOS, a reconnect that lasts 30 seconds becomes 'error', and the client continues to retry.
framePresented tells whether the screen has shown a frame. A new device, server, token,
transport, or display surface starts again at 'connecting'. Read error for the last
failure message.
Read device settings
deviceSettingsStatus reports settings availability on both platforms, independently
of video and app activity. It is 'idle' while disabled, 'loading' during discovery
and the first read, then 'ready' or 'error'. Settings reads time out after five
seconds; 'ready' can include empty settings.
import { useDeviceClient } from "@expo/hub-client";
function DeviceSettingsSummary() {
const { deviceSettingsStatus, deviceSettings } = useDeviceClient();
if (deviceSettingsStatus === "idle") return null;
if (deviceSettingsStatus === "loading") return <span>Loading settings…</span>;
if (deviceSettingsStatus === "error") return <span>Settings unavailable</span>;
return <span>Appearance: {deviceSettings?.appearance ?? "Unavailable"}</span>;
}
Failed refreshes report 'error' and retain cached values. Use 'ready' to enable
controls; recovery restores it without showing the initial loading state again.
Show the screen
Use useDeviceScreenClient with DeviceScreen. It follows screen and input changes
without updating for metrics or logs.
DeviceScreen fills its parent, so give the parent a size and position: relative.
displayScreen gives you the screen size after accounting for rotation.
DeviceScreen draws no background, loading state, or error: draw them yourself, over
it, from status, error, and framePresented.
import { DeviceScreen, displayScreen, useDeviceScreenClient } from "@expo/hub-client";
function Screen() {
const client = useDeviceScreenClient();
const screen = displayScreen(client.screen);
const aspectRatio = screen ? `${screen.width} / ${screen.height}` : "9 / 19.5";
const loading = client.status !== "error" && client.status !== "idle" && !client.framePresented;
return (
<div style={{ position: "relative", width: 360, aspectRatio, background: "#eee" }}>
<DeviceScreen client={client} />
{loading && <Spinner />}
{client.status === "error" && <p role="alert">{client.error}</p>}
</div>
);
}
Show the screen in its device shape
Import DeviceShape from @expo/hub-client/shapes to clip the screen to the shape
of its device: the corners of each iPhone, iPad, and Pixel model, and the shape of the
display that an iPhone Duo shows. Like DeviceScreen, it takes the client, and it turns
with the device. It only clips, so it draws nothing and pointer input keeps working. A
model without a known shape gets no clip. Apps that do not import
@expo/hub-client/shapes do not bundle the shapes.
import { DeviceScreen, useDeviceClient } from "@expo/hub-client";
import { DeviceShape, useScreenShape } from "@expo/hub-client/shapes";
function ShapedScreen({ model }: { model: string }) {
const client = useDeviceClient();
// The shape as the viewer sees it, turned with the device, for the box's proportions.
const shape = useScreenShape({ client, model });
const aspectRatio = shape ? `${shape.width} / ${shape.height}` : "9 / 19.5";
return (
<div style={{ position: "relative", width: 360, aspectRatio }}>
<DeviceShape client={client} model={model} style={{ position: "absolute", inset: 0 }}>
<DeviceScreen client={client} />
</DeviceShape>
</div>
);
}
The client's device name is the default model, but a simulator can report its name
late. Pass model when you know it: the simulator device type name on iOS, such as
"iPhone 17 Pro", or the AVD hardware profile id on Android, such as "pixel_9".
platform, display, orientation, and shape also override the client, and
without a client they are the only source, for example for a recording:
<DeviceShape
platform="ios"
model="iPhone Duo"
display="inner"
style={{ position: "absolute", inset: 0 }}
>
<video src={recordingUrl} />
</DeviceShape>
useScreenShape returns the same shape for layout, such as the box's aspect ratio above.
The clip keeps the corners right for the shape's own proportions, so give the box those
proportions, or pass the box's aspectRatio to DeviceShape.
screenShapeClipPath returns the CSS clip-path for an element that you cannot wrap.
Fold a foldable device
client.fold has the fold state of a foldable device, such as the iPhone Duo, and is
null for other devices. client.capabilities.fold lists the commands that the device
accepts: an angle range, the named poses with their hinge angles, and Table Mode. It is
false when the device offers no fold commands. Send only what it lists.
import { useDeviceClient } from "@expo/hub-client";
function FoldButtons() {
const { fold, capabilities } = useDeviceClient();
const controls = capabilities.fold;
if (!fold || !controls) return null;
const range = controls.angle;
return (
<>
<span>
{fold.angle ?? "?"}° {fold.pose ?? "custom"} {fold.pending ? "(moving)" : ""}
</span>
{controls.poses.book && (
<button onClick={() => fold.setControl({ control: "pose", value: "book" })}>Book</button>
)}
{range && (
<button onClick={() => fold.setControl({ control: "angle", value: range.max })}>
Most open
</button>
)}
{controls.tableMode && fold.tableMode?.available && (
<button onClick={() => fold.setControl({ control: "tableMode", value: true })}>
Table Mode
</button>
)}
</>
);
}
fold.tableMode and fold.physical are present only on a device that has them. The
poses share one vocabulary, FOLD_POSES: closed, open, laptop, book, and
tent. fold.observe reports the steps of each fold command, from its request to its
outcome, whichever control sent it. A queued command that a newer one replaces gets only
its request.
Show each display of a foldable
On an iPhone Duo, client.displays lists the cover and the inner display. Attach
one element per display, of the kind client.videoKind names, to stream each display
yourself. While any display has an element, the client parks the flat stream, and
status follows the active display. Send input to a display with its id, in that
display's coordinates. The client drops a gesture that begins on a display that the
device does not present (client.screen.display).
import { useDeviceClient } from "@expo/hub-client";
function Displays() {
const client = useDeviceClient();
// A tap in the middle of the inner display.
const tapInner = () => {
client.sendTouch({ phase: "begin", display: "inner", x: 0.5, y: 0.5 });
client.sendTouch({ phase: "end", display: "inner", x: 0.5, y: 0.5 });
};
return (
<>
{client.displays?.map((display) =>
client.videoKind === "video" ? (
<video key={display.id} ref={display.attachVideo} autoPlay muted playsInline />
) : client.videoKind === "canvas" ? (
<canvas key={display.id} ref={display.attachVideo} />
) : (
<img key={display.id} ref={display.attachVideo} alt="" />
),
)}
<button onClick={tapInner}>Tap the inner display</button>
</>
);
}
deviceModelUrl is a 3D model of the device, Xcode's for the iPhone Duo. The Hub's 3D
preview uses it with displays and fold.observe.
Show CPU usage
Use useDeviceClientSelector when you need one value from a larger object. This component
updates when the latest CPU percentage changes, even if memory or network readings change.
import { useDeviceClientSelector } from "@expo/hub-client";
function Cpu() {
const cpuPct = useDeviceClientSelector((client) => client.activity?.samples.at(-1)?.cpuPct);
return <span>{cpuPct == null ? "Waiting for metrics" : `CPU ${cpuPct.toFixed(1)}%`}</span>;
}
Reading activity with useDeviceClient would update the component whenever any part
of activity changes.
Keep updates focused
- Read state in your component body so it updates with the device. Data read only in an
event handler can be out of date. Controls such as
client.rotate()use the latest callback. - Pick named properties when destructuring. Spreading the client or using
{ status, ...rest }subscribes to every property. Readingactivity.samplessubscribes to the wholeactivityobject. - Keep selectors simple. Return a number, string, boolean, or an existing object. If you build a new object, pass a comparison function as the second argument.
- Treat the client and its data as read-only.
Connect to a token-gated Hub
A Hub started with npx expo-device-hub --require-token needs its session token on every
request. The Hub dashboard itself needs nothing extra: opening the link with the token gives
the browser a cookie that covers every request. A page on another origin has no such cookie,
so pass the token to the provider:
<DeviceClientProvider
key={udid}
platform="ios"
options={{
baseUrl: "http://127.0.0.1:3400/vendor/serve-sim",
device: udid,
streamMode: "mjpeg",
token,
}}
>
<Screen />
</DeviceClientProvider>
For Android, use platform="android" and the /vendor/serve-emu URL.
The client sends the token as Authorization: Bearer <token>, as a serve-sim.token.<token> or
serve-emu.token.<token>
WebSocket subprotocol, and as ?token= only where a browser cannot set a header (the MJPEG
<img>, EventSource).
The token does not replace CORS. Start the Hub with --cors-origin and the origin of your page,
once for each origin:
npx expo-device-hub --require-token --cors-origin https://example.com
The flag applies to iOS and Android. Without it, only a page on a loopback origin, such as
http://localhost:5173, can read the Hub's responses. For Android, the Hub must also listen on
a loopback address. On that page, Android refuses HTTP requests that change device state, such
as rotate, screenshot, and device settings. iOS refuses serve-sim's control socket, which carries
logs, events, metrics, device settings, location, and app actions.
Call device controls
Every control lives on the DeviceClient. Controls are no-ops while nothing is connected,
and each backend ignores the buttons its platform does not have.
// Hardware buttons: 'home' | 'back' | 'recents' | 'power' | 'appSwitcher' | 'hideKeyboard'
client.pressButton("home");
client.rotate();
// client.capabilities.orientation is false, or { orientations: DeviceOrientation[] } with the
// poses that setOrientation accepts. client.orientation shows the choice at once, then the
// backend's report; read client.orientationPending and client.orientationError like location.
client.setOrientation("landscape_left");
// Observed iOS devices separate app screenOrientation from physical deviceOrientation.
// A null observation is unknown; omitted fields mean the helper lacks this contract.
// screen.orientation follows the app interface in this mode; displayScreen/streamGeometry
// use physical pose for framing and map video/input accordingly. Duo and older helpers
// retain their existing geometry. client.orientation follows physical pose on observed iOS,
// including Duo, so portrait-only apps do not reset the Rotate cursor;
// Native-pose Duo keeps its clockwise physical order; older Duo uses legacy names.
// Ordinary observed iOS uses CLOCKWISE_ORIENTATIONS; older helpers use DEVICE_ORIENTATIONS.
// framesUpright means the backend already sends upright frames (serve-emu).
// While an Android recording could break, client.recordingLock is "status-unknown" or
// "recording" and rotate, setOrientation and the stream setters are silently ignored. Disable
// those controls from it. client.orientationUnavailable gives the reason orientation cannot
// change now, also while the iOS input socket cannot take input.
client.reload(); // reload the running React Native bundle
client.setAppearance("dark"); // 'light' | 'dark'; read it back from client.appearance
// { blob, artifact, metadata? }, or null when capture fails. `artifact` is the session artifact
// outcome: { status: 'saved' } | { status: 'disabled' } | { status: 'failed', error?: string }
// | null (null for a backend that does not report one). `metadata` is the capture's
// { width, height, displayRotation, screen? }; it is absent on older servers, and a zero width
// or height or a null displayRotation means unknown.
const capture = await client.screenshot();
if (capture?.artifact?.status === "failed")
console.warn("not saved to session artifacts", capture.artifact.error);
// Input is normalized to 0..1 of the screen, so it works for every device size.
client.sendTouch({ phase: "begin", x: 0.5, y: 0.5 });
client.sendTouch({ phase: "end", x: 0.5, y: 0.5 });
client.sendKey({ phase: "down", code: "KeyA", key: "a", repeat: false });
client.sendKey({ phase: "up", code: "KeyA", key: "a", repeat: false });
// Logs are off until you attach them.
client.attachLogs();
client.logs; // DeviceLog[]
client.detachLogs();
Optional features such as device settings, camera feeds, the accessibility tree, location,
and app permissions are only available on some backends. Check client.capabilities before
you show their controls. The full DeviceClient contract, with a comment on every field, is
in src/types.ts.
Remote connections
Set options.baseUrl on DeviceClientProvider to the remote streaming server:
- iOS:
https://hub.example.test/device-hub/vendor/serve-sim - Android:
https://hub.example.test/device-hub/vendor/serve-emu
iOS embedding
When embedding the iOS screen on another site, pass the public serve-sim mount that serves
/api and /helper as baseUrl, for example https://sim.example.test/preview/session.
The stream and input URLs then use that server. Start serve-sim with
--cors-origin <origin> for the origin of the embedding page, for example
--cors-origin http://localhost:8081. Without it, the exec-ws socket closes, and logs,
events, metrics and UI requests stop, even when both servers run on localhost.
The foreground app icon does not need exec-ws. hub-client reads it from serve-sim's
/api/apps/icon route, a plain GET, when /api advertises the route. Older servers send the
icon over exec-ws.
A serve-sim started with --require-token, such as an EAS Simulator Preview session, needs
its session token on every request. Pass it as options.token:
<DeviceClientProvider
key={udid}
platform="ios"
options={{
baseUrl: "https://sim.example.test/preview/session",
device: udid,
streamMode: "mjpeg",
token,
}}
>
<Screen />
</DeviceClientProvider>
The token does not replace --cors-origin.
License
MIT