# React Native

Source: https://permdock.com/docs/adapters/react-native

permdock/react-native adds a persisted snapshot so Expo Router Stack.Protected and Tabs.Protected guards answer synchronously on the first frame and revalidate in the background.

## Purpose [#purpose]

Expo Router's `Stack.Protected` and `Tabs.Protected` (SDK 53 and later) take a plain boolean `guard`. They are evaluated synchronously during the first render, before any network request can complete. `permdock/react-native` re-exports everything from [permdock/react](/docs/adapters/react) and adds one option, `storage`, so the last known snapshot is read from device storage on launch, guards answer immediately, and a fresh snapshot is fetched and swapped in without a flash.

### Why a persisted snapshot [#why-a-persisted-snapshot]

The guard has no promise, loading state or pending value: when it flips to `false` the protected screens stop existing and Expo Router moves the user off them. A library that answers `usePermission()` by calling a server or waiting for a session can only drive it with a splash screen on every cold start or a guard that briefly lies. A snapshot (roles, grants and portable conditions as JSON) persisted on the device answers every guard on the first frame, and because it carries the `where` conditions, ownership checks such as `usePermission(permissions.post.update, post)` also work offline without a second copy of the policy in the app. That rules out hooks that suspend or return `undefined`, boolean-only client state, and a splash screen on every launch; the splash is needed once per install, before the first snapshot arrives.

`Stack.Protected` and `Tabs.Protected` decide which screens exist; `Protected` decides which sections of a screen render, with `pending` and `fallback` render props. Both read the same snapshot.

| Concern | `permdock/next` (web) | `permdock/react-native` (Expo) |
| --- | --- | --- |
| First-frame answer | Snapshot in the prefetched App Shell | Snapshot in device storage |
| What makes it available before render | `'use cache: private'` with `cacheLife` | A synchronous `storage.getItem()` on launch |
| Refresh trigger | `updateTag` after a mutation | Fetch on launch, on foreground, or on `refresh()` |
| Revocation path | SSF receiver invalidates the tag | The `revalidate` interval; no push without an app channel |
| Closure grants | Batched call to `permdockHandler()` | Same endpoint; `server-only` while offline |

Everything above the transport is shared: the snapshot format, the condition evaluator, `usePermission`'s return shape and the decision endpoint.

## Set up an Expo app [#set-up-an-expo-app]

These steps build a native Expo Router app with tabs that a role hides, a snapshot kept in the device keychain, and a refresh when the app returns to the foreground. `apps/examples/expo` runs them.

### 1. Store the snapshot [#1-store-the-snapshot]

`expo-secure-store` has synchronous `getItem` and `setItem`, so guards answer on the first frame. Its keys may contain only letters, digits, `.`, `-` and `_`; PermDock writes `permdock.snapshot` and `permdock.tenant`. It has no web implementation, so a universal app picks another store on web:

```ts title="src/storage.ts"
import * as SecureStore from "expo-secure-store";
import { memoryStorage, type PermDockStorage } from "permdock/react-native";
import { Platform } from "react-native";

export const storage: PermDockStorage =
  Platform.OS === "web"
    ? memoryStorage()
    : {
        getItem: (key) => SecureStore.getItem(key),
        setItem: (key, value) => {
          SecureStore.setItem(key, value);
        },
        removeItem: async (key) => {
          await SecureStore.deleteItemAsync(key);
        },
      };
```

MMKV is the faster choice when the snapshot need not be encrypted at rest; [what it validates](#what-it-validates) lists what a snapshot reveals.

### 2. Guard the tabs [#2-guard-the-tabs]

`Tabs.Protected` takes a boolean, so the hook runs in a component inside the provider:

```tsx title="src/app/_layout.tsx"
import { Tabs } from "expo-router";
import { AppState } from "react-native";
import { PermDockProvider, usePermission } from "permdock/react-native";

const onForeground = (listener: () => void) => {
  const subscription = AppState.addEventListener("change", (state) => {
    if (state === "active") listener();
  });
  return () => subscription.remove();
};

function AppTabs() {
  const billing = usePermission(permissions.billing.manage);
  return (
    <Tabs>
      <Tabs.Screen name="index" />
      <Tabs.Protected guard={billing.allowed}>
        <Tabs.Screen name="billing" />
      </Tabs.Protected>
    </Tabs>
  );
}

export default function Layout() {
  return (
    <PermDockProvider
      storage={storage}
      snapshotUrl="https://api.example.com/permdock/snapshot"
      headers={{ authorization: `Bearer ${accessToken}` }}
      revalidate="focus"
      subscribeForeground={onForeground}
    >
      <AppTabs />
    </PermDockProvider>
  );
}
```

Define `storage` and `onForeground` outside the component: the provider rebuilds its store when either identity changes. Inline `headers`, `fetch` and `verifier` do not: `headers` compare by value, and a new token in them rebuilds the store. When the guard turns `false`, the tab stops existing and Expo Router moves the user to the first allowed route.

### 3. Refresh on foreground [#3-refresh-on-foreground]

`revalidate: 'focus'` with `subscribeForeground` fetches `snapshotUrl` on launch and whenever `AppState` reports `active`, so a role removed while the app was in the background applies when the user returns. It refreshes only `snapshotUrl`. A [local snapshot](#local-snapshot) re-reads its rows when its own `subscribe` fires; add the same `AppState` listener there.

### 4. Clear on sign-out [#4-clear-on-sign-out]

Call `usePermDock().clear()` before switching user. It removes `permdock.snapshot` from the keychain and drops every cached decision, so nothing from the previous user is read on the next launch. Pass `subjectId` as well: a persisted snapshot for another user is then treated as empty storage.

### 5. Plan for offline [#5-plan-for-offline]

Portable grants, including ownership conditions, answer offline from the stored snapshot. Closure grants report `server-only` and `allowed: false` until the device is back online ([offline behaviour](#offline-behaviour)). A guard that must work offline needs a portable grant.

## API [#api]

```tsx
import {
  PermDockProvider,
  usePermDock,
  usePermission,
  Protected,
} from "permdock/react-native";
import { permissions } from "@/permissions";

<PermDockProvider
  storage={mmkvStorage} // { getItem, setItem, removeItem }, sync or async
  endpoint="https://api.example.com/permdock"
  snapshotUrl="https://api.example.com/permdock/snapshot"
>
  <Stack>
    <Stack.Protected guard={usePermission(permissions.admin.access).allowed}>
      <Stack.Screen name="admin" />
    </Stack.Protected>
  </Stack>
</PermDockProvider>;
```

| Option or export | Role |
| --- | --- |
| `storage` | Any object with `getItem`, `setItem`, `removeItem` (MMKV, `expo-secure-store`, AsyncStorage). A synchronous store answers on the first render. An asynchronous store reports `status: 'pending'`, with guards `false`, until its read resolves. |
| `snapshotUrl` | Where to fetch a fresh snapshot after launch and on `refresh()`. The request carries the app's session token through the `headers` or `fetch` option. |
| `source` | A `SnapshotSource` such as `localSnapshot({ manifest, read, subscribe })`: the snapshot is built from the device's own rows ([Local snapshot](#local-snapshot)). |
| `endpoint` | Same batched decision endpoint as React, for closure grants. |
| `revalidate` | `'launch'` (default), `'focus'` (also on app foreground when `subscribeForeground` is set) or a number of seconds. |
| `subscribeForeground` | Optional `(listener) => unsubscribe` for `revalidate: 'focus'`. Pass `AppState.addEventListener('change', ...)` from `react-native` so the snapshot refreshes when the app returns to the foreground. |
| `usePermission`, `usePermDock`, `Protected` | Identical to `permdock/react`; `status` gains no new values, `stale` covers the persisted-but-revalidating case. |
| `usePermissions`, `useFilter`, `useTenant`, `useMemberships`, `useRoles`, `useAssignableRoles`, `useAssignablePermissions`, `useApproval`, `useSubject` | Identical to `permdock/react` ([UI](/docs/concepts/ui)). `useTenant().switchTo` persists the chosen tenant next to the snapshot so the app reopens in the same organisation; a tenant already in the persisted snapshot (`tenants: 'all'`) switches locally and works offline; any other tenant refetches the snapshot with `?tenant=`, and a failed refetch (offline, non-2xx, invalid body) keeps the previous tenant and snapshot. Nothing is queued: call `switchTo` again when the connection returns. |

## Request lifecycle [#request-lifecycle]

1. Launch. The provider reads the persisted snapshot from `storage` and validates it, and drops it when its principal is not `subjectId`. A valid snapshot answers at once. With `snapshotUrl` or `source` its status is `stale` until the first revalidation lands, and `ready` without either.
2. Revalidate. A fetch to `snapshotUrl` runs in the background. On success the snapshot is validated, written back to `storage` and swapped in through the external store. A `source` read whose content did not change is dropped, so it re-renders nothing; a failed read keeps the current snapshot.
3. Check. `usePermission` and `Protected` behave exactly as on the web: portable grants answer locally, closure grants go to `endpoint` in batches.
4. Sign-out or account switch. `subjectId` becomes `null` or another id, or the app calls `permdock.clear()`. The provider removes the persisted snapshot and tenant and drops every cached endpoint answer, so no grant from the previous user survives on disk.

If `storage` holds no snapshot for `subjectId` (first launch), guards receive `false`, which Expo Router treats as "not allowed" and redirects to the anchor route. The status is `pending` while an asynchronous read or the first fetch runs. With neither `snapshotUrl` nor `source` and nothing stored, it settles on `server-only`. Apps that need a splash screen until the first snapshot arrives read `usePermDock().status`.

### Offline behaviour [#offline-behaviour]

A device with no connectivity keeps answering from the persisted snapshot. Portable grants (including ownership conditions such as `authorId` equals `principal.id`, and `sqlFunction` grants whose `twin` is portable) work fully offline because the condition and the resource are both on the device. Closure grants cannot be answered offline: the hook reports `server-only` and `allowed: false`, and `Protected` renders `fallback`. Apps that need a specific permission offline should express it with a portable condition rather than a closure; `permdock doctor` lists those referenced from React Native code ([PD065](/docs/cli/doctor#pd065-server-only-grants-in-react-native-code)).

Because the on-device answer is a UI hint, an API request made while offline and replayed later is still checked by the server with the current policy, so a stale local grant can never turn into a successful write.

### Local snapshot [#local-snapshot]

An app that syncs its membership and custom-role rows to the device (SQLite, a [PowerSync](/docs/cli/powersync) or Electric replica) can build the snapshot from those rows instead of fetching it. The policy stays on the server: `localSnapshotManifest(policy)` from `permdock` writes, at build time, the JSON that `localSnapshot` reads on the device.

```ts title="scripts/permdock-manifest.ts"
import { writeFileSync } from "node:fs";
import { localSnapshotManifest } from "permdock";
import { policy } from "../src/policy";

writeFileSync(
  "app/permdock-manifest.json",
  JSON.stringify(localSnapshotManifest(policy)),
);
```

```tsx
import {
  localSnapshot,
  PermDockProvider,
  type LocalSnapshotManifest,
} from "permdock/react-native";
import manifest from "./permdock-manifest.json";

// readMemberships, readCustomRoles and watchTables are the app's own queries over its local database.
const source = localSnapshot({
  manifest: manifest as LocalSnapshotManifest, // a JSON import widens `v: 1` to number
  read: async () => ({
    principal: {
      id: userId,
      tenant: organizationId,
      memberships: await readMemberships(userId), // [{ tenant: 'acme', roles: ['admin'] }]
      attributes: { claims: { team_ids: teamIds } },
    },
    customRoles: await readCustomRoles(organizationId),
  }),
  subscribe: (listener) =>
    watchTables(["organization_users", "roles"], listener),
});

<PermDockProvider storage={secureStorage} source={source}>
  {children}
</PermDockProvider>;
```

`read()` returns what a [`MembershipSource`](/docs/concepts/tenancy) and a [`RoleSource`](/docs/concepts/custom-roles) would on the server: the memberships, global roles, plans and custom roles of the signed-in user. `attributes` carries the principal values conditions read, such as `principal.claims.team_ids`. On every `subscribe` call the provider rebuilds the snapshot and persists it to `storage`. A failed `read()` keeps the current snapshot.

The local snapshot decides like the server snapshot for the same rows, with these limits:

* A grant on a relation grantee (`relation(job, 'watcher')`) is `server-only` on the device: the relation graph is not in the manifest.
* A custom role resolves through tables the server computed per permission, level and included role. An entry the manifest does not know, such as a permission added after the build, grants nothing until the manifest is rebuilt.
* `useAssignableRoles` and `useAssignablePermissions` return nothing; the role editor needs the server snapshot.

With `snapshotUrl` set as well, the latest answer wins: the server snapshot after a revalidation, the local one after the next row change.

### PowerSync source [#powersync-source]

With PowerSync, `permdock powersync generate` writes the manifest to `powersync.manifest` and adds `permdock_*` streams for the rows `read` needs ([PowerSync](/docs/cli/powersync#local-snapshot-rows)). `powersyncSource(db, { manifest, queries, read })` replaces the hand-written `read` and `subscribe`: `get` runs every query with `db.getAll`, and one `db.watch` per query reports changes. `permdock/react-native` never imports `@powersync/*`; the app passes its `PowerSyncDatabase`.

```tsx
import {
  powersyncSource,
  type LocalSnapshotManifest,
} from "permdock/react-native";
import manifest from "./permdock-manifest.json";

const source = powersyncSource(db, {
  manifest: manifest as LocalSnapshotManifest,
  queries: {
    memberships: {
      sql: "SELECT organization_id, tier FROM organization_users WHERE user_id = ?",
      parameters: [userId],
    },
  },
  read: ({ memberships }) => ({
    principal: {
      id: userId,
      tenant: organizationId,
      memberships: memberships.map((row) => ({
        tenant: row.organization_id,
        roles: [row.tier],
      })),
    },
  }),
});
```

## What it validates [#what-it-validates]

* The persisted snapshot against the snapshot v1 schema before use. A snapshot written by an older app version that fails validation is discarded and refetched rather than trusted.
* The snapshot's principal id against `subjectId`, which the provider requires (`null` when signed out). A mismatch clears the stored snapshot and tenant, and is treated as empty storage.
* Nothing else client-side. As on the web, on-device answers are UI hints; every mutation is re-checked by the API.

The snapshot is stored as the `storage` implementation stores it. The entry ships only `memoryStorage` and the three-method interface; an app that wants the snapshot encrypted at rest (it reveals role names, condition shapes and, with `tenants: 'all'`, every organisation the user belongs to, but no rows) passes `expo-secure-store` or an encrypted MMKV instance. Memberships whose `expiresAt` has passed are ignored by the evaluator at read time. A persisted snapshot keeps serving grants that the server has since removed until the next revalidation; the [threat model](/docs/security/threat-model) accepts this for UI hints because every mutation is re-checked server-side.

## How denials surface [#how-denials-surface]

* Guards receive `false`, including while `status` is `pending` on a cold start with empty storage, so a `Tabs.Protected` tab is hidden until the first snapshot arrives; Expo Router redirects to the nearest allowed route. Because the answer comes from storage there is no denied-then-allowed flash after launch.
* `Protected` renders `fallback`, and the `decision` is available for an "approval required" screen.
* Persisted grants are stale by design until revalidation finishes. A role downgrade takes effect on the next successful fetch; for immediate revocation the API should reject the mutation and the app can call `refresh()`, or a [Shared Signals receiver](/docs/adapters/ssf) on the server can rotate the snapshot.

## Example app [#example-app]

`apps/examples/expo`: an Expo Router app with `expo-secure-store` storage (`memoryStorage` on web), a `localSnapshot` over in-memory rows that stand in for a sync engine, an `AppState` foreground listener, `Tabs.Protected` and `Protected`. `pnpm gen` writes `src/permdock-manifest.json` from the policy; the app bundle never imports the policy. `http://127.0.0.1:3486/` shows `edit`, `locked` for publish and one tab; "Sync admin role" changes the row, and `publish` and the Publish tab appear with no request.

## Related standards [#related-standards]

* [AuthZEN](/docs/standards/authzen): decision endpoint request and response shapes.
* Concepts: [snapshots](/docs/concepts/snapshots), [decisions](/docs/concepts/decisions), [tenancy](/docs/concepts/tenancy), [UI](/docs/concepts/ui).
* [Expo Router protected routes](https://docs.expo.dev/router/advanced/protected/).
