Using React Hooks in Native Methods
Native methods are declared with bridge({...}) at module scope, outside of any React component. So a regular native method cannot call useNavigation(), read a context value, or update a component's state.
delegate() solves this. You declare the method on the bridge, and the screen that renders the WebView provides the implementation through the WebView's delegate prop, where hooks are available. Types are inferred from a Standard Schema (Zod, Valibot, ArkType, ...), so you don't write the signature yourself.
// bridge.ts: declare
goBack: delegate(),
setTitle: delegate({ input: z.string() }),
// Screen.tsx: implement, with hooks
<WebView delegate={{ goBack: async () => navigation.goBack() }} />
// Web: call it like any other native method
await bridge.goBack();When to use delegate()
| What the native method needs | Use |
|---|---|
| Nothing from React: device APIs, in-app browser, storage | A regular method in bridge({...}) |
One app-wide instance, such as the root NavigationContainer ref | A regular method that reads a module-level ref |
| Hooks, context, component state, or anything that belongs to the screen that renders the WebView | delegate() |
Regular and delegated methods can be mixed on the same bridge.
Quick start
1. Declare the method on the bridge
delegate({ input, output }) takes the method's schemas. Export the bridge type as usual. The Web side sees delegated methods as normal methods.
// This file is src/bridge.ts
import { bridge, createWebView, delegate } from "@webview-bridge/react-native";
import InAppBrowser from "react-native-inappbrowser-reborn";
import { z } from "zod";
const userSchema = z.object({ id: z.string(), name: z.string() }).nullable();
export const appBridge = bridge({
// Regular method: implemented here, shared by every WebView.
async openInAppBrowser(url: string) {
if (await InAppBrowser.isAvailable()) {
await InAppBrowser.open(url);
}
},
// Delegated methods: implemented by each WebView.
goBack: delegate(), // no input, no return value
getUser: delegate({ output: userSchema }), // no input, returns a user
setTitle: delegate({ input: z.string() }), // takes a title
});
export type AppBridge = typeof appBridge;
export const { WebView } = createWebView({
bridge: appBridge,
});2. Implement it where the WebView is rendered
// This file is src/screens/WebViewScreen.tsx
import { useNavigation } from "@react-navigation/native";
import { WebView } from "../bridge";
import { useAuth } from "../auth";
export function WebViewScreen() {
const navigation = useNavigation();
const { user } = useAuth();
return (
<WebView
source={{ uri: "https://example.com" }}
delegate={{
goBack: async () => navigation.goBack(),
getUser: async () => user,
setTitle: async (title) => navigation.setOptions({ title }),
}}
/>
);
}The delegate prop is type-checked against the bridge:
- It is required once the bridge has a
delegate()method, and autocomplete insidedelegate=lists exactly the delegated methods (goBack,getUserandsetTitlehere, notopenInAppBrowser). - A missing method, an extra method, or a wrong signature is a type error.
The implementations always run with the WebView's latest props, so user above is never stale. You do not need useCallback or refs.
3. Call it from the Web
Nothing changes on the Web side.
import { linkBridge } from "@webview-bridge/web";
import type { AppBridge } from ""; // Import the type 'appBridge' declared in native
const bridge = linkBridge<AppBridge>();
const user = await bridge.getUser(); // { id: string; name: string } | null
await bridge.setTitle("Profile");
await bridge.goBack();Declaring a delegated method
With a schema
delegate({ input, output }) takes Standard Schemas and infers every type from them. Both are optional. Values are validated on native, because the page that calls the method is not trusted.
| Page (Web) | Native implementation | |
|---|---|---|
input | passes the schema's input type | receives the schema's output type (validated, transforms applied) |
output (optional) | receives the schema's output type | returns the schema's input type |
getUser: delegate({
input: z.object({ id: z.string().transform(Number) }), // page sends { id: "7" }, native gets { id: 7 }
output: z.object({ id: z.number(), name: z.string() }),
}),delegate()takes nothing and resolves tovoid.delegate({ output })takes nothing and resolves to the output type.delegate({ input })without an output schema resolves tounknownon the page.The input schema describes one argument. Use an object schema for several values. If the input schema accepts
undefined(for examplez.string().optional()), the page may omit it.postMessageSchemafollows the same rule: each event's schema describes the value crossing the bridge, the sender passes its input type, and the receiver gets its output type.A method declared with schemas takes exactly the arguments they describe: one argument with
input, none without it. Extra arguments fail with aBridgeValidationError.Values cross the bridge as JSON. TypeScript rejects schemas whose page-facing types (the input schema's input and the output schema's output) contain a
Date,BigInt,Map,Set, an object with methods (URL, typed arrays, most class instances), a function, orundefinedarray items (JSON turns them intonull). Transform such values on native instead, for exampleoutput: z.date().transform((d) => d.toISOString()).What the check does not catch
The check is structural and stops after 10 levels of nesting. It cannot tell a plain object from a class instance without methods (the page receives a plain object), it treats any intersection with a primitive (such as a branded
string & { __brand }) as that primitive, andNaN/Infinitybecomenullin JSON.Input is validated right before the implementation runs, after middleware, so it checks the arguments middleware may have rewritten. Output is validated on the final result, after middleware, so middleware cannot return a value that skips the output schema.
Invalid input never reaches the implementation, and native logs a warning. An invalid return value is logged as an error. In both cases the call fails with a
BridgeValidationError: it rejects whenthrowOnErrorapplies to the method, and resolves toundefinedotherwise, like any other native error.
With a type argument
Without a schema library, or for a signature a schema can't express (for example a generic function), pass the signature as a type argument. Nothing is validated at runtime.
confirm: delegate<(message: string) => Promise<boolean>>(),
push: delegate<
<Name extends keyof RootStackParamList>(
name: Name,
params: RootStackParamList[Name],
) => Promise<void>
>(),Use cases
Navigate from the current screen
With a global navigationRef, every call acts on the root navigator. With delegate(), each WebView screen uses its own navigation, so goBack, push, and setOptions affect the screen the page is actually on.
// bridge.ts
export const appBridge = bridge({
goBack: delegate(),
push: delegate({ input: z.string() }),
setTitle: delegate({ input: z.string() }),
});// WebViewScreen.tsx
function WebViewScreen({ navigation, route }: Props) {
return (
<WebView
source={{ uri: route.params.url }}
delegate={{
goBack: async () => navigation.goBack(),
push: async (path) =>
navigation.push("WebView", { url: `https://example.com${path}` }),
setTitle: async (title) => navigation.setOptions({ title }),
}}
/>
);
}See the React Navigation example for a complete setup.
Read auth or other context values
Context values and query clients are only available through hooks. Read them in the screen and return them from the delegated method.
// bridge.ts
export const appBridge = bridge({
getAccessToken: delegate({ output: z.string().nullable() }),
invalidate: delegate({ input: z.string() }),
});// WebViewScreen.tsx
function WebViewScreen() {
const { session } = useAuth();
const queryClient = useQueryClient();
return (
<WebView
source={{ uri: "https://example.com" }}
delegate={{
getAccessToken: async () => session?.accessToken ?? null,
// The web page changed data: refresh the native screens that show it.
invalidate: async (key) => queryClient.invalidateQueries({ queryKey: [key] }),
}}
/>
);
}Show native UI and wait for the user's answer
A delegated method can return a promise that resolves only when the user acts. This lets the page await a native confirm dialog, bottom sheet, or picker that is rendered with the screen's own state.
// bridge.ts
export const appBridge = bridge({
confirm: delegate({ input: z.string(), output: z.boolean() }),
});// WebViewScreen.tsx
function WebViewScreen() {
const [dialog, setDialog] = useState<{
message: string;
resolve: (ok: boolean) => void;
} | null>(null);
const close = (ok: boolean) => {
dialog?.resolve(ok);
setDialog(null);
};
return (
<>
<WebView
source={{ uri: "https://example.com" }}
delegate={{
confirm: (message) =>
new Promise((resolve) => setDialog({ message, resolve })),
}}
/>
<ConfirmDialog
visible={dialog !== null}
message={dialog?.message}
onConfirm={() => close(true)}
onCancel={() => close(false)}
/>
</>
);
}// Web
if (await bridge.confirm("Delete this post?")) {
await deletePost();
}The page waits as long as the user takes. linkBridge's timeout only limits how long native takes to acknowledge the call, not how long the method runs.
Apps built with @webview-bridge/react-native before 1.9.0
Older native builds do not acknowledge calls, so timeout (2000 ms by default) applies to the whole call. The call then resolves to undefined, or rejects if throwOnError is set. If your page can run inside such an app, raise the timeout or disable it with timeout: 0. The option applies to every native method of that linkBridge instance.
const bridge = linkBridge<AppBridge>({ timeout: 0 });Pass screen-specific data to the page
Route params and other per-screen values reach the page without query strings or global state.
// bridge.ts
export const appBridge = bridge({
getScreenParams: delegate({ output: z.object({ productId: z.string() }) }),
});// ProductWebViewScreen.tsx
function ProductWebViewScreen({ route }: Props) {
return (
<WebView
source={{ uri: "https://example.com/product" }}
delegate={{
getScreenParams: async () => ({ productId: route.params.productId }),
}}
/>
);
}Share implementations across screens
When several screens render the same bridge, put the hooks and implementations in a custom hook typed with BridgeDelegate, and spread it into each WebView.
import type { BridgeDelegate } from "@webview-bridge/react-native";
import type { AppBridge } from "./bridge";
export const useAppBridgeDelegate = (): BridgeDelegate<AppBridge> => {
const navigation = useNavigation();
const { user } = useAuth();
return {
goBack: async () => navigation.goBack(),
getUser: async () => user,
};
};function HomeWebViewScreen() {
const delegate = useAppBridgeDelegate();
return <WebView source={{ uri: "https://example.com" }} delegate={delegate} />;
}
function SettingsWebViewScreen() {
const delegate = useAppBridgeDelegate();
return (
<WebView
source={{ uri: "https://example.com/settings" }}
// Override one method for this screen only.
delegate={{ ...delegate, goBack: async () => closeSettings() }}
/>
);
}Behavior
- Each WebView uses its own implementations. When several WebViews share one bridge (for example a stack of screens), a call from a page always runs the implementation of the WebView that hosts that page.
- Which methods a page sees is fixed when the WebView mounts. If a method is missing from the
delegateprop at mount time, the page does not see it:bridge.isNativeMethodAvailable("getUser")returnsfalse, and calling it goes through the fallback flow. Implementations can change on every render, but the set of keys should stay the same. - Middleware still runs. Bridge middleware runs around delegated methods the same way it runs around regular ones.
- Delegated methods cannot be called on the native side directly.
appBridge.getState().goBack()throws, because the implementation lives on a WebView. - Non-React Native environments.
linkBridge({ initialBridge })accepts plain implementations for delegated methods, as described in Non-React Native Environment.