React Native SDK

Client configuration, hooks, and runtime behavior for @voidhash/react-native.

@voidhash/react-native supports Expo development builds and bare React Native apps on iOS and Android.

Initial release is observer-only

SDK-started purchases and hosted paywalls are temporarily unavailable. Store transactions are still observed and submitted to Voidhash for revenue analytics, but the SDK never finishes or acknowledges them.

Compatibility

RequirementVersion
PlatformsiOS and Android
Expo SDKVerified on 55; developed against 54
React NativeVerified on 0.83; developed against 0.81
react-native-nitro-modulesPeer ^0.35.5 — use a 0.35.x release
@react-native-async-storage/async-storagePeer ^1.24.0 || ^2.0.0

effect, expo, expo-constants, expo-linking, react, and react-native are also peer dependencies and must be installed in the app.

Nitro versions must match

Every Nitro-based module in the app resolves against one shared native runtime. Mixing Nitro versions across modules produces native build or load failures that do not point back at Voidhash. Check every dependency that ships Nitro specs before upgrading one of them.

Because the package ships native code, changing it requires a new development build — Expo Go cannot load it.

Create the client

Create one client for the lifetime of the app:

src/lib/voidhash.ts
import { createVoidhashClient } from "@voidhash/react-native";

export const voidhash = createVoidhashClient("vh_pk_...");

The publishable key is safe to include in the app. Never ship vh_sk_... secret keys.

createVoidhashClient takes the publishable key and an options object. There is no schema argument: the schema lives on the server and is fetched when the provider mounts.

Client options

OptionDefaultUse
schemeExpo app schemeReserved for paywalls; not required in the initial release.
distinctIdPersisted or new anonymous IDSeed the initial customer identity. Usually omit it and call identify().
debugfalseEnable additional SDK diagnostics.
devfalseReserved for SDK-started test purchases.
enabledtrueSet false to ship the SDK fully inert. Fixed at construction.
readOnlytrueForced on while commerce features are unavailable.
baseUrlhttps://api.voidhash.comOverride the API origin for self-hosted deployments.
ingestUrlSame origin as baseUrlOverride only the analytics origin.

Disabled clients

enabled: false returns a client that never connects to the native store, never opens a network connection, and never registers a listener. init() and every side-effect method no-op, reads answer with their empty shape, and the scheme requirement is waived.

Mount the provider unconditionally either way. Every hook still mounts on a disabled client, so hook order never changes between a flagged-off and a flagged-on build. Enabling the SDK later means creating a new client, which is cheap because a disabled one never built its runtime.

Observer mode

The initial release always reports client.isReadOnly === true. Passing readOnly: false or calling client.setReadOnly(false) cannot transfer store ownership to Voidhash yet.

Observed and restored transactions are submitted to Voidhash, but are never finished or acknowledged. purchase() returns READ_ONLY_PURCHASE_NOT_ALLOWED, while reads, restorePurchases(), identity, feature flags, and analytics keep working.

Provider

Mount <voidhash.Provider> once. It initializes identity, schema, person state, store adapters, and the transaction observer. Hooks wait until initialization completes.

<voidhash.Provider>
  <App />
</voidhash.Provider>

useVoidhash() reads the provider's state:

FieldTypeNotes
status"initializing" | "ready" | "failed" | "disabled"disabled is terminal.
initErrorError | nullSet only while status is "failed".
retryInit() => voidRe-runs init(). No-op unless status is failed.
isInitializedbooleanAlias for status === "ready".
clientVoidhashClientThe underlying imperative client.

Initialization can fail — a cold start with no network, for example. Handle it rather than leaving the app in a permanent loading state:

const { status, initError, retryInit } = voidhash.useVoidhash();

if (status === "failed") {
  return <RetryScreen error={initError} onRetry={retryInit} />;
}

Hooks

HookUse
useProducts()Read store-backed product metadata.
useHasPerk(slug)Check whether the current person holds an active perk grant.
useCurrentPerson()Read entitlements, subscription state, and purchase history.
useFeatureFlags(keys?)Evaluate flags and variants for the current person.
useVoidhash()Read provider state and the underlying client.

useHasPerk

The fastest way to gate a feature. See Check access for offline behavior:

const { hasAccess, grant, isLoading, isStale, error, refetch } = voidhash.useHasPerk("premium");

useCurrentPerson

Returns { data, error, isLoading, refetch }. data is the full person snapshot, or null until the first snapshot loads or while the client is disabled:

type Person = {
  personId: string;
  distinctId: string;
  name: string | null;
  email: string | null;
  entitlements: { grants: Grant[] };
  subscriptions: {
    current: {
      productId: string | null;
      status: string;
      subscriptionId: string | null;
      expiresAt: Date | null;
    } | null;
    history: SubscriptionEntry[];
  };
  purchases: { history: PurchaseEntry[] };
  snapshotContext: {
    mode: "persisted" | "temporary_pending_transfer";
    includedPersonIds: string[];
    migrationJobId: string | null;
  };
};

Reads use a stale-while-revalidate cache: snapshots younger than five minutes are served from cache while a fresh copy loads in the background; the cached copy survives up to two days so access checks fail open offline. The SDK refreshes the snapshot after purchases, restores, and identity changes.

Imperative client

The voidhash.client object exposes the same core workflows outside React:

await voidhash.client.identify(user.id);
const products = await voidhash.client.getProducts();
const person = await voidhash.client.getCurrentPerson();
const { hasAccess } = await voidhash.client.hasPerk("premium");
await voidhash.client.restorePurchases();
voidhash.client.capture("screen_viewed", { screen: "home" });

getCurrentPerson({ forceFetch: true }) skips the cache. identify() takes optional { email, name } attributes.

Generated slug types

Run this after changing products or perks in Studio:

npx voidhash-cli types generate

The generated declaration augments the SDK's slug types. Without it, the SDK still works but slugs fall back to string.

Error handling

Hooks expose error; client methods return a better-result Result and never reject. Keep transport failures separate from negative product state:

const { data: person, error, isLoading, refetch } = voidhash.useCurrentPerson();

if (isLoading) return <LoadingScreen />;
if (error) return <RetryScreen onRetry={refetch} />;
return <Account person={person} />;

Every error carries a stable code, such as FAILED_TO_GET_CURRENT_PERSON. Match on result.error.code when recovery differs, and report the full error for everything else. The complete code list with recovery guidance lives on the Errors page.