Skip to content

Migrating a feature to BridgeKit

This walks a single feature from uncontracted native calls to a typed, feature-owned contract. Use it as the template for migrating any feature.

Before BridgeKit, a feature’s native actions usually reach the host through several uncontracted channels:

  • ad-hoc native getters — one-off appGet*-style functions.
  • a legacy action dispatcher — a generic execute({ featureName, actionName, params }) call that routes string-named actions to native.
  • raw native event emitters — event streams off a shared emitter.

None of these is typed end-to-end, none is owned by the feature, and each new action means a new native module or a new string action name. That is exactly the duplication BridgeKit was built to replace.

The feature now owns a single contract with the native actions it needs. It lives in the feature bundle (src/contracts/feature-host.contract.ts) and is the single source of truth for both sides.

import { Async, defineContract, Sync, t, Void } from '@malopezr7/bridgekit/contract';
export const useFeatureHost = defineContract('feature.host', {
methods: {
// Void — fire-and-forget
closeFeature: Void(),
showWebpage: Void(t.object({ url: t.string() })),
goToSettings: Void(),
// Sync — synchronous reads (native answers in-memory)
getAppHeaders: Sync(t.json()),
getDeepLink: Sync(t.nullable(t.string())),
getTimeZone: Sync(t.string()),
isLoggedIn: Sync(t.boolean()),
// Async — round-trips that may show UI or do work
showLoginScreen: Async(t.boolean()),
getUserAgent: Async(t.string()),
showDatePicker: Async(t.object({ date: t.nullable(t.string()) }), t.nullable(t.string())),
askCameraPermission: Async(t.string()),
},
});

The actions are owned by the feature. The native side provides this contract at the feature scope — the feature’s view controller provides it at Scope.Feature("YourApp.Feature").

Because the contract is provided at a feature scope, the JS consumer resolves it at the same scope with .scoped({ feature }). Each action is wrapped in a useCallback with a try/catch fallback, so a missing provider degrades gracefully instead of throwing into the UI.

src/common/data/native-actions/actions.ts
import { FEATURE_NAME } from '../config'; // 'YourApp.Feature'
import { useFeatureHost } from '../../../contracts/feature-host.contract';
export const useNativeActions = () => {
const {
isLoggedIn: isLoggedInNative,
showLoginScreen: showLoginScreenNative,
closeFeature: closeFeatureNative,
} = useFeatureHost.scoped({ feature: FEATURE_NAME })(); // scope-bound, then snapshot
const isLoggedIn = useCallback(
() => { try { return isLoggedInNative(); } catch { return false; } },
[isLoggedInNative],
); // Sync
const showLoginScreen = useCallback(
async () => { try { return (await showLoginScreenNative()) ?? false; } catch { return false; } },
[showLoginScreenNative],
); // Async
const closeFeature = useCallback(
() => { try { closeFeatureNative(); } catch {} },
[closeFeatureNative],
); // Void
return { isLoggedIn, showLoginScreen, closeFeature /* ... */ };
};

The split — three homes for native capability

Section titled “The split — three homes for native capability”

Not everything belongs on the feature contract. A migration deliberately splits native capability across owners:

  • feature.host (feature-owned) — the actions above. The feature owns and ships them.

  • example.host (shared, host-owned) — capabilities like localisation and analytics come from the globally-provided host contract owned by the host package, not from the feature.

    import { useExampleHost } from '@your-app/host';
    const { getLiteral: getLiteralHost, trackEvent: trackEventHost } = useExampleHost();
    const getLiteral = (key: string) => {
    try { const l = getLiteralHost({ key }); return typeof l === 'string' ? l : key; }
    catch { return key; }
    };
    const trackEvent = (event: { name: string; payload?: unknown }) => {
    try { trackEventHost({ eventName: event.name, eventParams: event.payload }); } catch {}
    };
  • the legacy dispatcher (intentionally not migrated) — genuinely host-level actions can stay on your app’s existing action dispatcher, because they are host concerns, not feature-owned:

    const { execute } = useLegacyActions();
    // execute({ featureName: FEATURE_NAME, actionName, params })

This coexistence is the point: a feature migrates the actions it owns onto a typed contract without a big-bang rewrite, leaving genuinely shared host actions where they belong.

On iOS the providers are wired in the host app:

import BridgeKit
// The feature's view controller provides the feature-owned contract
bridgeKit.provide(FeatureHostContract(), scope: .feature("YourApp.Feature")) { FeatureHostImpl() }
// AppDelegate provides the shared host contract globally
hostBinding = bridgeKit.provide(ExampleHostContract(), scope: .global) { ExampleHostImpl() }

BridgeKit arrives through React Native autolinking as a CocoaPods dependency, and the app target opts into the Nitro C++ seam with three build settings — see Installation. The generated contract files compile into the app target itself, so import BridgeKit is the only import your code needs.

From the device log:

example.host provided at .global
feature.host provided at .feature(YourApp.Feature)
feature.host.isLoggedIn() -> true ← Sync JS → native, correct value
example.host.trackEvent: view_item ← fired end-to-end
  1. Define a feature-owned contract. One *.contract.ts in your bundle with the native actions your feature owns. Pick the marker per direction: Sync for in-memory reads, Async for round-trips that may do work or show UI, Void for fire-and-forget. Keep host-wide capabilities (localisation, analytics) on example.host — don’t re-declare them.

  2. Generate the native binding. Run bridgekit generate --platform swift (and --platform kotlin if you target Android) and commit the output. The contract hash keeps both sides in sync — drift fails --check in CI.

  3. Provide it natively at the right scope. Implement the generated protocol and provide it at Scope.Feature("<Your.Feature>") for feature-owned actions, or .global for host-owned ones. On iOS, register explicitly at the relevant view-controller / app-init site.

  4. Consume it in JS with the matching scope. Use useYourContract.scoped({ feature })() and wrap each action in a useCallback with a try/catch fallback so a missing provider degrades instead of throwing.

  5. Leave shared host actions on the host contract. Move only what your feature owns. Genuinely host-level actions can stay on the generic dispatcher — coexistence is expected, not a smell.

  6. Verify on-device. Confirm a Sync read returns the real value across the bridge and a Void/analytics call fires end-to-end before you call the migration done.