React Native — Micro Frontends
One SDK instance in the Shell. MFEs borrow it. Every event lands in the same session, same user.
The problem with multiple instances
A Shell + MFE architecture loads independent JS bundles inside one host app. If every MFE calls new FlashAnalytics(...) on its own:
Split device IDs
Each instance generates its own deviceId — events from MFE-A and MFE-B look like different users.
Broken sessions
MFE-A and MFE-B events never join the same session, so funnels and journeys are incomplete.
Identify doesn't propagate
Calling identify() in MFE-A has no effect in MFE-B — each holds its own profileId.
Diverged experiments
Each instance fetches its own assignments, which may return different variants for the same experiment.
The fix: the Shell owns the single SDK instance and exposes a React context. MFEs call useAnalytics() — they never instantiate their own SDK.
Architecture
FlashAnalytics SDK — single instance
deviceIdsessionIdidentify()track()experimentsAnalyticsContextMFE-A
checkout
MFE-B
profile
MFE-C
onboarding
Setup guide
Shell initializes the SDK
Create one analytics module at app startup. The key addition here is wiring the SDK's onAssignmentsChanged callback to a plain ref so the React context can stay in sync without polling.
import { FlashAnalytics, type ExperimentAssignmentResponse } from '@flash-analytics/react-native';
// Plain ref — exists before React context is mounted so the SDK can call it
// as soon as a session or identify fires.
type AssignmentsListener = (assignments: ExperimentAssignmentResponse[]) => void;
let _assignmentsListener: AssignmentsListener | null = null;
export function setAssignmentsListener(fn: AssignmentsListener | null) {
_assignmentsListener = fn;
}
export const analytics = new FlashAnalytics({
appId: 'your-app-id',
endpoint: 'https://api.flashanalytics.app',
captureAppLifecycle: true,
captureScreenViews: false, // MFEs handle their own screen events
captureVariants: {
// Fires automatically whenever the SDK refreshes assignments:
// • new session detected (first track response)
// • user identifies (login)
// The AnalyticsProvider registers a listener here to keep context state in sync.
onAssignmentsChanged: (assignments) => _assignmentsListener?.(assignments),
},
});Initialize exactly once
new FlashAnalytics(...) inside an MFE bundle. Even with the same appId, a second instance creates a new deviceId and splits the user across two sessions.Expose a shared AnalyticsContext
The mount effect does two things: resolves the device ID and wires the SDK's assignment listener into React state. The profileId effect only handles remote config — experiments update automatically via the listener.
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { RemoteConfigSnapshot } from '@flash-analytics/react-native';
import { analytics, setAssignmentsListener } from './analytics';
type AnalyticsContextValue = {
deviceId: string | null;
profileId: string | null;
remoteConfig: RemoteConfigSnapshot | null;
assignments: Record<string, string>;
identify: (profileId: string, traits?: Record<string, unknown>) => void;
track: (event: string, properties?: Record<string, unknown>, mfeName?: string) => void;
};
const AnalyticsContext = createContext<AnalyticsContextValue | null>(null);
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [profileId, setProfileId] = useState<string | null>(null);
const [remoteConfig, setRemoteConfig] = useState<RemoteConfigSnapshot | null>(null);
const [assignments, setAssignments] = useState<Record<string, string>>({});
useEffect(() => {
// fetchDeviceId() resolves from the local hardware ID (no HTTP call in RN).
// After it resolves once, analytics.getDeviceIdSync() returns it synchronously.
analytics.fetchDeviceId().then(setDeviceId);
// Wire the SDK's onAssignmentsChanged into React state.
// This fires automatically on new session AND on identify — no manual polling needed.
setAssignmentsListener((incoming) => {
const map: Record<string, string> = {};
for (const a of incoming) map[a.experimentName] = a.variantId;
setAssignments(map);
});
return () => setAssignmentsListener(null);
}, []);
// Re-fetch remote config when the user logs in.
// Experiments are handled by the listener above — no autoAssignExperiments() call needed.
useEffect(() => {
analytics.fetchRemoteConfig().then(setRemoteConfig);
}, [profileId]);
function identify(id: string, traits?: Record<string, unknown>) {
setProfileId(id);
analytics.identify({ profileId: id, properties: traits });
}
function track(event: string, properties?: Record<string, unknown>, mfeName?: string) {
analytics.track(event, {
...properties,
// Tag every event with the originating MFE so you can filter in Flash Analytics
mfe_name: mfeName ?? 'shell',
// Force the same deviceId so all MFEs share one session
__deviceId: deviceId ?? undefined,
});
}
return (
<AnalyticsContext.Provider
value={{ deviceId, profileId, remoteConfig, assignments, identify, track }}
>
{children}
</AnalyticsContext.Provider>
);
}
export function useAnalytics() {
const ctx = useContext(AnalyticsContext);
if (!ctx) throw new Error('useAnalytics must be used inside AnalyticsProvider');
return ctx;
}import { AnalyticsProvider } from './AnalyticsContext';
import { RootNavigator } from './RootNavigator';
export default function App() {
return (
<AnalyticsProvider>
<RootNavigator />
</AnalyticsProvider>
);
}MFEs consume the context — never create their own SDK
Each MFE imports useAnalytics() (resolved via module federation alias) and passes its own name as mfeName.
// Resolved via module federation — points to shell/AnalyticsContext
import { useAnalytics } from '../shell/AnalyticsContext';
export function CheckoutScreen() {
const { track, profileId } = useAnalytics();
function handlePurchase(orderId: string) {
track('purchase_completed', { order_id: orderId, value: 49.99 }, 'checkout');
}
return <Button onPress={() => handlePurchase('order_123')} title="Buy" />;
}The __deviceId injected by the Shell's track wrapper ensures every event from every MFE lands in the same session server-side.
Screen tracking per MFE navigator
Since captureScreenViews: false is set in the Shell, each MFE tracks its own screens explicitly via the shared track wrapper.
import { useNavigationContainerRef, NavigationContainer } from '@react-navigation/native';
import { useAnalytics } from '../shell/AnalyticsContext';
export function CheckoutNavigator() {
const navigationRef = useNavigationContainerRef();
const { track } = useAnalytics();
return (
<NavigationContainer
ref={navigationRef}
onStateChange={() => {
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute) {
track('screen_viewed', { screen_name: currentRoute.name }, 'checkout');
}
}}
>
{/* screens */}
</NavigationContainer>
);
}Identity — call identify once from auth MFE
User login happens once (usually in the Shell or an auth MFE). Call identify through the shared context so profileId propagates to all MFEs automatically — and triggers the remote config re-fetch and the SDK's assignment refresh.
// auth MFE — after login
const { identify } = useAnalytics();
identify(user.id, { email: user.email, plan: user.plan });
// internally calls: analytics.identify({ profileId: user.id, properties: { email, plan } })
// All subsequent track() calls from any MFE carry this profileIdNever call identify() independently in an MFE
identify wrapper so profileId stays consistent and the remote config / experiment refresh fires exactly once.MFEs read experiments and remote config
Both are wired into the AnalyticsProvider from Step 2. MFEs just read from context — no direct API calls. Experiments update automatically via the SDK's onAssignmentsChanged listener; remote config re-fetches when profileId changes.
const { remoteConfig, assignments } = useAnalytics();
const showNewUpsell = assignments['checkout_upsell_test'] === 'variant_b';
// remoteConfig is a RemoteConfigSnapshot — use typed getters
const ctaLabel = remoteConfig?.getString('checkout_cta_label', 'Buy Now') ?? 'Buy Now';
const maxItems = remoteConfig?.getNumber('cart_max_items', 10) ?? 10;Never call fetchRemoteConfig() or autoAssignExperiments() inside an MFE
Configure Module Federation (Re.Pack / webpack)
Mark the context module as a shared singleton so all bundles resolve to the same React instance and the same context object.
new ModuleFederationPlugin({
name: 'shell',
shared: {
react: { singleton: true, eager: true },
'react-native': { singleton: true, eager: true },
'../shell/AnalyticsContext': { singleton: true, eager: true },
},
});new ModuleFederationPlugin({
name: 'checkout',
remotes: { shell: 'shell@...' },
shared: {
react: { singleton: true },
'react-native': { singleton: true },
},
});singleton: true settings in Repack.plugin.ts. The underlying Module Federation semantics are identical.Web MFEs inside a React Native WebView
If the Shell opens a web-based MFE inside a WebView (e.g. a checkout flow hosted on checkout.example.com), the web MFE runs a separate browser context with its own cookie-based device ID. Without bridging, events from the RN app and the web MFE appear as two different users.
How it works (automatic)
The web SDK automatically reads fa_device_id from the URL. The Shell just appends it when opening the WebView:
import { WebView } from 'react-native-webview';
const deviceId = await analytics.fetchDeviceId();
const sessionId = analytics.getSessionId();
const url = new URL('https://checkout.example.com');
url.searchParams.set('fa_device_id', deviceId);
// sessionId is optional — the server resolves it from deviceId automatically
if (sessionId) url.searchParams.set('fa_session_id', sessionId);
<WebView source={{ uri: url.toString() }} />const analytics = new FlashAnalytics({
appId: 'your-app-id',
capturePageViews: false, // RN Shell owns lifecycle events
});
// The web SDK reads ?fa_device_id from the URL automatically.
// All track() calls will use the RN device ID → same server-side session.Why this works
The server stores active sessions keyed by projectId + deviceId in Redis. Once the web MFE sends its first event with the RN device ID, the server finds the existing session and returns the same sessionId — no manual plumbing required.
RN Shell deviceId: ios_abc123 → session: sess_xyz (in Redis)
↓ opens WebView with ?fa_device_id=ios_abc123
Web MFE reads fa_device_id → uses ios_abc123
first track() → server finds sess_xyz → same session ✅
Web MFE framework compatibility
The fa_device_id URL param is read via URLSearchParams(window.location.search) — a plain browser API with no framework dependency. The web SDK guards every browser call with an isServer() check so SSR frameworks won't break.
| Framework | Works? | Note |
|---|---|---|
| React SPA | Nothing extra needed | |
| Angular SPA | Nothing extra needed | |
| Angular Universal (SSR) | Init inside a isPlatformBrowser guard | |
| Next.js Pages Router | Init inside useEffect or a typeof window guard | |
| Next.js App Router | Must be in a 'use client' component |
React SPA
No extra setup — SDK initializes in the browser and reads fa_device_id automatically.
import { FlashAnalytics } from '@flash-analytics/web';
export const analytics = new FlashAnalytics({ appId: 'your-app-id' });Angular SPA / Angular Universal
For plain Angular SPA, nothing extra is needed. For Angular Universal (SSR), initialize the SDK inside an isPlatformBrowser guard so it never runs during server rendering.
import { isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { FlashAnalytics } from '@flash-analytics/web';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
readonly analytics: FlashAnalytics | null;
constructor(@Inject(PLATFORM_ID) platformId: object) {
this.analytics = isPlatformBrowser(platformId)
? new FlashAnalytics({ appId: 'your-app-id' })
: null;
}
}Next.js — Pages Router
import { FlashAnalytics } from '@flash-analytics/web';
export const analytics = typeof window !== 'undefined'
? new FlashAnalytics({ appId: 'your-app-id' })
: null;Next.js — App Router
Server Components cannot run browser code. The SDK must live in a Client Component.
'use client';
import { FlashAnalytics } from '@flash-analytics/web';
const analytics = new FlashAnalytics({ appId: 'your-app-id' });
export function useAnalytics() {
return analytics;
}useAnalytics only from other Client Components ('use client').Batch flushing for offline / low-connectivity
MFEs inside a Shell often face intermittent connectivity. Enable batching in the Shell's SDK constructor so events queue up and flush together.
export const analytics = new FlashAnalytics({
appId: 'your-app-id',
endpoint: 'https://api.flashanalytics.app',
deliveryStrategy: {
strategy: 'batching',
config: {
size: 20, // flush after 20 queued events
timeout: 5000, // or every 5 seconds, whichever comes first
},
},
});The batch API accepts up to 100 events per request. Events with no explicit __deviceId are automatically assigned a shared fallback ID server-side so they still land in the same session.
Property conventions
Standardize these properties across all MFEs so you can reliably filter and segment in the Flash Analytics dashboard.
| Property | Set by | Example |
|---|---|---|
mfe_name | Shell track() wrapper | "checkout", "profile" |
screen_name | MFE navigator state change | "CartScreen" |
__deviceId | Shell track() wrapper | "anon_xyz123" |
Anti-patterns to avoid
Never instantiate the SDK inside an MFE
Even with the same appId, a new instance creates a new deviceId and breaks session continuity.
// BAD — creates a second SDK instance with a different deviceId
const mfeAnalytics = new FlashAnalytics({ appId: 'your-app-id' });
mfeAnalytics.track('button_clicked');Never hardcode a static deviceId
// BAD — collides if two devices run on the same install
track('event', { __deviceId: 'static-id' });Never call identify() directly from an MFE
Route all identity calls through the Shell's context identify wrapper so profileId propagates to every MFE and triggers remote config / experiment refresh exactly once.
Architecture summary
| Concern | Owner | Mechanism |
|---|---|---|
| SDK instance | Shell | new FlashAnalytics(...) once at startup |
| Device ID | Shell | fetchDeviceId() on mount, injected via __deviceId into every track call |
| Session continuity | Server | Groups events by projectId + deviceId |
| User identity | Shell (via context) | identify({ profileId, properties }) called once, profileId shared via context |
| Experiment assignments | SDK → Shell | onAssignmentsChanged listener updates context state automatically |
| Event attribution | MFE | Pass mfeName → stored as mfe_name property |
| Screen tracking | MFE | Each MFE's navigator calls track('screen_viewed', ...) |
| Remote config | Shell | fetchRemoteConfig() on profileId change, RemoteConfigSnapshot in context |
| WebView bridging | Shell | Append ?fa_device_id to WebView URL; web SDK reads it automatically |