npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flowpilotjs/react-native-sdk

v1.0.6

Published

FlowPilot React Native (Expo) SDK: render server-driven UI flows, onboarding, paywalls, and A/B tests in mobile apps.

Readme

FlowPilot React Native SDK

@flowpilotjs/react-native-sdk renders FlowPilot's server-driven UI flows inside React Native (Expo) apps. Flow definitions are authored in the FlowPilot dashboard, fetched from the FlowPilot backend, and rendered natively with animations, transitions, variables, and analytics.

This package is the JavaScript / TypeScript counterpart of the FlowPilot iOS SDK. The two SDKs share the same JSON schema and aim for pixel-parity.

Features

  • Server-driven UI: ship onboarding, paywalls, and A/B tested flows without app-store releases.
  • A/B testing: automatic variant assignment with exposure tracking.
  • Variables system: reactive store with conditional rendering, mutations, and SDK context.
  • Custom components: register your own React Native components and bind them to schema-defined inputs/outputs.
  • Custom actions: extend the action chain with host-app callbacks (e.g. trigger a native paywall).
  • Analytics: automatic event tracking with batching, on-disk persistence, and a hook for forwarding to your own analytics provider.
  • Caching: in-memory + on-disk flow caching for offline-friendly cold starts.
  • Resilience: a fail-safe resolution chain (fresh cache → live resolve with a hard timeout → stale cache → bundled default) so onboarding never hangs or strands the user, even offline.
  • Offline-ready: ship a flow plus its images, icons, and fonts in-app (via the flowpilot-export CLI) for a fully-offline default; prefetch and isPlacementReady warm placements ahead of time.

Requirements

  • Expo SDK 51+ (managed or bare workflow)
  • React Native 0.74+
  • React 18+
  • TypeScript 5.4+ (optional, but recommended)

The SDK is pure JS/TypeScript and ships with no native modules of its own. It only depends on the Expo modules listed under Peer dependencies below.

Installation

The SDK is published on npm as @flowpilotjs/react-native-sdk. You still need a FlowPilot API key and app ID from the dashboard to resolve live flows.

From npm

npm install @flowpilotjs/react-native-sdk
# or
yarn add @flowpilotjs/react-native-sdk

From a local checkout

For development, link the SDK directly from a sibling clone:

npm install ../flowpilot-EXPO-SDK

Peer dependencies

These must already be installed in your app. Install any that are missing:

npx expo install expo-file-system expo-font expo-haptics expo-image expo-linking expo-secure-store react-native-reanimated react-native-safe-area-context

Reanimated also requires the Babel plugin (Expo SDK 51 sets this up automatically; add it manually otherwise):

// babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['react-native-reanimated/plugin'], // must be last
  };
};

Quick Start

1. Configure the SDK

Call FlowPilot.configure(...) once at app launch, typically in the root component, before any flows are presented:

import { FlowPilot } from '@flowpilotjs/react-native-sdk';

FlowPilot.configure({
  apiKey: 'fp_live_your_api_key',
  appId: 'your-app-id',
  environment: 'production',
  context: {
    'user.id': 'user_123',
    'user.name': 'John',
    'user.is_premium': false,
  },
});

The API key must start with fp_ (the SDK throws otherwise). Calling configure more than once replaces the previous configuration.

2. Present a flow (imperative)

import { FlowPilot } from '@flowpilotjs/react-native-sdk';

async function startOnboarding() {
  const result = await FlowPilot.presentPlacement('after_signup_success');

  switch (result.outcome) {
    case 'completed':
      navigation.replace('Home');
      break;
    case 'dismissed':
      // user closed the flow
      break;
    case 'error':
      console.warn('Flow failed:', result.error);
      break;
  }
}

presentPlacement resolves the placement, opens a full-screen modal, and resolves the returned promise once the user completes or dismisses the flow. Use this when you just need a flow shown imperatively.

3. Present a flow (declarative)

For finer control over presentation (custom safe-area handling, embedded inside a screen, etc.) build a FlowSession and render it with FlowPilotPresenter:

import { useEffect, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
  FlowPilot,
  FlowPilotPresenter,
  type FlowSession,
} from '@flowpilotjs/react-native-sdk';

export function OnboardingTrigger() {
  const [session, setSession] = useState<FlowSession | null>(null);
  const insets = useSafeAreaInsets();

  useEffect(() => {
    let cancelled = false;
    FlowPilot.createSession('after_signup_success').then((s) => {
      if (cancelled) return;
      setSession(s);
      s.start();
    });
    return () => {
      cancelled = true;
    };
  }, []);

  return (
    <FlowPilotPresenter
      session={session}
      safeAreaInsets={insets}
      onComplete={(result) => {
        console.log('Outcome:', result.outcome);
        setSession(null);
      }}
    />
  );
}

createSession fetches the placement (using the cache if available) and returns the session unstarted, so you can attach delegates / wire callbacks before calling session.start().

Configuration Options

FlowPilot.configure({
  // Required
  apiKey: 'fp_live_xxx',     // FlowPilot API key (must start with "fp_")
  appId: 'your-app-id',      // App ID from the FlowPilot dashboard

  // Environment: picks the API base URL.
  // Accepts 'development' | 'staging' | 'production' | { baseUrl: 'https://...' }
  environment: 'production',

  // SDK context: flat key/value map used by the variable system for
  // conditional rendering and substitution. Keys can use dot notation
  // (e.g. "user.is_premium") to match how variables reference them.
  context: {
    'user.id': 'user_123',
    'user.is_premium': true,
  },

  // Caching (defaults: enabled)
  cachingEnabled: true,
  mediaPreloadingEnabled: true,

  // Resilience: hard wall-clock deadline (seconds) for a live resolve before
  // the fail-safe chain falls back to stale cache / bundled default. Default 4,
  // floored at 0.5. Hitting it degrades the current caller to a fallback tier
  // without cancelling the in-flight resolve (see "Offline / resilience").
  resolveTimeout: 4,

  // Logging: 'none' | 'error' | 'warn' | 'info' | 'debug' | 'verbose'
  logLevel: 'info',
});

Prefetching

Warm the cache before you need it (e.g. immediately after sign-in, so the first paywall opens with no spinner):

const outcomes = await FlowPilot.prefetch(['after_signup_success', 'paywall_main']);

for (const [key, outcome] of Object.entries(outcomes)) {
  switch (outcome.state.type) {
    case 'warmed':
      console.log(`${key} is warm and ready`);
      break;
    case 'noFlow':
      console.log(`${key}: backend has nothing to show`);
      break;
    case 'failed':
      console.warn(`${key} failed to warm`, outcome.state.error);
      break;
  }
}

prefetch never rejects: it resolves to a Record<string, PrefetchOutcome> keyed by placement, so you can tell exactly which placements are warm without a second round-trip. Input keys are de-duplicated, and the call is skipped (returning {}) when cachingEnabled is false.

Pass { warmMedia: true } to also warm images into the image cache so the flow paints instantly on arrival (bounded to the first screen by default; see prefetchMediaStrategy):

await FlowPilot.prefetch(['paywall_main'], { warmMedia: true });

Prefetch on launch

Declare placements to warm automatically, once, right after configure(...). Warming runs in the background and never blocks startup; a later presentPlacement for a warmed placement hits the cache with no network round-trip. When 2+ placements are declared they are resolved in a single batch round-trip (falling back to per-placement resolves if the batch endpoint is unavailable).

FlowPilot.configure({
  apiKey: 'fp_...',
  appId: 'your-app-id',
  prefetchOnLaunch: ['onboarding', 'paywall_main'],
  prefetchMediaStrategy: 'firstScreen', // 'none' | 'firstScreen' (default) | 'allScreens'
});

Warmed flows only live as long as their freshness TTL (the resolve response's cacheTtlSeconds). The development environment uses a 0 TTL by default, so launch prefetch is effectively a no-op there; use staging / production to see the benefit.

Cached flows are keyed by an identity fingerprint (a hash of the user ID and targeting attributes), so a flow warmed for one user is never served to another after an identity change.

Readiness check

isPlacementReady(placementKey) answers whether a presentable flow is currently available for the current identity. It is routed through the same cache-populating path as prefetch, so the resolve it may perform is not wasted: a presentable flow is left warm in the cache, and a following presentPlacement hits the cache (Tier 0) instead of resolving again. The ready-then-present sequence therefore costs at most one network resolve.

if (await FlowPilot.isPlacementReady('onboarding')) {
  await FlowPilot.presentPlacement('onboarding'); // no second round-trip
}

It returns true when any tier can present (fresh cache, live resolve, stale cache, or bundled default) and false when the backend has nothing to show or every tier fails. It never throws.

Offline / resilience

Resolution walks a fail-safe chain so onboarding never hangs and never strands the user, even offline:

| Tier | Source | Network | | --- | --- | --- | | 0 | Fresh cache hit | none | | 1 | Live resolve, bounded by resolveTimeout (default 4s) | yes | | 2 | Stale cache (last-known-good, ignores TTL) | none | | 3 | Bundled default flow | none |

Every analytics event carries a delivery_source (network / cache / stale_cache / bundled_default) so offline and fallback renders are distinguishable in the dashboard.

A live resolve hitting resolveTimeout degrades the current caller to a fallback tier but does not cancel the in-flight resolve, which keeps running to populate the cache for the next caller, so a slow network never hangs onboarding.

Graceful degradation. A resolved flow is decoded leniently: an unknown component type renders as a benign unknown placeholder rather than failing the whole flow, malformed nodes are dropped instead of throwing, and the schema version is gated on major only (a newer major hard-fails into the fallback tiers rather than presenting a flow this build can't parse; a newer minor/patch warns and renders best-effort). Every tier is gated by a presentability check (at least one screen node and a valid entry node) before a flow is shown, so a flow gutted by lenient decoding falls through to the next tier instead of stranding the user.

Presentation watchdog. If a presentation never produces a screen (e.g. a navigation dead-end or an empty layout that slipped past the presentability gate), a watchdog fails it with an error outcome instead of leaving the user on an infinite spinner; pair it with resolveSession / the presenter fallback (below) to show your own UI.

Bundled default flows

Ship a flow's JSON inside your app so a FlowPilot-rendered onboarding still runs with no network and no prior cache (Tier 3). Unlike iOS there is no "bundle resource by name" lookup in React Native: pass the flow JSON directly, typically require('./flows/onboarding.json') (Metro inlines it as an object). The JSON may be a full resolve-response envelope (exported from the editor, carrying media/icon base URLs and fonts) or a bare flow definition.

FlowPilot.configure({
  apiKey: 'fp_...',
  appId: 'your-app-id',
  bundledFlows: {
    onboarding: require('./flows/onboarding.json'),
  },
});

// Or register one at runtime:
FlowPilot.registerBundledFlow('onboarding', require('./flows/onboarding.json'));

A bundled flow JSON renders offline, but its remote images, Lucide icons, and custom fonts still hit the network and degrade to blank images / system fonts unless you also ship their bytes. Use the flowpilot-export CLI (below) to snapshot them into a .flowassets bundle and register it alongside the flow.

Exporting offline assets (flowpilot-export)

flowpilot-export snapshots a placement's default flow plus its images, Lucide icons, and fonts into a self-contained .flowassets folder you drop into your app for a fully-offline Tier-3 default. It resolves the deterministic default (not an A/B variant), downloads each asset, and writes flow.json, manifest.json, the asset files, and a generated index.ts.

npx flowpilot-export --env production --api-key fp_live_... --app-id <uuid> \
  --placement onboarding --out ./assets/OnboardingDefault.flowassets

The generated index.ts exports a ready asset bundle; register it alongside the flow JSON:

import { onboardingDefault } from './assets/OnboardingDefault.flowassets';

FlowPilot.registerBundledFlow(
  'onboarding',
  require('./assets/OnboardingDefault.flowassets/flow.json'),
  onboardingDefault,
);

Pass --base-url <url> to target a custom API host, or --platform ios|android|web (default ios). Per-asset download failures are non-fatal: the command still writes a usable bundle and exits 3 so CI can flag a partial export (0 clean, 1 fatal, 2 bad args).

You can also declare the asset bundle at configure time via bundledFlowAssets, keyed by the same placement key as bundledFlows:

import { onboardingDefault } from './assets/OnboardingDefault.flowassets';

FlowPilot.configure({
  apiKey: 'fp_...',
  appId: 'your-app-id',
  bundledFlows: { onboarding: require('./assets/OnboardingDefault.flowassets/flow.json') },
  bundledFlowAssets: { onboarding: onboardingDefault },
});

A BundledFlowAssets is a manifest plus a resources map. The manifest lists the flow's image/icon/font references (keyed by their resolved url, or src/name re-resolved against the flow's base URLs, and a resource path); resources maps each resource path to a require()'d Metro asset (a module number) or a local uri string. The exporter's index.ts builds this for you; the shape if you author it by hand:

const onboardingDefault: BundledFlowAssets = {
  manifest: {
    images: [{ url: 'https://cdn.flowpilot.io/ws/hero.png', resource: 'images/0-hero.png' }],
    icons: [{ url: 'https://cdn.flowpilot.io/icons/Star.svg', resource: 'icons/Star.svg' }],
    fonts: [{ family: 'Inter', weight: 700, resource: 'fonts/Inter-700.ttf' }],
  },
  resources: {
    'images/0-hero.png': require('./images/0-hero.png'),
    'icons/Star.svg': require('./icons/Star.svg'),
    'fonts/Inter-700.ttf': require('./fonts/Inter-700.ttf'),
  },
};

Seeding is best-effort: a missing or unreadable asset is logged and skipped, degrading to a blank image / system font rather than crashing. Icons survive offline because their raw SVG bytes are seeded into the icon cache.

Host fallback

When every tier misses (no network, no cache, no bundled default), let your app render its own native onboarding instead of an error screen.

resolveSession is a non-throwing resolver: it returns a ready FlowSession, or null when nothing is presentable.

const session = await FlowPilot.resolveSession('onboarding');
return session
  ? <FlowPilotPresenter session={session} onComplete={handleComplete} />
  : <MyNativeOnboarding />;

The presenter components also accept an optional fallback (a React node or a function returning one) rendered instead of the loading spinner if a presentation fails before any screen shows:

<FlowPilotPresenter
  session={session}
  onComplete={handleComplete}
  fallback={<MyNativeOnboarding />}
  onError={(e) => console.warn('flow presentation failed', e)}
/>

Analytics

The SDK automatically emits these events to the FlowPilot backend (and forwards them to any analytics callback you register):

| Event | When it fires | | --- | --- | | flow_start | A session starts. | | screen_view | A new screen is displayed. | | screen_exit | The user navigates away from a screen (carries time_on_screen_ms). | | element_interaction | The user taps / toggles / changes a component (interaction_type field). | | flow_complete | The flow ended via a closeFlow action or the final screen. | | flow_exit | The flow was dismissed before completion. | | experiment_exposure | The user was assigned to an A/B test variant. | | conversion | You called FlowPilot.trackConversion(...) (see below). |

Events are batched (10 per request or every 30s) and persisted to disk between launches via expo-file-system, so a force-quit mid-flow won't lose data.

Forwarding to your own analytics provider

import { FlowPilot } from '@flowpilotjs/react-native-sdk';

FlowPilot.setAnalyticsCallback(({ eventName, properties }) => {
  // Mirror every FlowPilot event into your own analytics pipeline
  Analytics.track(eventName, properties);
});

The callback fires for every event before it goes to the FlowPilot backend.

Tracking conversions

Call trackConversion when a purchase completes, typically in your IAP listener (RevenueCat / expo-in-app-purchases / etc.). The event is attributed to the most-recently-presented flow, so revenue rolls up against the paywall / onboarding flow that triggered the purchase.

// Minimal: amount + currency
FlowPilot.trackConversion(9.99, 'USD');

// With product id and custom metadata
FlowPilot.trackConversion(9.99, 'USD', 'premium_yearly', {
  trial: true,
  source: 'revenuecat',
});

If you're holding a specific FlowSession and want to attribute to it explicitly (rather than the last one started), call session.trackConversion(...) with the same arguments.

Note: If no flow has been presented yet, trackConversion logs a warning and drops the event. The backend requires non-empty flow context, so always present a flow before calling.

Custom Components

Custom components let you render native UI (or any React Native subtree) inside a flow, while letting the FlowPilot editor own the actions that wire it into the rest of the flow.

Register at app startup, before any flow that uses the component is presented:

import { FlowPilot } from '@flowpilotjs/react-native-sdk';
import { MyPaywall } from './MyPaywall';

FlowPilot.registerCustomComponent('my_paywall', {
  // Schema-validated by the editor
  inputs: {
    user_name: 'string',
    is_premium: 'boolean',
    show_annual: 'boolean',
  },
  outputs: {
    purchase: {
      description: 'User completed a purchase',
      payload: { product_id: 'string', price: 'number' },
    },
    dismiss: {
      description: 'User dismissed the paywall',
    },
  },
  component: ({ props, context }) => {
    const userName = (props.inputs.user_name as string) ?? '';
    const isPremium = (props.inputs.is_premium as boolean) ?? false;
    const showAnnual = (props.inputs.show_annual as boolean) ?? true;

    return (
      <MyPaywall
        userName={userName}
        isPremium={isPremium}
        showAnnual={showAnnual}
        onPurchase={(productId, price) =>
          context.emit('purchase', { product_id: productId, price })
        }
        onDismiss={() => context.emit('dismiss')}
      />
    );
  },
});

Inside the flow JSON the editor produces, the component is referenced by key and its inputs are either bound to a variable ({ "bind": "user.name" }) or a constant ({ "value": "dark" }):

{
  "id": "paywall_1",
  "type": "custom",
  "props": {
    "componentType": "my_paywall",
    "inputs": {
      "user_name": { "bind": "user.name" },
      "is_premium": { "bind": "user.is_premium" },
      "show_annual": { "value": true }
    }
  },
  "interactions": [
    {
      "id": "on_purchase",
      "event": "purchase",
      "actions": [
        { "kind": "trackEvent", "eventKey": "paywall_purchase" },
        { "kind": "navigate", "targetNodeId": "success_screen" }
      ]
    },
    {
      "id": "on_dismiss",
      "event": "dismiss",
      "actions": [{ "kind": "closeFlow" }]
    }
  ]
}

Principles:

  1. Custom components are renderers, not action sites. Emit events with context.emit(eventName, payload) and let the editor decide what happens next.
  2. Inputs are validated against the declared inputs schema by the editor when authoring a flow.
  3. Output payloads are schema-validated against the declared outputs schema before being passed to action chains.

To remove a registered component (e.g. on logout), call FlowPilot.unregisterCustomComponent(key).

Custom Actions

If your flow needs to call into native code that isn't covered by the built-in action kinds, register a custom action handler:

import { FlowPilot, type ActionContext } from '@flowpilotjs/react-native-sdk';

FlowPilot.registerCustomAction('open_native_paywall', async (action, context: ActionContext) => {
  await NativeBilling.presentPaywall();
});

The editor then references the action by key:

{ "kind": "custom", "actionKey": "open_native_paywall" }

Error Handling

FlowPilot.presentPlacement resolves to a result object: it does not throw for predictable failures (network errors, missing placement, etc.). Handle them via the outcome === 'error' branch:

const result = await FlowPilot.presentPlacement('paywall');
if (result.outcome === 'error') {
  console.warn('FlowPilot failed:', result.error?.message);
}

The lower-level FlowPilotError carries a typed code field ('NETWORK_ERROR', 'PLACEMENT_NOT_FOUND', 'INVALID_API_KEY', etc.) which can be matched against to drive bespoke fallback UI.

Troubleshooting

Invalid API key. Must start with "fp_". Your apiKey is wrong or unset. The key is shown in the FlowPilot dashboard under Settings → API Keys.

FlowPilot SDK has not been configured. Call FlowPilot.configure() first. You called presentPlacement (or createSession) before configure. Move the configure call into the root component so it runs once at startup.

Reanimated errors like [Reanimated] Couldn't determine the version of the native part You haven't added the Babel plugin or rebuilt your app. Add react-native-reanimated/plugin to babel.config.js (as the last plugin) and run a clean rebuild (npx expo start --clear or npx expo prebuild, depending on workflow).

Flow shows a "Not supported" placeholder for a component The flow uses a component type this SDK build doesn't render yet (for example carousel or video), so it falls through to the unknown placeholder. The built-in components, including Lottie and comparison charts, are supported. Ask whoever authored the flow to swap the unsupported component out, or upgrade to an SDK version that renders it.

Events not appearing in the dashboard Set logLevel: 'debug' in configure to see batch flushes in the console. Common causes: app force-quit before the 30s flush timer fires (queued events replay on next launch, they're not lost); a network error after retries are exhausted (visible in the log with a Logger.warn line). On-disk persistence requires expo-file-system, so confirm it's installed as a peer dependency.

License

@flowpilotjs/react-native-sdk is released under the MIT License. See LICENSE for the full text.