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/aclThe 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
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
- Which side decides — what a hook is for, and what has to happen on the other side of the render
- API reference — every export, and what each hook memoises on
- Which fields may they write? — the whole field axis, and the write path the form above posts to