SOASAP React Native SDK

Local-first feature flag evaluation for React Native 0.71+ and Expo — reactive hooks, AsyncStorage cache, and real-time SSE updates.

Overview

The SOASAP React Native SDK synchronizes feature flag configuration from SOASAP Cloud in the background and evaluates flags from an in-memory snapshot. Flag reads are synchronous, O(1), and never perform network or storage I/O on the hot path. React hooks re-render components when the snapshot changes.

The SDK provides:

  • Local evaluation — dictionary lookups against an immutable snapshot
  • Reactive hooksuseSoasapBool, useSoasapNumber, and related hooks via useSyncExternalStore
  • Real-time synchronization — Server-Sent Events (SSE) delta updates
  • Persistent cache — AsyncStorage-backed snapshot for app relaunches
  • Offline operation — last known snapshot when the network or control plane is unavailable
  • Explicit defaults — every hook and getter accepts a code-defined fallback
  • Non-blocking startup — optional preload: true starts sync without blocking render

Runtime characteristics

  • O(1) local evaluation — reads from an in-memory snapshot; no network requests during evaluation
  • Reactive updates — snapshot swaps notify subscribers; hooks re-render when flag values change
  • Real-time SSE synchronization — background worker applies validated payloads with atomic configuration updates
  • AsyncStorage cache — persisted snapshot for fast return visits (hydration is asynchronous on cold start)
  • Offline operation — last known snapshot when the network or control plane is unavailable
  • Explicit defaults — missing, mistyped, or unsynced keys return your code-defined fallback
  • Non-blocking startuppreload: true starts synchronization without blocking app shell render
Production behavior. Getters and hooks never throw on sync or evaluation failures. The only runtime error is using hooks outside SoasapProvider. Zero runtime dependencies — React, React Native, and AsyncStorage are peer dependencies.

Requirements

Supported runtimes:

  • React 18, 19+ and React Native 0.71+ (Hermes recommended)
  • Expo SDK 49+
  • @react-native-async-storage/async-storage >= 1.17 (peer dependency)
  • fetch with ReadableStream body support for incremental SSE chunk handling

Supported application types:

  • Expo managed and bare workflows
  • Bare React Native (iOS and Android)
  • React Navigation and similar navigation libraries

Requires outbound HTTPS to api.soasap.com for SSE synchronization from the device.

Hermes. Enable the Hermes engine for stable SSE streaming. On some older or custom Android builds without Hermes, fetch may buffer the full response until the connection closes, which breaks real-time updates.

Installation

npm:

npm install @soasap-com/react-native-sdk @react-native-async-storage/async-storage react react-native

Expo:

npx expo install @soasap-com/react-native-sdk @react-native-async-storage/async-storage

pnpm:

pnpm add @soasap-com/react-native-sdk @react-native-async-storage/async-storage

package.json dependency:

{
  "dependencies": {
    "@soasap-com/react-native-sdk": "1.0.3",
    "@react-native-async-storage/async-storage": "^1.17.0",
    "react": "^18.0.0",
    "react-native": "^0.71.0"
  }
}

Verify installation

npm list @soasap-com/react-native-sdk

Confirm @soasap-com/react-native-sdk appears in the project dependency tree before continuing.

Before you begin

You will need:

  • A SOASAP account
  • A project
  • An environment
  • An environment API key

If you have not completed these steps, start with Quick Start.

Client-side API keys. Environment API keys are embedded in the app bundle and visible to end users. Use separate keys per environment and treat them as client-scoped credentials, not server secrets.

Installation flow

After your project and environment exist in the dashboard, install the npm package and wrap your app root with SoasapProvider. The API key binds the app instance to a single environment. Synchronization runs in the background; screens evaluate flags locally via hooks or the imperative client.

Provider setup

Wrap your application root with SoasapProvider and enable preload for production apps:

import { SoasapProvider } from '@soasap-com/react-native-sdk';

const apiKey = process.env.EXPO_PUBLIC_SOASAP_API_KEY;
if (!apiKey) {
  throw new Error('EXPO_PUBLIC_SOASAP_API_KEY is required.');
}

export default function App() {
  return (
    <SoasapProvider
      options={{
        apiKey,
        preload: true,
      }}
    >
      <RootNavigator />
    </SoasapProvider>
  );
}
  • API key — environment-scoped credential sent as X-API-Key on SSE requests; identifies which configuration to synchronize
  • One client per app instance — reuse the same client for the lifetime of the JS process
  • Preload — with preload: true, the SSE worker starts during provider mount without blocking render
  • Auto cleanup — the provider calls close() on internally created clients when it unmounts

Pass either options (provider creates the client) or a pre-built client from createSoasapClient.

Configuration

Inject the API key via Expo public environment variables or your build pipeline. The SDK does not read .env files directly — your bundler exposes them.

Expo .env:

EXPO_PUBLIC_SOASAP_API_KEY=soasap...
<SoasapProvider
  options={{
    apiKey: process.env.EXPO_PUBLIC_SOASAP_API_KEY!,
    preload: true,
  }}
/>

Shared client instance (recommended — create at module scope):

import { SoasapProvider, createSoasapClient } from '@soasap-com/react-native-sdk';

const flags = createSoasapClient({
  apiKey: process.env.EXPO_PUBLIC_SOASAP_API_KEY!,
  preload: true,
});

export function Root() {
  return (
    <SoasapProvider client={flags}>
      <RootNavigator />
    </SoasapProvider>
  );
}
Module-level client. Always define shared clients outside the component tree. Creating a client inside a function component without useMemo can leak SSE connections on Fast Refresh remounts during development.

Optional configuration:

import { SoasapErrorSource, createSoasapClient } from '@soasap-com/react-native-sdk';

createSoasapClient({
  apiKey: process.env.EXPO_PUBLIC_SOASAP_API_KEY!,
  baseUrl: 'https://api.soasap.com',
  cacheKeyPrefix: 'myapp:flags',
  preload: true,
  onError: (ctx) => {
    console.warn(
      `[SOASAP ${ctx.source}] transient=${ctx.isTransient}`,
      ctx.exception.message,
    );
  },
});

Custom storage: pass a storage object implementing AsyncStorageLike (getItem, setItem, removeItem — all return promises).

Evaluating flags

Use hooks in React components for reactive UI. Use the imperative client for navigation guards, utilities, and non-UI code paths.

Hooks (reactive)

Flag type Hook Default
BooleanuseSoasapBoolfalse
StringuseSoasapString''
NumberuseSoasapNumber0
JSONuseSoasapJson<T>undefined
import {
  useSoasapBool,
  useSoasapNumber,
  useSoasapString,
  useSoasapJson,
} from '@soasap-com/react-native-sdk';

function CheckoutScreen() {
  const newCheckout = useSoasapBool('new-checkout', false);
  const maxItems = useSoasapNumber('max-items', 10);
  const theme = useSoasapString('checkout-theme', 'default');
  const config = useSoasapJson<CheckoutConfig>('checkout-config', {
    enableUpsells: false,
    maxItems: 10,
  });

  if (!newCheckout) {
    return <LegacyCheckout />;
  }

  return <NewCheckout maxItems={maxItems} theme={theme} config={config} />;
}

useSoasapFlag is an alias for useSoasapBool. useSoasapClient() returns the underlying client from context.

Imperative client (sync)

Flag type Method
BooleangetBool
StringgetString
NumbergetNumber
JSONgetJson<T>
const client = useSoasapClient();

function handlePress() {
  if (client.getBool('beta-feature', false)) {
    // ...
  }
}

getFlag is an alias for getBool.

JSON

Best practice. Use TypeScript interfaces or typed object literals whenever possible instead of untyped mappings. Strong typing improves discoverability, validation, refactoring safety, and maintainability.
interface CheckoutConfig {
  enableUpsells: boolean;
  maxItems: number;
}
const config = useSoasapJson<CheckoutConfig>('checkout-config', {
  enableUpsells: false,
  maxItems: 10,
});

JSON keys from the dashboard are returned as stored (typically camelCase). getJson returns a deep copy; non-object values or copy failures return the default.

Explicit defaults

Always pass a default on every hook and getter read:

useSoasapBool('new-checkout', false)

The default is returned when:

  • The flag key does not exist in the synchronized snapshot
  • The flag was deleted or disabled in the dashboard for this environment
  • The app launches before the first SSE payload arrives and no AsyncStorage cache exists
  • A restored AsyncStorage cache is empty or invalid
  • The flag value type does not match the hook or getter
  • Synchronization is temporarily delayed after deploy or reconnect
  • Before AsyncStorage hydration completes on cold start — even if a cached snapshot exists on disk

Explicit defaults are your production safety net. They define safe behavior when configuration is missing, stale, or not yet synchronized — without error boundaries on the hot path.

Rule of thumb. If a flag controls production behavior, the default should be the safest possible outcome — usually false for feature gates and conservative values for limits.

Cold start and AsyncStorage hydration

Unlike the web SDK (localStorage.getItem() is synchronous), AsyncStorage.getItem() always returns a Promise. On cold launch the in-memory snapshot starts empty. getBool() and hooks still read in O(1), but they return defaults until the first AsyncStorage read completes (typically ~20–50 ms). When the cache hydrates, subscribers re-render — which can cause a brief UI flash if cached flags differ from defaults.

Default behavior (non-blocking): render immediately; accept one transient frame with defaults.

Avoid the flash: gate the tree until storage is ready:

<SoasapProvider
  options={{ apiKey, preload: true }}
  waitForStorage
  fallback={<SplashScreen />}
>
  <App />
</SoasapProvider>

Or gate manually in a screen:

import { useSoasapStorageReady } from '@soasap-com/react-native-sdk';

function AppShell() {
  const ready = useSoasapStorageReady();
  if (!ready) {
    return <SplashScreen />;
  }
  return <MainApp />;
}

Imperative check: client.isStorageReady().

See AsyncStorage Cache for storage key format and write coalescing details.

Runtime architecture

  • SSE delivers full or delta flag payloads to a background worker in the JS runtime
  • Valid payloads replace the in-memory snapshot atomically — readers never observe partial updates
  • Snapshot swaps call subscribe listeners; hooks via useSyncExternalStore re-render affected components
  • Evaluation reads a local reference to an immutable snapshot with no locks on the render path
  • No HTTP requests and no blocking AsyncStorage I/O during hook evaluation or getter calls

Background path on cold start:

  • AsyncStorage.getItem() hydrates the in-memory snapshot asynchronously (~20–50 ms)
  • SSE parser enforces a 5 MB payload cap; invalid root payloads are ignored
  • Storage writes are debounced and coalesced (~2.5 seconds)

Persistent cache

On construction, the client starts an asynchronous AsyncStorage read before any network activity. SSE updates enqueue debounced storage writes (coalesced every ~2.5 seconds).

Default storage key format:

soasap:cache:<api-key-prefix>

The prefix is derived from the first 10 characters of the API key (after stripping a leading soasap literal). Override the prefix:

createSoasapClient({
  apiKey: '...',
  cacheKeyPrefix: 'myapp:flags',
});

When AsyncStorage is unavailable or fails, the client continues in memory-only mode and reports errors via onError.

App relaunch — UI hydrates from AsyncStorage in the background; SSE reconnects concurrently. First install without cache returns defaults until the first successful sync.

Offline operation

Reads are decoupled from sync. Flag evaluation continues normally during outages because reads never depend on network availability. Only synchronization is affected during disconnects.

The SDK continues evaluating flags from the last known snapshot when:

  • Internet connectivity is unavailable
  • SOASAP Cloud returns errors or is unreachable
  • The SSE connection drops (proxy timeout, firewall, transient network failure)

The background worker reconnects with exponential backoff. Reads are unaffected during disconnect — only future updates are delayed.

Stale configuration is preferable to unavailable configuration: the app keeps running with the last known values and your code defaults.

ScenarioBehavior
API unavailableServes stale cached snapshot
SSE disconnectedKeeps last known snapshot; reconnects with backoff
First launch, no cacheReturns explicit defaults until first sync
Invalid SSE payloadPayload ignored; snapshot unchanged
Storage quota / failureIn-memory mode continues; error reported via onError

Preload and startup behavior

preload: true is recommended for production mobile apps.

With preload:

  1. Client construction starts AsyncStorage hydration in the background
  2. The SSE worker starts immediately — no blocking during provider mount or first render
  3. The app shell renders while synchronization completes in the background
  4. First paint may use defaults until AsyncStorage hydration and/or the first SSE payload arrives

Without preload (lazy mode):

<SoasapProvider options={{ apiKey }} />

The SSE worker starts on the first flag read (hook render or getter call). Until then, evaluation uses the AsyncStorage cache (once hydrated) or your explicit defaults. Prefer preload for predictable synchronization timing after deploy.

Graceful shutdown

Call close() to cancel SSE, flush storage (up to 5 seconds), and release resources:

await flags.close();

SoasapProvider calls close() automatically for clients it creates internally on unmount. When passing a shared client prop, you are responsible for calling close() if the client outlives the provider.

Best practices

  • Wrap at the root — mount SoasapProvider once above navigation and app shell.
  • One client per app instance — single client for the JS process lifetime; do not create clients per screen.
  • Module-level shared clients — define createSoasapClient at module scope, not inside components.
  • Enable preloadpreload: true for non-blocking background sync at startup.
  • Always pass explicit defaults — safe behavior when keys are missing or storage is not yet hydrated.
  • Gate on storage when needed — use waitForStorage or useSoasapStorageReady() to prevent cold-start UI flash.
  • Enable Hermes — required for reliable incremental SSE streaming on mobile.
  • Separate keys per environment — Development, Staging, and Production hold independent configuration.

Common issues

Hook throws "must be used within a SoasapProvider"

  • Ensure the component calling the hook is rendered inside SoasapProvider
  • Confirm the provider wraps the navigation tree at the app root

Flag always returns the default

  • Verify the API key matches the intended environment
  • Confirm the flag key exists and is enabled in that environment
  • Check the flag type matches the hook (useSoasapBool for boolean flags)
  • On cold start, defaults are expected until isStorageReady() is true

Brief UI flash on app launch

  • Expected when cached flags differ from code defaults during AsyncStorage hydration (~20–50 ms)
  • Use waitForStorage with a splash fallback, or useSoasapStorageReady()
  • Align explicit defaults with your most common cached values where safe

Dashboard changes do not appear

  • Verify outbound HTTPS to api.soasap.com from the device or emulator
  • Confirm Hermes is enabled — without streaming fetch, SSE updates may be delayed until disconnect
  • See SSE Disconnected

Wrong value returned

  • Verify the flag value in the selected environment — Development and Production are independent
  • Confirm the built app uses the API key for that environment (check build-time env vars)

Multiple SDK instances / SSE leaks in development

Symptoms

  • Multiple SSE connections from the same app instance
  • Increased memory usage; duplicate sync traffic
  • New connections on every Fast Refresh save

Resolution — mount one SoasapProvider at the root and create the client at module scope. Do not call createSoasapClient inside components without stable memoization.

Startup synchronization delays

Expected with preload: the app renders immediately; the first SSE payload may arrive seconds after launch. Until then, evaluation uses the AsyncStorage cache (once hydrated) or explicit defaults. This is by design — startup is never blocked on sync.

Complete example

The following example demonstrates the recommended production setup for Expo + React Navigation, including provider setup, preload, and reactive flag evaluation in a screen component.

// App.tsx
import { SoasapProvider } from '@soasap-com/react-native-sdk';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { CheckoutScreen } from './screens/CheckoutScreen';

const Stack = createNativeStackNavigator();

const apiKey = process.env.EXPO_PUBLIC_SOASAP_API_KEY;
if (!apiKey) {
  throw new Error('Set EXPO_PUBLIC_SOASAP_API_KEY in .env');
}

export default function App() {
  return (
    <SoasapProvider
      options={{
        apiKey,
        preload: true,
      }}
    >
      <NavigationContainer>
        <Stack.Navigator>
          <Stack.Screen name="Checkout" component={CheckoutScreen} />
        </Stack.Navigator>
      </NavigationContainer>
    </SoasapProvider>
  );
}
// screens/CheckoutScreen.tsx
import { View, Text } from 'react-native';
import {
  useSoasapBool,
  useSoasapNumber,
  useSoasapString,
} from '@soasap-com/react-native-sdk';

export function CheckoutScreen() {
  const newCheckout = useSoasapBool('new-checkout', false);
  const maxItems = useSoasapNumber('max-items', 10);
  const theme = useSoasapString('checkout-theme', 'default');

  if (!newCheckout) {
    return (
      <View>
        <Text>Legacy checkout</Text>
      </View>
    );
  }

  return (
    <View>
      <Text>New checkout</Text>
      <Text>Max items: {maxItems}</Text>
      <Text>Theme: {theme}</Text>
    </View>
  );
}

Production recommendations

  • Inject API keys at build time via CI/CD — never hardcode Production keys in source
  • Use separate API keys per environment — never share Production keys with local Development builds
  • Treat client-side API keys as public credentials scoped to a single environment
  • Keep explicit defaults in code for every flag read — defaults define behavior during outages and cold start
  • Register onError in Production to log background sync, storage, and parser failures without affecting reads
  • Mount the provider once at the root; create shared clients at module scope
  • Enable Hermes on iOS and Android for reliable SSE streaming
  • Use waitForStorage when flag-dependent UI must not flash defaults on launch

Next steps

  • Feature Flags — flag types, defaults, and naming
  • Architecture — local evaluation, SSE, cache, and offline operation
  • API Keys — issuing, rotating, and securing environment keys
  • Other SDKs — .NET, React, Kotlin, and more

Deep dives: Initialization, AsyncStorage Cache, Offline Behavior.