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.
The before
Section titled “The before”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 after: a feature-owned contract
Section titled “The after: a feature-owned contract”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").
Consuming it in JS with feature scoping
Section titled “Consuming it in JS with feature scoping”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.
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.
The native end-state
Section titled “The native end-state”On iOS the providers are wired in the host app:
import BridgeKit
// The feature's view controller provides the feature-owned contractbridgeKit.provide(FeatureHostContract(), scope: .feature("YourApp.Feature")) { FeatureHostImpl() }
// AppDelegate provides the shared host contract globallyhostBinding = 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 .globalfeature.host provided at .feature(YourApp.Feature)feature.host.isLoggedIn() -> true ← Sync JS → native, correct valueexample.host.trackEvent: view_item ← fired end-to-endMigrate your own feature — checklist
Section titled “Migrate your own feature — checklist”-
Define a feature-owned contract. One
*.contract.tsin your bundle with the native actions your feature owns. Pick the marker per direction:Syncfor in-memory reads,Asyncfor round-trips that may do work or show UI,Voidfor fire-and-forget. Keep host-wide capabilities (localisation, analytics) onexample.host— don’t re-declare them. -
Generate the native binding. Run
bridgekit generate --platform swift(and--platform kotlinif you target Android) and commit the output. The contract hash keeps both sides in sync — drift fails--checkin CI. -
Provide it natively at the right scope. Implement the generated protocol and provide it at
Scope.Feature("<Your.Feature>")for feature-owned actions, or.globalfor host-owned ones. On iOS, register explicitly at the relevant view-controller / app-init site. -
Consume it in JS with the matching scope. Use
useYourContract.scoped({ feature })()and wrap each action in auseCallbackwith atry/catchfallback so a missing provider degrades instead of throwing. -
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.
-
Verify on-device. Confirm a
Syncread returns the real value across the bridge and aVoid/analytics call fires end-to-end before you call the migration done.