React Native / Expo Guide
Complete guide for integrating UI Bridge with React Native and Expo applications. Mobile integration differs significantly from web since there's no DOM to observe.
Key Differences from Web
| Feature | Web | React Native |
|---|---|---|
| DOM Snapshots | ✅ Full DOM capture | ❌ No DOM |
| MutationObserver | ✅ Auto-detects changes | ❌ Not available |
| Auto-Registration | ✅ Automatic discovery | ⚠️ Manual via hooks |
| Render Logging | ✅ DOM-based | ✅ Screen/component-based |
| Element Control | ✅ Direct DOM access | ✅ Via component refs |
Installation
npm install @qontinui/ui-bridge-native
For Expo projects, no additional native modules are required.
Basic Setup
1. Add the Provider
// app/_layout.tsx (Expo Router)
import { UIBridgeNativeProvider } from '@qontinui/ui-bridge-native';
import { useAutoRegister } from '@qontinui/ui-bridge-native/react';
function AppContent() {
// Enable auto-registration infrastructure
useAutoRegister({ enabled: __DEV__ });
return <Stack>{/* your screens */}</Stack>;
}
export default function RootLayout() {
return (
<UIBridgeNativeProvider
features={{
server: __DEV__, // Enable HTTP server in dev
debug: __DEV__,
}}
config={{
serverPort: 9876,
verbose: __DEV__,
}}
>
<AppContent />
</UIBridgeNativeProvider>
);
}
2. Add Render Logging
Since React Native has no DOM, render logging captures screen changes and component state:
// lib/render-log/types.ts
export type RenderLogEntryType =
| 'screen_change'
| 'component_render'
| 'interaction'
| 'state_change'
| 'error'
| 'custom';
export interface RenderLogEntry {
id: string;
type: RenderLogEntryType;
timestamp: number;
source: string;
trigger: string;
data: Record<string, unknown>;
}
// lib/render-log/RenderLogProvider.tsx
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { usePathname, useSegments } from 'expo-router';
interface RenderLogContextValue {
isEnabled: boolean;
logRender: (componentName: string, trigger: string, data?: Record<string, unknown>) => void;
logInteraction: (
eventType: string,
targetComponent?: string,
details?: Record<string, unknown>
) => void;
getEntries: () => Promise<RenderLogEntry[]>;
clear: () => Promise<void>;
currentScreen: string;
}
const RenderLogContext = createContext<RenderLogContextValue | null>(null);
export function RenderLogProvider({ children, enableOnMount = __DEV__ }) {
const [isEnabled, setEnabled] = useState(enableOnMount);
const [logs, setLogs] = useState<RenderLogEntry[]>([]);
const pathname = usePathname();
const segments = useSegments();
const currentScreen = pathname || segments.join('/') || '(root)';
const previousScreenRef = useRef<string | null>(null);
// Log screen changes
useEffect(() => {
if (!isEnabled) return;
const previous = previousScreenRef.current;
if (previous === currentScreen) return;
previousScreenRef.current = currentScreen;
const entry: RenderLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: 'screen_change',
timestamp: Date.now(),
source: currentScreen,
trigger: previous ? 'navigation' : 'mount',
data: {
previousScreen: previous,
currentScreen,
},
};
setLogs((prev) => [...prev.slice(-499), entry]);
}, [currentScreen, isEnabled]);
const logRender = useCallback(
(componentName, trigger, data = {}) => {
if (!isEnabled) return;
const entry: RenderLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: 'component_render',
timestamp: Date.now(),
source: componentName,
trigger,
data: { ...data, screen: currentScreen },
};
setLogs((prev) => [...prev.slice(-499), entry]);
},
[isEnabled, currentScreen]
);
const logInteraction = useCallback(
(eventType, targetComponent, details = {}) => {
if (!isEnabled) return;
const entry: RenderLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: 'interaction',
timestamp: Date.now(),
source: targetComponent || 'unknown',
trigger: eventType,
data: { ...details, screen: currentScreen },
};
setLogs((prev) => [...prev.slice(-499), entry]);
},
[isEnabled, currentScreen]
);
const getEntries = useCallback(async () => logs, [logs]);
const clear = useCallback(async () => setLogs([]), []);
return (
<RenderLogContext.Provider
value={{
isEnabled,
logRender,
logInteraction,
getEntries,
clear,
currentScreen,
}}
>
{children}
</RenderLogContext.Provider>
);
}
export function useRenderLog() {
const context = useContext(RenderLogContext);
if (!context) throw new Error('useRenderLog must be used within RenderLogProvider');
return context;
}
export function useRenderLogOptional() {
return useContext(RenderLogContext);
}
3. Update Layout with Render Logging
// app/_layout.tsx
import { UIBridgeNativeProvider } from '@qontinui/ui-bridge-native';
import { useAutoRegister } from '@qontinui/ui-bridge-native/react';
import { RenderLogProvider } from '@/lib/render-log';
function AppContent() {
useAutoRegister({ enabled: __DEV__ });
return <Stack>{/* screens */}</Stack>;
}
export default function RootLayout() {
return (
<UIBridgeNativeProvider
features={{ server: __DEV__, debug: __DEV__ }}
config={{ serverPort: 9876 }}
>
<RenderLogProvider enableOnMount={__DEV__}>
<AppContent />
</RenderLogProvider>
</UIBridgeNativeProvider>
);
}
Registering Interactive Elements
Since there's no automatic DOM discovery in React Native, register elements explicitly:
Using useUIElement
import { useUIElement } from '@qontinui/ui-bridge-native';
import { Pressable, Text } from 'react-native';
function SubmitButton({ onSubmit }) {
const { props, trigger } = useUIElement({
id: 'submit-button',
type: 'button',
label: 'Submit Form',
actions: ['press'],
customActions: {
submit: {
handler: onSubmit,
description: 'Submit the form',
},
},
});
return (
<Pressable
{...props}
onPress={() => {
trigger('press');
onSubmit();
}}
>
<Text>Submit</Text>
</Pressable>
);
}
Using useUIComponent
import { useUIComponent } from '@qontinui/ui-bridge-native';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
useUIComponent({
id: 'login-form',
name: 'Login Form',
actions: [
{
id: 'login',
label: 'Submit Login',
handler: async ({ email, password }) => {
await loginUser(email, password);
},
},
{
id: 'reset',
label: 'Reset Form',
handler: async () => {
setEmail('');
setPassword('');
},
},
],
});
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="Email" />
<TextInput
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="Password"
/>
</View>
);
}
Logging Component Renders
Use the render log to track what components display:
import { useRenderLog } from '@/lib/render-log';
import { useEffect } from 'react';
function UserProfile({ user }) {
const { logRender } = useRenderLog();
useEffect(() => {
logRender('UserProfile', 'data_loaded', {
userId: user.id,
hasAvatar: !!user.avatar,
memberSince: user.createdAt,
});
}, [user]);
return (
<View>
<Text>{user.name}</Text>
{user.avatar && <Image source={{ uri: user.avatar }} />}
</View>
);
}
Convenience Hook
// lib/render-log/useLogRender.ts
import { useEffect } from 'react';
import { useRenderLogOptional } from './RenderLogProvider';
export function useLogRender(
componentName: string,
data?: Record<string, unknown>,
deps: unknown[] = []
) {
const renderLog = useRenderLogOptional();
useEffect(() => {
renderLog?.logRender(componentName, 'effect', data);
}, [componentName, renderLog, ...deps]);
}
// Usage
function ProductCard({ product }) {
useLogRender(
'ProductCard',
{
productId: product.id,
inStock: product.quantity > 0,
},
[product.id]
);
return <View>...</View>;
}
Logging Interactions
import { useRenderLog } from '@/lib/render-log';
function AddToCartButton({ productId }) {
const { logInteraction } = useRenderLog();
const handlePress = () => {
logInteraction('press', 'AddToCartButton', { productId });
addToCart(productId);
};
return (
<Pressable onPress={handlePress}>
<Text>Add to Cart</Text>
</Pressable>
);
}
Route Awareness
Report the screen the app is on as a route template with
useRouteAwareness. Every snapshot then carries it as page.route, which is
the same field the web SDK uses. The runner's journey ledger reads
page.route.pattern from that field and stores it as the screen's node key.
function useRouteAwareness(info: RouteInfo, options?: { unmatched?: boolean }): void
RouteInfo has the same fields as the web hook's (pattern, patternSource,
params, queryParams, routeStack). Call the hook once, from a component
mounted inside UIBridgeNativeProvider. The info is cleared when that
component unmounts, and the hook does nothing outside a provider.
The concrete path is still reported separately, as currentRoute (from your
RouteProvider). page on native carries only route. It has no url,
pathname or title, by design.
The same hook and helpers are also exported from @qontinui/ui-bridge/native,
the React Native entry point inside the web package, and they behave the same
there. That entry point has no RouteProvider, so its snapshot carries no
currentRoute, only page.route. The examples below import from
@qontinui/ui-bridge-native. If your app uses the /native entry point, import
from there instead.
The rules
- The pattern is a template, never the concrete path.
/profile/[user]is a template./profile/aliceis user input, and storing it as a template leaks it. Derive the pattern withexpoRouterPatternorreactNavigationPattern. Both returnnullrather than leak. - Never pass
usePathname(), a URL, or a param value as the pattern. - Set
patternSource: 'router'. The runner keeps a pattern only when it carries that assertion, and drops it otherwise. - Groups are kept.
["(tabs)", "runs"]becomes/(tabs)/runs. Group names are declared by the developer, never typed by a user, so keeping them leaks nothing. Two screens in different groups can also share a URL ((tabs)/indexand(auth)/indexare both/), and the group keeps them apart as separate screens. A consumer can strip(…)segments if it wants the URL shape. A stripped pattern cannot be restored. - There is no not-found signal to wire. The web SDK needs one because a
Next.js layout renders before its
not-found.tsxexists. Native routers do not have that gap: Expo Router'suseSegments()already reads['+not-found']in the same render, and a React Navigation not-found screen is an ordinary named route. Pass{ unmatched }directly. While it is true, the reportedpatternisnullandparamsis dropped. On a not-found screen the concrete path is always in Expo Router'spathname(which you never pass), and for a NESTED+not-foundroute it is also inparams['not-found']. Droppingparamskeeps it out of the snapshot. routeStackholds cumulative templates, as on the web (["/", "/(tabs)", "/(tabs)/runs"]), not raw segments. Derive it withexpoRouterRouteStack(segments), which fails closed the same way.
Expo Router
Mount this once in the root layout (app/_layout.tsx):
import { useSegments } from 'expo-router';
import {
expoRouterPattern,
expoRouterRouteStack,
isExpoRouterUnmatched,
useRouteAwareness,
} from '@qontinui/ui-bridge-native';
export function RouteAwareness() {
const segments = useSegments();
useRouteAwareness(
{
pattern: expoRouterPattern(segments),
patternSource: 'router',
routeStack: expoRouterRouteStack(segments),
},
{ unmatched: isExpoRouterUnmatched(segments) }
);
return null;
}
params is optional, and the example leaves it out. Do not fill it from
useLocalSearchParams() in the root layout: there it reads the root route's
own params, not the focused screen's. If you want route params, derive them
from useGlobalSearchParams() and keep only the keys that appear as a [key]
or [...key] segment; every other key is a query parameter. The hook drops
params anyway while unmatched is true.
React Navigation
Read the root navigation state from the container. Do not use
useNavigationState: directly under NavigationContainer it throws
("Couldn't get the navigation state. Is your component inside a
navigator?"), and inside a screen it returns only that navigator's state, which
truncates the chain. Wrap your navigators in a component like this one, rendered
inside UIBridgeNativeProvider:
import { useState } from 'react';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import {
reactNavigationPattern,
useRouteAwareness,
type NavigationStateLike,
} from '@qontinui/ui-bridge-native';
export function Navigation({ children }: { children: React.ReactNode }) {
const navigationRef = useNavigationContainerRef();
const [rootState, setRootState] = useState<NavigationStateLike | undefined>();
const syncRootState = () => setRootState(navigationRef.getRootState());
// 'NotFound' is the linking config's catch-all screen.
const pattern = reactNavigationPattern(rootState, { notFoundRouteNames: ['NotFound'] });
useRouteAwareness({ pattern, patternSource: 'router' }, { unmatched: pattern === null });
return (
<NavigationContainer ref={navigationRef} onReady={syncRootState} onStateChange={syncRootState}>
{children}
</NavigationContainer>
);
}
A navigator level whose state has no index focuses its last route for a stack
and its first route for any other navigator type, matching React Navigation's
own rule.
The pattern is the chain of focused route names from the root navigator to
the leaf, such as /HomeTabs/Profile. Route names are static identifiers
declared in code, and route.params is never read, so the chain is a template
by construction. The whole chain is used, not only the leaf, so that
same-named screens in different navigators stay distinct.
Expo Router runs on React Navigation, but an Expo app should use
expoRouterPattern. Walking Expo's navigation state yields Expo's internal
route names, not the file routes.
HTTP Server (Development)
In development, ui-bridge-native can expose an HTTP server for external tools:
# List registered elements
curl http://localhost:9876/elements
# Get element details
curl http://localhost:9876/elements/submit-button
# Execute action
curl -X POST http://localhost:9876/elements/submit-button/action \
-H "Content-Type: application/json" \
-d '{"action": "press"}'
# Get render log
curl http://localhost:9876/render-log
Action body shape: POST /control/element/:id/action
The mobile bridge takes the same { action, params } envelope as the
runner/web SDK. The element id goes in the path; the action name goes in the
JSON body under the top-level action field (NOT in the path, NOT under a
nested key). The on-device bridge listens on port 8087 under the
/ui-bridge/ prefix:
# ✅ Press a button — `action` is a top-level body field
curl -X POST http://<device-ip>:8087/ui-bridge/control/element/submit-button/action \
-H "Content-Type: application/json" \
-d '{"action": "press"}'
// Request body schema
{
"action": "press", // required — the action name (press, type, setValue, scroll, …)
"params": { }, // optional — per-action arguments (see below)
"waitOptions": { } // optional — pre-action wait predicate
}
A body that omits the top-level action field is rejected with
{"success": false, "code": "INVALID_REQUEST", "error": "Action is required"}.
Actions that take no arguments (press, longPress, focus, blur, …) need
only {"action": "<name>"} — params is optional for those. Value-mutating
actions require their argument under params (next section).
Setting an input's value (mobile envelope)
params envelope — top-level value/text is rejectedOn the mobile (ui-bridge-native) bridge, value-mutating actions take their
argument inside params, not at the top level. The on-device bridge
listens on port 8087 under the /ui-bridge/ prefix:
# ✅ Correct — text lives under params
curl -X POST http://<device-ip>:8087/ui-bridge/control/element/email-input/action \
-H "Content-Type: application/json" \
-d '{"action": "setValue", "params": {"text": "user@example.com"}}'
# `type` uses the same envelope and supports clear:true to replace atomically
curl -X POST http://<device-ip>:8087/ui-bridge/control/element/email-input/action \
-H "Content-Type: application/json" \
-d '{"action": "type", "params": {"text": "user@example.com", "clear": true}}'
# ❌ Rejected — a top-level `value` or `text` is NOT read on mobile
curl -X POST http://<device-ip>:8087/ui-bridge/control/element/email-input/action \
-d '{"action": "setValue", "value": "user@example.com"}' # → no value set
setValue accepts either params.text (canonical) or params.value as an
alias, but both must be inside params. After the action,
GET /ui-bridge/control/element/email-input and /ui-bridge/control/snapshot
both reflect the new state.value synchronously — no polling needed.
Python Client Usage
from ui_bridge import UIBridgeClient
# Connect to React Native app's HTTP server
client = UIBridgeClient("http://192.168.1.100:9876")
# List elements
elements = client.get_elements()
for el in elements:
print(f"{el.id}: {el.type}")
# Execute action
client.execute_action("submit-button", "press")
# Execute component action
client.component("login-form").action("login", {
"email": "user@example.com",
"password": "secret"
})
Complete Example
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { UIBridgeNativeProvider } from '@qontinui/ui-bridge-native';
import { useAutoRegister } from '@qontinui/ui-bridge-native/react';
import { RenderLogProvider } from '@/lib/render-log';
const queryClient = new QueryClient();
function AppContent() {
// Enable UI Bridge infrastructure
useAutoRegister({ enabled: __DEV__ });
return (
<Stack screenOptions={{ headerShown: true }}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<UIBridgeNativeProvider
features={{ server: __DEV__, debug: __DEV__ }}
config={{ serverPort: 9876, verbose: __DEV__ }}
>
<RenderLogProvider enableOnMount={__DEV__} maxEntries={500}>
<AppContent />
</RenderLogProvider>
</UIBridgeNativeProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
Best Practices
1. Register Key Interactive Elements
Focus on registering elements that AI agents or tests need to interact with:
// Good: Key user actions
<Pressable {...useUIElement({ id: 'checkout-btn', type: 'button' }).props}>
Checkout
</Pressable>
// Skip: Internal/decorative elements
<Pressable onPress={toggleHelp}>
<HelpIcon />
</Pressable>
2. Use Meaningful IDs
// Good
useUIElement({ id: 'product-123-add-to-cart', type: 'button' });
// Bad
useUIElement({ id: 'btn1', type: 'button' });
3. Log Significant State Changes
useEffect(() => {
if (orderComplete) {
logRender('CheckoutScreen', 'order_complete', {
orderId: order.id,
totalAmount: order.total,
});
}
}, [orderComplete]);
4. Development Only
// Only enable in development
useAutoRegister({ enabled: __DEV__ });
<RenderLogProvider enableOnMount={__DEV__}>
Troubleshooting
HTTP Server Not Accessible
- Check device/simulator IP address
- Verify port 9876 is not blocked
- For physical devices, ensure same network
- Use
adb reverse tcp:9876 tcp:9876for Android emulator
Elements Not Registering
- Verify
useUIElementhook is called - Check element has unique ID
- Verify
useAutoRegisteris enabled - Check console for registration logs
Screen Changes Not Logged
- Verify
RenderLogProviderwraps the app - Check
usePathnameis returning values - Verify
enableOnMountis true
Next Steps
- Auto-Registration - Hook details
- Render Logging - Logging options
- API Reference - HTTP endpoints