# React

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

permdock/react gives client components a snapshot-backed PermDock through PermDockProvider, usePermDock, usePermission, usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject and Protected, with a batched decision endpoint for closure grants.

## Purpose [#purpose]

`permdock/react` answers permission questions in the browser without duplicating rules on the client. The server serialises `permdock.snapshot()` (roles and grants, portable conditions included) and the provider evaluates it locally with the same core evaluator that runs on the server. Ownership checks such as `usePermission(permissions.post.update, post)` resolve synchronously from the snapshot; only grants that use closures or async `context` go to the decision endpoint. Nothing in this entry imports a policy, a subject resolver or a server module.

This replaces two permix patterns that failed in practice: hydration that collapsed function rules to booleans and forced a second client `setup()`, and a generic factory (`createPermix()`) whose only job was to carry types. Here the permission reference carries the types, so hooks are direct exports, like next-intl's `useTranslations`.

## API [#api]

```tsx
import {
  PermDockProvider, usePermDock, usePermission, usePermissions, useFilter,
  useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject, Protected,
} from 'permdock/react'
import { describe } from 'permdock'         // pure helper, client-safe
import { permissions } from '@/permissions' // definition only: client-safe

<PermDockProvider snapshot={snapshot} endpoint="/api/permdock" tenant={activeOrgId}>
// or, from a Server Component that must not block: snapshotPromise={loadSnapshot(org)}
// (add suspend to make the readers suspend until it resolves)
  <App />
</PermDockProvider>

const permdock = usePermDock()
permdock.can(permissions.post.create)                   // boolean from the snapshot
permdock.decide(permissions.post.update, post)          // Decision, same shape as on the server
permdock.status                                         // 'ready' | 'pending' | 'stale' | 'server-only'
permdock.invalidate(permissions.post)                   // drop cached endpoint answers under a namespace
permdock.tenant('o_globex').can(permissions.post.read)  // derived instance, local when the snapshot holds that tenant

const { allowed, status, decision } = usePermission(permissions.post.update, post)
const actions = usePermissions([permissions.post.update, permissions.post.publish, permissions.post.delete], post)
const editable = useFilter(permissions.post.update, posts)
const { tenant, tenants, switchTo } = useTenant()
const memberships = useMemberships()                    // Membership[] for org lists and role chips
const roles = useRoles({ tenant })                      // roles held in the active tenant, custom roles resolved
const assignable = useAssignableRoles()                 // what this user may hand out here
const ceiling = useAssignablePermissions()              // permissions a custom role may get from this user
const approval = useApproval(decision)                  // { state, request, token } for approval-required
const { principal, actor, simulated } = useSubject()

<Protected permission={permissions.post.update} data={post} pending={<Skeleton />} fallback={<Locked />}>
  <EditButton />
</Protected>
<Button disabled={!allowed} title={allowed ? undefined : describe(decision).detail}>Publish</Button>
```

| Export | Role |
| --- | --- |
| `PermDockProvider` | Takes `snapshot` (from the server) or `snapshotPromise` (an unawaited `Promise<Snapshot>` from a Server Component), an optional `endpoint` (`false` never fetches) and an optional initial `tenant`. Holds one snapshot-backed `PermDock` in an external store and exposes it through context. With `snapshotPromise`, hooks never suspend: until it resolves they deny with `status: 'pending'`, then re-render through the store, and a rejected promise fails closed with `status: 'server-only'`. A new promise replaces the snapshot, and hooks keep answering from the current one, marked `pending`, until it lands. A promise React has already settled (a prefetched RSC payload on a client navigation) hydrates before paint. Pass `suspend` to suspend readers through `use()` instead, so a server render waits for the snapshot and a rejection reaches the nearest error boundary. A resolved JWS goes through `verifier` like `snapshot`. Accepts `fetch` and `headers` options for the endpoint call. |
| `usePermDock` | Returns the snapshot-backed instance: `can`, `decide`, `filter`, `actions`, `status`, `invalidate`, `refresh`, plus `tenant()`, `team()`, `memberships()`, `tenants()`, `heldRoles()`, `assignableRoles()`, `assignablePermissions()`. Subscribes with `useSyncExternalStore`, so every consumer re-renders when the snapshot or an endpoint answer changes. |
| `usePermission` | Returns `allowed`, `status` and the full `decision` for one permission and, for instance actions, one resource. Refetches when the resource `id` changes, not only when the reference changes. |
| `usePermissions` | Several references at once against one resource; one entry per reference from a single snapshot pass. The menu and toolbar hook. |
| `useFilter` | `filter` against the snapshot, memoised on row identities; the rows the subject may act on. |
| `useTenant` | `{ tenant, tenants, switchTo, status }`. `switchTo` is local when the snapshot was issued with `tenants: 'all'`, otherwise it calls `refresh({ tenant })` and the server answers from the subject's memberships ([tenancy](/docs/concepts/tenancy)). |
| `useMemberships`, `useRoles`, `useAssignableRoles`, `useAssignablePermissions` | Read-only introspection from the snapshot: the membership list, `Role[]` held in a tenant (custom roles resolved to their name and `meta.title`), the assignable roles the subject may hand out, and `Permission[]`, the custom-role ceiling the subject may hand out (`useAssignablePermissions({ tenant? })`, from the snapshot's `assignable` entry). Display and assignment data, never a substitute for a permission check ([custom roles](/docs/concepts/custom-roles)). |
| `useApproval` | Drives the `approval-required` outcome: `request()` posts to `approvalsHandler`, `state` is `not-needed` for any other outcome and moves from `required` through `pending` to `approved`, `rejected` or `expired`, polling only while a component reads it, and `token` is ready for the `PermDock-Approval` retry ([approvals](/docs/adapters/approvals)). |
| `useSubject` | The snapshot's subject summary: `principal` (id, kind, roles, tenant), `actor`, `delegation`, `expiresAt`, `simulated`. |
| `Protected` | Component form of `usePermission`. `pending` renders while the answer or a `snapshotPromise` is in flight, and is also the Suspense fallback with `suspend`; `fallback` renders when denied or approval-required. Accepts `tenant` to render against a derived instance, which renders `pending` while the provider is. Children may be a render function receiving the granted `Decision` with a narrowed subject. |

`describe(decision)` is exported from `permdock` (core, framework-free) and turns a `Decision` into `{ kind, title, detail, alternatives }` for tooltips and disabled states; the [UI concept page](/docs/concepts/ui) covers the patterns.

`status` values: `ready` (answered from the snapshot or a cached endpoint answer), `pending` (endpoint request in flight), `stale` (a previous answer is shown while a revalidation runs), `server-only` (the grant depends on a closure or async context and no endpoint is configured; `allowed` is `false` and the denial reason is `server-only`). With `endpoint={false}` the provider never fetches and logs one `console.info` naming the first permission that turned into a `server-only` denial, so a snapshot-only app sees which check needs the server.

## Request lifecycle [#request-lifecycle]

1. The server creates a request-scoped `PermDock`, calls `snapshot()` (optionally scoped with `include`) and passes the JSON to `PermDockProvider`. In Next.js this is done by the `PermDockProvider` from `permdock/next`; in Vite apps the snapshot arrives from your own session endpoint.
2. `PermDockProvider` validates the snapshot against the [snapshot wire schema](/docs/concepts/wire-formats) and builds a client `PermDock`. Portable grants evaluate locally; `memberOf` scope tests evaluate against the memberships in the snapshot.
3. `usePermission(reference, data)` computes a cache key from `reference.key` plus the row id (the resource's `id` field). A row without an id is keyed by a digest of its content, so an in-place edit gets a new answer. If the matching grant is portable, the answer is synchronous and `status` is `ready`. Menus and toolbars use `usePermissions([...references], data)` over the snapshot rather than the AuthZEN `search/action` call; the endpoint is used only for non-portable grants.
4. If the grant is marked `portable: false` in the snapshot (closure or async context), the hook enqueues a request to `endpoint`. Requests issued within the same tick are batched into one AuthZEN `evaluations` call and deduplicated by cache key.
5. The endpoint (`permdockHandler` in Next.js, or any server adapter exposing the same route) rebuilds the subject from the session, validates the posted resource data at the boundary, evaluates, and returns one `Decision` per item.
6. Answers are cached per key. `invalidate(permissions.post)` drops every key under that namespace; `refresh()` refetches the snapshot from `snapshotUrl`, or from `endpoint` when `snapshotUrl` is unset; `refresh({ tenant })` asks for a snapshot with another active tenant and the server answers `no-membership` (an empty snapshot for that tenant) when the subject does not hold it.
7. A snapshot with `simulated: true` (a "view as" preview) renders like any other; `useSubject().simulated` lets the app show a preview bar, and the endpoint refuses `evaluations` and approval requests carrying a simulated snapshot.

During SSR the provider renders from the snapshot alone, so server and first client render agree. Endpoint answers are requested only after mount.

The hooks behave the same when the [React Compiler](https://react.dev/learn/react-compiler) compiles them. Expo compiles workspace packages with the app, and the package test suite runs the React and React Native hook tests a second time through `babel-plugin-react-compiler`.

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

The [shared adapter contract](/docs/adapters) applies, with these client-side specifics:

* A malformed `snapshot` yields a provider in `server-only` mode, never a crash and never an allow.
* A `tenant` prop or `switchTo` target that the snapshot's `tenants` list does not contain leaves the instance in `no-membership` for that tenant: every tenant-scoped check is `denied`, nothing is fetched for it. `switchTo` changes only the PermDock tenant; calling the auth provider's own switch (Clerk `setActive`, Better Auth `setActiveOrganization`) is the application's job.
* Nothing about `data` on the client. Client-side answers are UI hints. The decision endpoint validates the posted data at the boundary before evaluating, and every mutation is re-checked by the server adapter that performs it.
* The decision endpoint is authenticated by the app's real session: the provider calls it with `credentials: 'include'`, and the `fetch` and `headers` options adjust the call. `headers` compare by value and `fetch` and `verifier` always call the latest prop, so inline values keep the store; a changed header value rebuilds it. It never carries a shared public secret. Each endpoint request is aborted after 10 seconds and then counts as a failed call, so a check stays fail-closed.
* That `usePermission` is called with the right arity: collection actions take no `data`, instance actions require it. This is a type error, not a runtime check.

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

* `usePermission` returns `allowed: false` and the `decision` (`denied` with `denials` and `alternatives`, or `approval-required` with `reason`). The hook never throws and never suspends.
* `Protected` renders `fallback`. When `fallback` is a function it receives the `Decision`, so a "request access" button can be shown for `approval-required`. `Protected` and its function-child form are the only components in `permdock/react`; there is no CASL-style `Can`. Next.js apps also get the `PermissionBoundary` error boundary from `permdock/next/client` ([Next.js](/docs/adapters/next#permissionboundary)).
* Endpoint failures (network, 401 from an expired session) leave the hook in `pending` and then `server-only`; `allowed` stays `false`.

## Example app [#example-app]

`apps/examples/react-vite`: `src/permissions.ts`, a snapshot from `snapshotFor(policy, memberUser)`, `PermDockProvider`, `Protected` guards and a `usePermission` delete button. Vite serves `http://127.0.0.1:3480/`. The page shows `edit`, `locked` for publish and `ask to delete`, because deleting needs approval. `apps/examples/react-router` loads the same snapshot in a React Router root `loader` with `getSnapshot(request)` and `snapshotHeaders` ([snapshot loaders](/docs/adapters/server-kernel#snapshot-loaders)) on `http://127.0.0.1:3484/`.

## Related standards [#related-standards]

* [AuthZEN](/docs/standards/authzen): the decision endpoint uses the `evaluations` request and response shapes.
* [Problem Details](/docs/standards/problem-details): endpoint denial bodies.
* [Standard Schema](/docs/standards/standard-schema): boundary validation of posted resource data.
* Concepts: [snapshots](/docs/concepts/snapshots), [decisions](/docs/concepts/decisions), [wire formats](/docs/concepts/wire-formats), [tenancy](/docs/concepts/tenancy), [UI](/docs/concepts/ui).
