Fox Events Fox Events

Bridge React Native

Bidirectional messaging between React Native and WebView. Code snippets on this page use syntax highlighting with the Dracula theme (Prism) in the generated documentation.

---

Table of contents

---

1. Introduction

The Bridge lets you use the same Fox Events API in your React Native app (via an adapter) and inside the WebView (via minimal setup). Events emitted in one environment are received in the other — commands, data, or lifecycle events (e.g. “web ready”, “reload”) without custom postMessage glue.

Set up the bridge once on the WebView side with createReactNativeBridge and on the RN side with createFoxRNAdapter and your WebView’s onMessage / inject. Then use fox.emit, fox.on, and fox.once in RN and Fox.emit / Fox.on in the web app.

---

2. Setup

2.1 WebView side

ts
import { createReactNativeBridge } from "fox-events/bridge-react-native";

const dispose = createReactNativeBridge({
  direction: "both",
  filter: (name) => name.startsWith("app:"),
  debug: false,
});

dispose();

2.2 RN side — Adapter

ts
import { useRef, useMemo } from "react";
import { WebView } from "react-native-webview";
import { createFoxRNAdapter } from "fox-events/bridge-react-native";

function App() {
  const webViewRef = useRef<WebView>(null);
  const fox = useMemo(
    () =>
      createFoxRNAdapter({
        sendToWebView: (msg) => {
          const code = `window.postMessage(${JSON.stringify(msg)}, '*'); true;`;
          webViewRef.current?.injectJavaScript(code);
        },
        filter: (name) => name.startsWith("app:"),
      }),
    []
  );

  return (
    <WebView
      ref={webViewRef}
      source={{ uri: "https://example.com" }}
      onMessage={(e) => fox.handleMessage(e.nativeEvent.data)}
    />
  );
}

---

3. Best practices

3.1 WebView: global bridge

Initialize the bridge once in a module imported at the entry point. Any component can use Fox.emit and Fox.on without repeating the setup.

Bridge module (src/bridge.ts):

ts
import { createReactNativeBridge } from "fox-events/bridge-react-native";

createReactNativeBridge({
  direction: "both",
  filter: (name) => name.startsWith("app:"),
  debug: false,
});

Entry point (main.tsx): import before App:

ts
import "./bridge";
import App from "./App";

3.2 RN: global adapter usage

To use the same adapter across screens without passing props, register it globally.

Option A — Global module (app/fox-global.ts):

ts
import type { FoxRNAdapter } from "fox-events/bridge-react-native";

let foxInstance: FoxRNAdapter | null = null;

export function setFox(fox: FoxRNAdapter): void {
  foxInstance = fox;
}

export function getFox(): FoxRNAdapter {
  if (foxInstance == null) {
    throw new Error("Fox not initialized. Ensure the layout with WebView has mounted and called setFox(fox).");
  }
  return foxInstance;
}

In the layout with WebView: call setFox(fox) after creating the adapter:

ts
const fox = useMemo(() => createFoxRNAdapter({ sendToWebView: (msg) => { /* ... */ } }), []);

useEffect(() => {
  setFox(fox);
}, [fox]);

In any screen: use getFox():

ts
import { getFox } from "@/app/fox-global";

export default function OtherScreen() {
  const fox = getFox();
  const handleSend = () => fox.emit("app:command", { id: "1" });
  useEffect(() => {
    const unsub = fox.on("app:response", (p) => console.log(p));
    return () => unsub();
  }, [fox]);
  return (/* ... */);
}

Option B — Hook that uses the global (app/fox-context.tsx):

ts
import type { FoxRNAdapter } from "fox-events/bridge-react-native";
import { getFox } from "./fox-global";

export function useFox(): FoxRNAdapter {
  return getFox();
}

Usage: const fox = useFox();. To inject via the React tree, use createContext and <FoxContext.Provider value={fox}> in the layout.

---

4. Usage (API)

ts
fox.emit("app:command", { action: "reload" });
fox.on("app:ready", (payload) => console.log(payload));
const payload = await fox.once("app:login");

---

5. RN ↔ Web flow

  • RN → Web: fox.emit("app:command", payload) in RN; the bridge on the web calls Fox.emit(name, payload); any Fox.on("app:command", ...) in the web runs.
  • Web → RN: Fox.emit("app:ready", payload) in the web; the bridge sends to RN via ReactNativeWebView.postMessage; in RN, onMessage calls fox.handleMessage(data); any fox.on("app:ready", ...) in RN runs.

Example — Web:

ts
import { Fox } from "fox-events";
import { createReactNativeBridge } from "fox-events/bridge-react-native";

createReactNativeBridge({ direction: "both", filter: (n) => n.startsWith("app:") });

Fox.on("app:command", (payload) => {
  if (payload.action === "reload") window.location.reload();
});

Fox.emit("app:ready", { version: "1.0" });

Example — React Native:

ts
fox.emit("app:command", { action: "reload" });

fox.on("app:ready", (payload) => {
  console.log("Web says ready:", payload.version);
});

---

6. Implementation examples

6.1 Example: download with progress

Button in WebView triggers a “download”; RN simulates and sends progress; the web shows a bar and toast when done.

Events: app:download:start (web → RN), app:download:progress (RN → web), app:download:complete (RN → web).

Web:

ts
import { useEffect, useState } from "react";
import { Fox } from "fox-events";
import { createReactNativeBridge } from "fox-events/bridge-react-native";

function App() {
  const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
  const [toast, setToast] = useState<string | null>(null);

  useEffect(() => {
    const dispose = createReactNativeBridge({
      direction: "both",
      filter: (name) => name.startsWith("app:"),
      debug: false,
    });

    Fox.on("app:download:progress", (payload: { progress: number }) => {
      setDownloadProgress(payload.progress);
    });

    Fox.on("app:download:complete", () => {
      setDownloadProgress(null);
      setToast("Download complete!");
      setTimeout(() => setToast(null), 3000);
    });

    return () => dispose();
  }, []);

  const handleDownload = () => {
    setDownloadProgress(0);
    Fox.emit("app:download:start", {});
  };

  return (
    <div>
      <button onClick={handleDownload} disabled={downloadProgress !== null}>
        {downloadProgress !== null ? `Downloading... ${downloadProgress}%` : "Download"}
      </button>
      {downloadProgress !== null && downloadProgress < 100 && (
        <div className="progress-bar" style={{ width: `${downloadProgress}%` }} />
      )}
      {toast && <div className="toast">{toast}</div>}
    </div>
  );
}

React Native:

ts
import { useRef, useMemo, useEffect } from "react";
import { WebView } from "react-native-webview";
import { createFoxRNAdapter } from "fox-events/bridge-react-native";

export default function App() {
  const webViewRef = useRef<WebView>(null);
  const downloadIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  const fox = useMemo(
    () =>
      createFoxRNAdapter({
        sendToWebView: (msg) => {
          const code = `window.postMessage(${JSON.stringify(msg)}, '*'); true;`;
          webViewRef.current?.injectJavaScript(code);
        },
        filter: (name) => name.startsWith("app:"),
      }),
    []
  );

  useEffect(() => {
    fox.on("app:download:start", () => {
      if (downloadIntervalRef.current) clearInterval(downloadIntervalRef.current);
      let progress = 0;
      downloadIntervalRef.current = setInterval(() => {
        progress += Math.random() * 15 + 5;
        if (progress >= 100) {
          if (downloadIntervalRef.current) {
            clearInterval(downloadIntervalRef.current);
            downloadIntervalRef.current = null;
          }
          fox.emit("app:download:complete", {});
        } else {
          fox.emit("app:download:progress", { progress: Math.round(progress) });
        }
      }, 300);
    });
    return () => {
      if (downloadIntervalRef.current) clearInterval(downloadIntervalRef.current);
    };
  }, [fox]);

  const handleWebViewLoadEnd = () => {
    fox.emit("app:mounted", { timestamp: Date.now() });
  };

  return (
    <WebView
      ref={webViewRef}
      onLoadEnd={handleWebViewLoadEnd}
      onMessage={(e) => fox.handleMessage(e.nativeEvent.data)}
      source={{ uri: "https://your-webview-url.com" }}
    />
  );
}

Flow summary: 1) Web emits app:download:start. 2) RN simulates and emits app:download:progress. 3) Web updates bar. 4) RN emits app:download:complete. 5) Web shows toast.

---

6.2 Example: completed todos list

RN sends a list of completed todos on onLoadEnd; the web receives and renders it.

Event: app:todos:completed (RN → web), payload { todos: Array<{ id, title, completedAt }> }.

Web:

ts
type TodoCompleted = { id: string; title: string; completedAt: number };

function App() {
  const [completedTodos, setCompletedTodos] = useState<TodoCompleted[]>([]);

  useEffect(() => {
    const dispose = createReactNativeBridge({
      direction: "both",
      filter: (name) => name.startsWith("app:"),
    });

    Fox.on("app:todos:completed", (payload: { todos: TodoCompleted[] }) => {
      setCompletedTodos(payload.todos ?? []);
    });

    return () => dispose();
  }, []);

  return (
    <section>
      <h2>Completed todos</h2>
      {completedTodos.length === 0 ? (
        <p>No completed todos received from the app.</p>
      ) : (
        <ul>
          {completedTodos.map((todo) => (
            <li key={todo.id}>
              <span>{todo.title}</span>
              <span>{new Date(todo.completedAt).toLocaleDateString()}</span>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
}

React Native:

ts
const completedTodos = useMemo(
  () => [
    { id: "1", title: "Set up RN ↔ WebView bridge", completedAt: Date.now() - 86400000 * 2 },
    { id: "2", title: "Implement download with progress", completedAt: Date.now() - 86400000 },
    { id: "3", title: "Send todos list to web", completedAt: Date.now() },
  ],
  []
);

const handleWebViewLoadEnd = () => {
  fox.emit("app:mounted", { timestamp: Date.now() });
  fox.emit("app:todos:completed", { todos: completedTodos });
};

return (
  <WebView
    ref={webViewRef}
    onLoadEnd={handleWebViewLoadEnd}
    onMessage={(e) => fox.handleMessage(e.nativeEvent.data)}
    source={{ uri: "https://your-webview-url.com" }}
  />
);

---

6.3 Example: native button → paint screen red

Native RN button sends an event on press; the web uses a scoped channel and once() and paints the screen red the first time.

Event: app:ui:paintRed (RN → web).

Web:

ts
import { useEffect, useState } from "react";
import { Fox } from "fox-events";
import { createReactNativeBridge } from "fox-events/bridge-react-native";

const appScope = Fox.forScope("app");

function App() {
  const [screenRed, setScreenRed] = useState(false);

  useEffect(() => {
    const dispose = createReactNativeBridge({
      direction: "both",
      filter: (name) => name.startsWith("app:"),
    });

    Fox.on("app:ui:paintRed", () => {
      appScope.emit("ui:paintRed", {});
    });

    appScope.channel("ui:paintRed").once().then(() => setScreenRed(true));

    return () => dispose();
  }, []);

  return (
    <>
      {screenRed && <div className="screen-red-overlay" aria-hidden />}
      <div className="app">{/* rest of content */}</div>
    </>
  );
}

Overlay CSS:

css
.screen-red-overlay {
  position: fixed;
  inset: 0;
  background: #b91c1c;
  z-index: 9999;
}

React Native:

ts
import { Button, View } from "react-native";
import { WebView } from "react-native-webview";
import { createFoxRNAdapter } from "fox-events/bridge-react-native";

export default function App() {
  const webViewRef = useRef<WebView>(null);
  const fox = useMemo(
    () =>
      createFoxRNAdapter({
        sendToWebView: (msg) => {
          const code = `window.postMessage(${JSON.stringify(msg)}, '*'); true;`;
          webViewRef.current?.injectJavaScript(code);
        },
        filter: (name) => name.startsWith("app:"),
      }),
    []
  );

  const handlePaintRed = () => {
    fox.emit("app:ui:paintRed", {});
  };

  return (
    <View style={{ flex: 1 }}>
      <Button
        title="Native React Native button sending event to WebView on tap"
        onPress={handlePaintRed}
      />
      <WebView
        ref={webViewRef}
        onMessage={(e) => fox.handleMessage(e.nativeEvent.data)}
        source={{ uri: "https://your-webview-url.com" }}
      />
    </View>
  );
}

Summary: tap button → RN emits app:ui:paintRed → bridge sends to web → listener forwards to app scope → channel("ui:paintRed").once() resolves → red overlay. Reacts only the first time because of once().