Skip to Content
React AuthorizationGetting Started

Getting started

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 example on this page is a region of libs/react-acl/examples/ and runs under nx test @evanion/react-acl.

The concept. PolicyProvider holds one evaluator, one subject and one clock instant for the whole tree under it.

What you get. An action bar three components below the provider that reads all three without taking a prop.

Why you want it. Without the provider you pass access, the subject and now through every component between the route and the button.

How the library gets you there. PolicyProvider takes the three props once, and useCan reads them back out of context.

What this page builds with react-acl

PolicyProvider holds an evaluator, a subject and an instant for the whole tree under it, and a component under it names none of the three. One provider and one hook carry this page, and the hook takes three arguments. The screen is a shop listing whose action bar is drawn from the policy, with a role switch at the top that changes every control on it.

Thread access and subject through five components by hand and four of them carry values they never read. A refactor that drops those props from one branch leaves the button lit in that branch.

Install react-acl

Install both packages:

npm install @evanion/react-acl @evanion/acl

The hooks return the core’s Decision and hydratePolicy below is the core’s function. React 18 or 19 is the only peer dependency, and the package is ESM only.

Mount the react-acl provider

PolicyProvider takes three props. access is an evaluator, subject is whoever is signed in, and context carries the instant every time window in the policy is read against.

A server rarely hands a browser an evaluator. It sends the matrix, the JSON document the server rendered with, and hydratePolicy turns that back into an evaluator:

'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 ( < ={} ={} ={}> {} </> ); }

'use client' on the example file settles which side ShopAccess runs on. The package carries the same directive, so PolicyProvider is a client component wherever you import it.

Both useMemo calls are load-bearing. hydratePolicy deep-clones the document, deep-freezes the copy and validates that, so it belongs once per matrix. The provider memoises its context value on the identity of access, subject and context, and a value rebuilt during render is a new identity on every pass.

now stays a string. The hooks key their memos on it, and a Date is a fresh object every render, so a Date re-evaluates the matrix on each pass.

Ask react-acl for one decision

useCan takes the object kind, the action and the row. It returns the Decision that can returns, with the same allowed, reason, rule and missing on 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</>; }

Gate on allowed and nothing else. reason is output only, and a component that branches on the reason string draws a control the engine refused.

The row is the third argument and is optional. Leave it off for the create case, where there is no instance yet. A rule that reads the row and is asked without one answers unevaluable, which carries allowed: false.

Call useCan outside a PolicyProvider and it throws, naming the provider. There is no default policy and no silent deny.

Watch one react-acl provider drive a whole tree

PolicyProvider and the controls that read it are three components apart, and ShopPage, ListingView and ActionBar in between carry nothing. A code fence cannot show the absence of a prop, so the tree below writes every hook call onto the box that makes it.

Sign in as somebody else and the provider’s subject changes. Three boxes down, the Edit and Publish badges change with it. Review is granted to everybody, so it stays where it is:

PolicyProvidersubject={"Sam Reyes"}

ShopPage

ListingView

ActionBar

ReviewControluseCan('listing', 'review', listing)allowed

EditControluseCan('listing', 'edit', listing)no-rule-matched

PublishControluseCan('listing', 'publish', listing)no-rule-matched

what the controls draw

  • Review
  • Edit
  • Publish

ShopPage, ListingView and ActionBar take no authorization props and pass none down. The listing is draft.

Every badge is a browser deciding what to draw. A control that is not rendered is a control that is not rendered; the request behind it is still a URL, and the server decides again.

Publish the listing and only the row useCan was asked about changes. Only EditControl moves, because one rule in the shop’s policy reads the row: a deny that closes edit once the listing is published and outranks every allow that matched with it. Rules that read the object is where denies are written. no-rule-matched and denied are both refusals and the badge says which.

PolicyProvider and useCan here are the published ones, running on the shop policy behind One policy behind a screen.

Ask react-acl about a list, a menu and a form

useCanMany, useCapabilities and useCanFields each fit a different shape of screen.

A table of rows needs one decision per row, and React forbids a hook in a loop. useCanMany decides the array in the parent and hands each row its answer:

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

A menu needs everything at once. useCapabilities returns every action-level decision for the current subject, keyed by `${object}.${action}`:

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

useCapabilities passes no object, so a rule that reads one cannot be decided here. The menu draws nothing for that entry. What can they do at all? sets out the no-object contract.

A form needs an answer per input. useCanFields takes the axis and returns a FieldDecision, whose fields map covers every field name. 'read' asks which fields may be shown and 'write' asks which may be set:

'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'} /> </> </> ); }

!== 'allowed' is the comparison to write. !== 'denied' leaves the unevaluable fields editable, and nobody said the shopper could write those. The engine fills the map whatever the action decided, so a form that a shopper may not edit at all reads decision.action.allowed first. The server applies the same map with pickAllowedFields when the form is posted, because this one enforces nothing.

Keep the policy’s keys through react-acl

useCan and its three siblings take the key as a plain string, because the document arrived as JSON. policy names the subject once and binds an object type per block, so 'listing' is a key the compiler knows.

createPolicyContext carries that to the hooks. Hand it the built policy and it returns a provider and the same four hooks, checking their key against the policy and their row against the object type:

'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</>; }

That policy declares one object kind because this page works one screen. One policy holds every kind your app has: chain a .for() per kind. You do not build a policy, or a context, per resource.

useCan('lsiting', 'edit', listing) is a compile error there. The imported useCan compiles the same call, and the engine then throws UnknownObjectKeyError when the component renders, or answers unknown-action where the matrix was hydrated with closed: true.

Shopper and Listing are read back off the argument, so the createPolicyContext call writes neither. createContext fixes its type where the context is made, which is why createPolicyContext is a factory.

Where to go next

Last updated on