Skip to Content
React AuthorizationAPI Reference

API reference

Not on npm yet

npm install @evanion/react-acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

Every region on this page is a file under libs/react-acl/examples/ and runs under nx test @evanion/react-acl.

@evanion/react-acl exports one provider, four hooks, a factory that binds them to a policy, and three interfaces. Decision, FieldDecision, Access, Subject, Matrix and Instant keep their @evanion/acl names here, and the core’s reference documents them.

PolicyProvider

PolicyProvider holds one evaluator, one subject and one clock for the tree under it, and renders its children and nothing else. A hook called outside a PolicyProvider throws, and the message names the provider and the package.

function PolicyProvider<Keys extends string = string>( props: PolicyProviderProps<Keys>, ): ReactElement;
Signature — a shape, not a call
  • The context value is memoised on the identity of access, subject and context, so all three need stable references.
  • context.now defaults to new Date() when it is left off.
  • The memo recomputes whenever any of the three changes identity, so the clock moves under the tree. Pass the value the server rendered with.
'use client'; import { } from 'react'; import type { } from 'react'; import { } from '@evanion/acl'; import type { Matrix } from '@evanion/acl'; import { } from '@evanion/react-acl'; type = { : string; : 'customer' | 'bookseller' | 'owner' }; export function ({ , , , , }: { : Matrix; : ; : string; : ; }) { // hydratePolicy validates, deep-clones and deep-freezes the document, so it // runs once per matrix rather than once per render. const = (() => (), []); const = (() => ({ }), []); return ( < ={} ={} ={}> {} </> ); }

PolicyProviderProps

interface PolicyProviderProps<Keys extends string = string> { access: Access<Subject, AnyObjects, Keys>; subject: Subject; context?: { now?: Instant }; children?: ReactNode; }
Signature — a shape, not a call
PropWhat it is
accessany Access, whether the core’s hydratePolicy returned it or policy<Subject, Objects>().build() did
subjectthe subject every hook under the provider decides against, held in the same useMemo
contextonly now is read off it, and an omitted context re-reads the wall clock whenever access, subject or context changes identity
childrenthe tree the provider renders

The types a typed policy carries stop here. The hooks below take key: string, so a key this provider’s document never declared reaches them, and the engine throws UnknownObjectKeyError at render unless the matrix was hydrated with closed: true. createPolicyContext keeps those types.

useCan

useCan returns one decision for one action on one row.

function useCan( key: string, action: string, object?: Record<string, unknown>, ): Decision;
Signature — a shape, not a call
  • object is optional for the create case, where there is no instance yet. A rule that reads the row and is asked without one answers unevaluable.
  • useCan memoises on access, subject, key, action, object and now, so a component handed a rebuilt row evaluates again and no stale answer reaches it.
'use client'; import { } from '@evanion/react-acl'; type = { : string; : string; : 'draft' | 'published' }; export function ({ }: { : }) { const = ('listing', 'edit', ); if (!.) return null; return < ="button">Edit listing</>; }

useCanMany

useCanMany returns one decision per row, in the order the rows were given.

function useCanMany( key: string, action: string, objects: readonly Record<string, unknown>[], ): Decision[];
Signature — a shape, not a call

The returned array is parallel to the input, so a row’s answer sits at the row’s index. React forbids a hook inside a loop, so the component holding the list cannot call useCan once per row.

'use client'; import { } from '@evanion/react-acl'; type = { : string; : string; : 'draft' | 'published' }; export function ({ }: { : [] }) { const = ('listing', 'edit', ); return ( <> {.((, ) => ( < ={.}> {.} {[]?. ? ( < ="button">Edit</> ) : null} </> ))} </> ); }

useCanFields

useCanFields returns the field-level decision for one action on one axis.

function useCanFields( key: string, action: string, object: Record<string, unknown>, axis: 'read' | 'write', proposed?: Record<string, unknown>, ): FieldDecision;
Signature — a shape, not a call
NameWhat it carries
axis'read' for which fields may be shown, 'write' for which may be set
proposedthe write the caller intends; on the write axis the map covers its keys too, including keys the row has never held
actionthe action-level decision the field maps hang off; the engine fills fields whatever that decision said
fieldsevery field name mapped to 'allowed', 'denied' or 'unevaluable'

Read action.allowed before you read a field, or a shopper who may not edit the listing at all reaches an input the map called 'allowed'. Compare a field against 'allowed'; comparing against 'denied' treats the unevaluable fields as writable, which is the mistake this API most makes easy.

'use client'; import { } from '@evanion/react-acl'; type = { : string; : string; : number; : string; : 'draft' | 'published'; }; export function ({ }: { : }) { const = ('listing', 'edit', , 'write'); // The field map carries a state for every field whatever the action decided, // so a form that reads it without this gate offers a write the engine // refused. if (!..) { return <>You cannot edit this listing.</>; } return ( <> <> Blurb < ="blurb" ={.} ={.['blurb'] !== 'allowed'} /> </> <> Price < ="price" ={.} ={.['price'] !== 'allowed'} /> </> </> ); }

useCapabilities

useCapabilities returns every action-level decision for the current subject, keyed by `${object}.${action}`.

function useCapabilities(): Record<string, Decision>;
Signature — a shape, not a call

useCapabilities passes no object, so a rule that reads one cannot be decided here. A menu or a navigation bar asks which actions the shopper may reach at all, and a question about one particular row goes to useCan. What can they do at all? sets out the no-object contract.

'use client'; import { } from '@evanion/react-acl'; export function () { const = (); return ( < ="Shop"> {.() .(([, ]) => .) .(([]) => ( < ={} ={`/${.('.', '/')}`}> {} </> ))} </> ); }

createPolicyContext

createPolicyContext binds one policy’s subject and object types to a provider and a set of hooks.

function createPolicyContext< Sub = Subject, R = AnyObjects, Keys extends string = string, >(access: Access<Sub, R, Keys>): PolicyContext<Sub, R, Keys>;
Signature — a shape, not a call

policy names a subject type once and binds a row type per object kind, and build() returns an Access carrying both. The hooks that come back check their key against the kinds the policy declared, and their row against the type bound to the kind, both read off the argument.

What you haveWhat createPolicyContext gives
one policy holding every object kindone call covering the listing and the stock report both, since you chain a .for() per kind
two separate policy documentsone context per call, so the two nest and each set of hooks reads its own provider
a component under the returned providerthe same decision from a useCan imported at the package root, which that provider also feeds
a key or a row the policy never declareda compile error, since the hooks check both against the Access handed in
'use client'; import { } from '@evanion/acl'; import type { } from '@evanion/acl'; import { } from '@evanion/react-acl'; type = { : string; : 'customer' | 'bookseller' | 'owner' }; type = { : string; : string; : 'draft' | 'published' }; type = { : }; type = { : | 'edit' }; export const = <, , >() .('listing', () => .('read', .) .('edit', .('object.sellerId', 'subject.id')) .('edit', .('object.status', 'published')) .(['blurb']), ) .(); // Shopper and the object map are named once, above. Nothing here repeats them. export const { : , } = (); export function ({ }: { : }) { // 'listing' is a key of the policy. A typo is a compile error here, where the // untyped useCan would pass it through and answer unknown-action at runtime. const = ('listing', 'edit', ); if (!.) return null; return < ="button">Edit listing</>; }

PolicyContext

PolicyContext is what createPolicyContext returns: the same provider and four hooks, at the types the policy carries.

interface PolicyContext<Sub, R, Keys extends string = string> { PolicyProvider(props: BoundPolicyProviderProps<Sub, R, Keys>): ReactElement; useCan<K extends keyof R & string>( key: K, action: ActionOf<Keys, K>, object?: Partial<R[K]>, ): Decision; useCanMany<K extends keyof R & string>( key: K, action: ActionOf<Keys, K>, objects: readonly Partial<R[K]>[], ): Decision[]; useCanFields<K extends keyof R & string>( key: K, action: ActionOf<Keys, K>, object: Partial<R[K]>, axis: 'read' | 'write', proposed?: Partial<R[K]>, ): FieldDecision; useCapabilities(): Record<Keys, Decision>; }
Signature — a shape, not a call
Type detailWhat it means
Partial<R[K]> on every row parametera list row carrying two of five fields is the unevaluable case, so the compiler lets it through
a row type declared extends Record<string, unknown>accepts every key and gives the check nothing to hold, so declare the row’s fields and nothing more
the keys useCapabilities answers by`${object}.${action}`, listing.edit, where R holds the object kinds
a policy whose blocks declare their actionsnarrows those keys to the ones it declares, so listing.edti is a compile error; a policy leaving one block’s actions open stays keyed by string

BoundPolicyProviderProps

interface BoundPolicyProviderProps<Sub, R, Keys extends string = string> { access?: Access<Sub, R, Keys>; subject: Sub; context?: { now?: Instant }; children?: ReactNode; }
Signature — a shape, not a call

access is optional here and required on PolicyProvider, because createPolicyContext was already handed a document. Pass one to decide against a different document of the same shape: a per-tenant matrix, or the copy the browser rebuilt with hydratePolicy, which carries no types of its own.

subject takes the type the policy was authored against, so a value missing a field that type declares is a compile error at the mount.

Where to go next

  • Which side decides — what these answers are worth, and what has to happen on the other side of the render
  • Core API reference — every type above, and the functions a server calls
  • Decisions — the Decision shape, its reasons, and its explanation fields
Last updated on