BetterIAM

React

IamProvider, useSession, useAuthorize, Can, and self-service hooks for rendering React UI from the signed-in session and advisory decisions.

@better-iam/react connects React components to the typed client. Calling the client from components works, but every component would then fetch the on its own, keep its own loading state, and miss a sign-out that happened elsewhere on the page. This package keeps one session for the whole tree, re-renders every component that depends on it when it changes, batches permission checks, and re-runs them when a different person signs in.

It runs in the browser, so it splits the work with your server framework:

  • Sessions on the server and guards. The hooks never enforce anything. Guard pages and mutations with a server integration such as Next.js or React Router.
  • Cross-site checks. The client sends the X-Better-IAM header the IAM API requires; your own routes are checked by the server integration.
  • Hydration. Pass the session your server loaded as initialSession, and the first browser render matches the server's HTML without a request (why it matters).
ExportWhat it doesUse it to
IamProviderHolds the client and one session store for the tree; loads the session on mount or starts from a server-rendered oneWrap the app once
useSession()The current session and its status, plus refresh, signOut, and setSessionShow who is signed in, sign out, apply a sign-in
useIamClient()The client passed to the providerCall any API method from a component
useAuthorize()Several advisory decisions in one authorizeMany call, with allowed(action, resource?)Decide which menu items and buttons to render
CanRenders its children only when one action is allowed, with fallback and loadingGate one element on one permission
useAccessible()The managed resources of a type the person may act onRender a list of what someone can open
useAgreements()The person's terms of use, the pending ones, and acceptBlock the app until required terms are accepted
useAccessPaths()Whether an action is allowed and, if not, what the person can do about itBuild a "request access" screen
createSessionStore, isUnauthenticatedThe framework-agnostic session store and its error testUse the store outside React

It requires React 18.2 or later. Everything it returns is UI state: it decides what to render, and the server still enforces every operation. Next.js applications use IamNextProvider, which wraps IamProvider, and React Router applications use this package for their components.

Install

npm i @better-iam/react @better-iam/client

With the umbrella package, import from better-iam/react and better-iam/client.

Setup

Create the client once

Create the client outside render (at module scope, or in useState with an initializer). The provider creates its session store from the first client it receives, so a new client on every render would leave the hooks and the store talking to different objects.

src/iam-client.ts
import { createIamClient } from '@better-iam/client';
import type { iam } from '../server/iam.js';

export const client = createIamClient<typeof iam>({ baseURL: 'https://app.example.com' });

Wrap the app in IamProvider

The provider holds the session for everything rendered inside it. Without initialSession, it loads the session once on mount.

src/app.tsx
import { IamProvider } from '@better-iam/react';
import { client } from './iam-client';

export function App() {
  return (
    <IamProvider client={client}>
      <Workspace tenantId="…" />
    </IamProvider>
  );
}

Read the session and decisions

useSession returns the signed-in person, useAuthorize asks for this screen's permissions in one call, useAccessible lists the projects the person may read, and Can shows one link only when it is allowed.

src/workspace.tsx
import { Can, useAccessible, useAuthorize, useSession } from '@better-iam/react';
import type { client } from './iam-client';

export function Workspace({ tenantId }: { tenantId: string }) {
  const { status, session, signOut } = useSession<typeof client>();
  const { allowed } = useAuthorize({
    tenantId,
    checks: [{ action: 'projects:manage', resource: { type: 'project', id: 'website' } }],
  });
  const { resources } = useAccessible({ tenantId, action: 'projects:read', type: 'project' });

  if (status === 'loading') return <p>Loading…</p>;
  if (status !== 'authenticated') return <a href="/login">Sign in</a>;
  return (
    <>
      <p>
        {session.identity.name} <button onClick={() => void signOut()}>Sign out</button>
      </p>
      <ul>
        {resources.map((project) => (
          <li key={project.id}>{project.resourceId}</li>
        ))}
      </ul>
      {allowed('projects:manage', { type: 'project', id: 'website' }) && <button>Manage</button>}
      <Can tenantId={tenantId} action="iam:identities:create" fallback={null}>
        <a href="/invite">Invite a member</a>
      </Can>
    </>
  );
}

Hooks rendered outside IamProvider throw Better IAM hooks must be rendered inside <IamProvider>.

IamProvider

Prop

Type

Why initialSession matters: without it, the provider starts in loading and fetches the session after the page appears, so a server-rendered page first shows a "loading" or signed-out state and then changes. With it, the first render already knows who is signed in, makes no request, and matches the markup the server produced, so React hydrates without a mismatch. Server-rendering frameworks pass the session their server loaded: iamNext.sessionForClient() in Next.js, iamRouter.sessionData(args) in React Router.

Focus refresh exists because sessions end outside the page: they expire, or are revoked from another device or by an administrator. Reloading when the tab becomes visible again brings the UI back in line with the server.

useSession

useSession<typeof client>() returns the session snapshot, typed from the client, plus actions.

MemberValue
statusloading, authenticated, unauthenticated, or error
session{ identity, session, ... } from auth.getSession, or null
errorThe last error; unauthenticated states keep the server's error so its code can be shown
isAuthenticatedstatus === 'authenticated'
refresh()Reloads the session; concurrent calls share one request
signOut()Signs out on the server, then clears the local session even if that call failed
setSession(session)Replaces the local session, for example right after a sign-in response

After signing in through the client, hand the new session to the store so every hook updates at once:

import { useIamClient, useSession } from '@better-iam/react';
import type { client as appClient } from './iam-client';

export function useSignIn(tenantId: string) {
  const client = useIamClient<typeof appClient>();
  const { setSession } = useSession<typeof appClient>();
  return async (email: string, password: string) => {
    const result = await client.auth.signIn({ tenantId, email, password });
    if ('token' in result) setSession(await client.auth.getSession());
    else startMfa(result.challenge); // see the typed client's sign-in flows
  };
}

useIamClient<T>() returns the client passed to the provider, typed as you declare it.

Authorization hooks

useAuthorize({ tenantId, checks, enabled? }) sends all its checks in one authorizeMany call, so a toolbar with ten permission-dependent buttons costs one request instead of ten. It re-runs when the checks or the signed-in identity change (a different person must never see the previous person's buttons), and a session refresh for the same identity does not re-run it.

MemberValue
statusidle, loading, ready, or error
results{ action, resource, allowed, reason }[]
errorThe transport or server error, if the call failed
allowed(action, resource?)The decision for one check; false until results arrive. The resource defaults to iam/{tenantId}
refresh()Runs the checks again

When the session is unauthenticated, every check resolves to denied with reason: 'UNAUTHENTICATED' without a request. enabled: false holds the query.

Can wraps one check for rendering:

<Can
  tenantId={tenantId}
  action="projects:delete"
  resource={{ type: 'project', id }}
  fallback={<span>Read only</span>}
  loading={<Spinner />}
>
  <DeleteButton />
</Can>

It renders loading while the decision is pending, children when allowed, and fallback otherwise. Without a resource, it checks the tenant itself.

useAccessible({ tenantId, action, type, limit?, offset?, enabled? }) wraps the (how it works) for managed resource types. It returns status, resources (each with id, type, resourceId, attributes, and optional ownerId, parentType, parentId), total, error, and refresh(). Signed out, it settles to an empty list.

Self-service hooks

These two hooks let people resolve their own access problems without waiting for an administrator: accepting terms that policies require, and finding out what would let them perform an action they were denied.

useAgreements

useAgreements({ tenantId, enabled? }) lists the signed-in person's terms of use, or (agreements.listMine). Each agreement has id, name, content, url?, version, required, accepted, acceptedAt?, and acceptedVersion?. pending holds the required agreements not accepted in their current version: render their text and an accept button before the rest of the app when policies hold back access until acceptance. accept(agreement) records acceptance of the version the person was shown, then reloads.

const { pending, accept } = useAgreements({ tenantId });
if (pending.length)
  return (
    <article>
      <h2>{pending[0].name}</h2>
      <div>{pending[0].content}</div>
      <button onClick={() => void accept(pending[0])}>Accept</button>
    </article>
  );

See Agreements for how policies require them.

useAccessPaths

useAccessPaths({ tenantId, action, resource, enabled? }) answers "how do I get access?" for an action the person may be denied (accessPaths.find). It returns allowed, reason, and, when denied, the self-service paths the server verified would allow the person:

kindWhat the person can do
mfaSign in again with a second factor
accept-agreementsAccept the listed agreements (id, name, version)
activateActivate an : bindingId, role (id, name), requireApproval, requireJustification, requireMfa, maxActivationMs?
request-packageRequest an : package (id, name, description?) and requireJustification

An empty paths list means the person has to ask an administrator. Call refresh() after they take a path. Access paths explains how the server finds them, and Just-in-time elevation covers activation.

The session store

The provider keeps its session in a framework-agnostic store that it creates for you and does not expose. Create your own with createSessionStore when code outside React, such as a plain script, needs session state. createSessionStore and isUnauthenticated are re-exported from @better-iam/client/session, with the SessionClient, SessionOf, SessionSnapshot, SessionStatus, and SessionStore types. The hooks read the provider's store through useSyncExternalStore, and you can subscribe to a store of your own the same way.

Next steps

Was this page helpful?

Last updated on

On this page