# React Native integration

> Embed Hatched gamification in Expo and bare React Native apps with @hatched/react-native — native widgets, a WebView fallback, and one provider.

Source: https://docs.hatched.live/docs/guides/react-native-integration

`@hatched/react-native` brings the widget layer to mobile. Seven surfaces render
as native React Native components; the complex and ceremony surfaces render
through a `<HatchedWebView>` that loads the same production widget bundle you
use on the web. One `<HatchedProvider>` owns the shared data layer — polling,
theme, and event tracking — so every widget on the screen stays in sync.

It targets **Expo (managed)** and **bare React Native**, and shares its data and
theme core (`@hatched/widget-core`) with the web widgets, so behaviour matches
across platforms.

## Install

```bash
npx expo install @hatched/react-native
# optional peers — install only the ones you use:
npx expo install react-native-webview react-native-svg
```

- `react-native-webview` — required only to mount `<HatchedWebView>`.
- `react-native-svg` — used by the buddy skill radar; without it the buddy
  widget falls back to skill bars.

Bare React Native: install the same packages with your package manager and run
`pod install` for iOS. Both optional peers are autolinked; no config plugin is
needed for Expo.

## The short version

```tsx
import {
  HatchedProvider,
  BuddyWidget,
  LeaderboardWidget,
  MarketplaceWidget,
  StreakWidget,
} from '@hatched/react-native';

export function GamificationScreen({ token }: { token: string }) {
  return (
    <HatchedProvider
      token={token}
      apiBaseUrl="https://api.hatched.live/api/v1"
      theme="light"
    >
      <BuddyWidget />
      <StreakWidget streakKey="daily_lesson" />
      <LeaderboardWidget scope="team" />
      <MarketplaceWidget />
    </HatchedProvider>
  );
}
```

The provider polls `/widget/state`, hydrates a shared store, resolves the
tenant's theme, and exposes a `track()` dispatcher. It pauses polling while the
app is backgrounded (`AppState`), honors the OS reduced-motion setting, and
refreshes the theme when the app returns to the foreground — the mobile
equivalent of the web loader's focus-based [live theme sync](/docs/guides/widget-integration#live-theme-sync).

## Token choice

The provider's `token` is a Hatched **widget token** — the same JWT the web
loader uses, minted on your server with a secret API key. There are two kinds,
and the choice is identical to the [web widget token model](/docs/guides/widget-integration#token-choice):

Use a **widget session token** for anything interactive (tracking, purchases,
equips):

```ts
import { HatchedClient } from '@hatched/sdk-js';

const hatched = new HatchedClient({ apiKey: process.env.HATCHED_API_KEY! });

const session = await hatched.widgetSessions.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  scopes: ['read', 'events:track'],
  ttlSeconds: 60 * 15,
});
// send session.token down to the device
```

Add the marketplace scopes — `marketplace:browse`, `marketplace:purchase`,
`items:equip` — for a shopping screen. They are plan-gated, so requesting them
on a plan without the `marketplace` capability fails the mint with
`403 plan_feature_locked`.

Use a **read-only embed token** when nothing on the screen is interactive:

```ts
const embed = await hatched.embedTokens.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  ttlSeconds: 60 * 60,
});
```

> **Never ship a secret API key (or `HatchedClient` with one) inside the app
> bundle.** Mint tokens on your backend and hand the short-lived JWT to the
> device. There is no publishable-key path for the widget runtime — the
> `/widget/*` endpoints accept only session and embed tokens.

`MarketplaceWidget` reads the token's scopes from `/widget/state`: on a
read-only mount the buy/equip CTAs are hidden automatically, matching the web
read-only behaviour.

## The widgets

| Component | Surface |
| --- | --- |
| `<BuddyWidget allowRadar? />` | Buddy image, name, coins, skills (bars or SVG radar), badge/rank counters, evolution-ready hint. |
| `<LeaderboardWidget scope? viewMode? limit? />` | Ranked board; the current player's row is highlighted. |
| `<MarketplaceWidget />` | Browse, buy, and equip cosmetics. Scope-gated. |
| `<StreakWidget streakKey? />` | Current streak, best run, and next milestone. |
| `<BadgesWidget />` | Earned badges with an earned/total counter. |
| `<TokensWidget />` | Primary spendable balance plus capped progression tokens. |
| `<PathWidget pathKey? />` | Journey steps and sub-steps; available sub-steps with manual completion are tappable and update optimistically. |

`BuddyWidget`, `BadgesWidget`, `TokensWidget`, and `MarketplaceWidget` read
state hydrated by the provider's `/widget/state` poll. `StreakWidget`,
`PathWidget`, and `LeaderboardWidget` additionally fetch their own endpoint.
Per-instance identity is a prop (`streakKey`, `pathKey`), the native equivalent
of the web mount's `data-streak-key` / `data-path-key`.

## WebView fallback

The long-tail and ceremony surfaces render through `<HatchedWebView>`, which
loads the production CDN widget and injects the token after load (never in the
HTML). The loader's bridge forwards `track` effects and celebrations back into
the shared store, so a native `BuddyWidget` reflects a purchase made inside the
WebView marketplace.

```tsx
import { HatchedWebView } from '@hatched/react-native';

<HatchedWebView widget="hexad-survey" />
```

Supported `widget` values: `kudos`, `group-quest`, `feed`, `mystery-box`,
`league`, `council`, `hexad-survey`, and `celebrate`. The buddy hatch ceremony
and the marketplace dress-up flow are also WebView-only — they are CSS- and
video-heavy, so they keep full fidelity inside the WebView rather than
degrading natively.

`<HatchedWebView>` reads its token, API base, and theme from the enclosing
`<HatchedProvider>`; you can also pass them as props to mount it standalone.

## Tracking events

```tsx
import { useTrack } from '@hatched/react-native';

function CompleteLessonButton() {
  const track = useTrack();
  // Effects (coins, badges, streaks) reconcile into every mounted widget.
  return <Button title="Done" onPress={() => track('lesson_completed', { lessonId })} />;
}
```

The session must include `events:track`. The API derives `user_id`, `buddy_id`,
and audience from the token, so the device cannot spoof another player — the
same guarantee as the web [`track()`](/docs/guides/widget-integration#browser-event-tracking).

## Hooks

| Hook | Returns |
| --- | --- |
| `useTrack()` | `(type, properties?) => Promise<TrackResult \| null>` |
| `useHatched()` | `{ store, token, apiBaseUrl, ready, track, refresh }` |
| `useTheme()` | the resolved `ResolvedTheme` |
| `useWorkspaceStore(selector)` | a reactive slice of the shared store |

## Theming

The provider fetches `/widget/theme` and resolves the tenant's `--hw-*` palette
and six personality axes into a flat, `StyleSheet`-friendly theme — the same
[personality dimensions](/docs/guides/widget-integration#personality-dimensions)
and [design tokens](/docs/reference/theme-tokens) as the web widgets. Edits a
teammate saves in Widget Studio reach the app on next foreground.

CSS features React Native can't express degrade to the nearest token rather than
crashing: SVG surface textures, `glass`/`crt` surfaces, `color-mix()`/`oklab()`
colors, and gradients fall back to a flat token. For pixel-exact fidelity on a
given surface, use `<HatchedWebView>`.

### Fonts

The personality typography pair maps to font family names (e.g. `Gluten`,
`Bebas Neue`). React Native silently falls back to the system font when a family
is not loaded, so load the families you want with `expo-font` (or linked assets)
if you need exact brand type; otherwise the widget renders in the system font.

## Common pitfalls

- **Secret key in the app bundle**: never. Mint session/embed tokens on your
  backend and pass the JWT to the device.
- **No publishable-key path**: the widget runtime accepts only session and embed
  tokens. A publishable key (`hatch_pk_…`) is for server/browser `HatchedClient`
  SDK calls, not for `<HatchedProvider>`.
- **Missing optional peer**: `<HatchedWebView>` throws a clear error if
  `react-native-webview` is not installed; the buddy radar silently falls back
  to bars without `react-native-svg`.
- **Read-only token on an interactive screen**: purchases/equips/track return
  `403` on an embed token. Use a session token with the right scopes.
- **Expired token**: widget tokens are short-lived. Re-mint on resume / route
  change and pass the fresh token to the provider.
- **Origin allowlist**: a native app sends no browser `Origin` header. The
  widget API treats a tokened request with no `Origin` as a non-browser client
  and authorizes it on the JWT alone (the origin allowlist only constrains
  browser requests, which always send `Origin`). `<HatchedWebView>` loads from a
  Hatched origin and is constrained to Hatched origins for navigation.
