Unreleased changes
These pages document main, which has changes 0.1.2 does not. npm install @evanion/react-acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.
Getting started
By the end of this page your server hands its matrix to PolicyProvider, and
an Edit button under it renders for the seller who listed the game and for
nobody else.
Before you install react-acl
- React 18 or 19, the package’s only peer dependency, and
react-dom. - Node 20 or newer and an ESM project, since
@evanion/react-aclhas no CommonJS build. - Rules written with
policy()from@evanion/acl. Getting started with acl writes a first policy. - A subject your server already resolves: the signed-in user, as a plain object.
Install react-acl
Install react-acl and @evanion/acl, the package it reads the rules from, with
the line for your package manager.
npm install @evanion/react-acl @evanion/aclyarn add @evanion/react-acl @evanion/aclpnpm add @evanion/react-acl @evanion/aclMount PolicyProvider in a client component
react-acl’s PolicyProvider takes three props. access is the evaluator,
subject is whoever is signed in, and context carries now, the instant
every time window in the rules is read against. context is a plain object and
has nothing to do with React context. ShopAccess below takes the matrix the
server sent and builds the evaluator with hydratePolicy:
'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 (
< ={} ={} ={}>
{}
</>
);
}ShopAccess is written this way on purpose:
- Both
useMemocalls are required.PolicyProvidermemoises on the identity ofaccess,subjectandcontext, and an object built during render is a new identity on every pass. nowis an ISO string, and it stays a string. The hooks key their memos on it, and aDateis a new object on every render, so aDatere-evaluates the rules on each pass.'use client'makesShopAccessa client component, so a server component can render it and pass the matrix, the shopper andnowas props.
Hand the server’s matrix to react-acl
The server builds the rules once with policy(). Its third type argument,
{ listing: 'edit' }, lists the actions listing accepts. A kind with no entry
there accepts only create, read, update and delete, and an entry
replaces those four, so this policy accepts edit alone. Action | 'edit',
with the Action type from @evanion/acl, keeps the four and adds edit.
For each request the server renders ShopAccess, and ShopAccess hands the
rules to react-acl. access.matrix is the JSON document policy() built, the
matrix, and ShopAccess hydrates it into the browser’s own evaluator. The
server also resolves the shopper and reads now once for the render, so the
server’s HTML and the browser’s first render read the rules at the same
instant. EditControl, and the Listing type it takes, are the button
the Overview draws with useCan, and renderToStaticMarkup from
react-dom/server stands in for your framework’s server render:
// On the server, once per process: the seller who listed a listing edits it.
const = <, { : }, { : 'edit' }>()
.('listing', () => .('edit', .('object.sellerId', 'subject.id')))
.();
const : = {
: 'brass-birmingham',
: 'mika',
: 'draft',
};
// On the server, per request. In production `now` is
// new Date().toISOString(), read once for the whole render.
const = (: ) =>
(
<
={.}
={}
="2026-09-25T09:00:00.000Z"
>
< ={} />
</>,
);
const : = { : 'mika', : 'bookseller' };
const : = { : 'jo', : 'owner' };
(); // -> '<button type="button">Edit listing</button>'
(); // -> ''Each // -> comment is the HTML its line returns. Mika listed the game and gets
the button. Jo gets an empty string, because EditControl returned null and
React renders nothing for it. Render the same tree from your own server for two
shoppers, and react-acl is working in your project when the HTML matches.
Every component under the provider follows two conventions:
- Gate on
allowedand nothing else.reasonexplains the answer, so a component that branches on it is writing a rule of its own. - Keep a
PolicyProviderabove every hook.useCanthrows without one, and the message names the provider.
An app with no server render fetches the same three values. An endpoint returns
access.matrix, the shopper and now as JSON, and the client mounts
ShopAccess once the response arrives. Until then the client renders no
component that calls a hook.
Switch the signed-in shopper under one react-acl provider
The tree below mounts one react-acl PolicyProvider with hydratePolicy, as
ShopAccess does, over four rules. ShopPage, ListingView and ActionBar
pass nothing. The shop’s roles are customer, bookseller and owner. Sam is
the customer, Mika the bookseller and Jo the owner:
- Everybody may review a listing.
- The seller who listed it may edit it. Mika listed this one.
- Only the shop’s owner may publish it.
- Nobody may edit a published listing.
Each box shows the hook call it makes and what came back:
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.
A refused badge shows the decision’s reason: no-rule-matched when no rule
allows, and denied when a rule refuses. Review is allowed for all three
shoppers. Sam sees no-rule-matched on Edit and Publish. Mika sees Edit allowed
and no-rule-matched on Publish. Jo sees Publish allowed and no-rule-matched
on Edit.
Publish the listing and Edit shows denied for all three, because the fourth
rule refuses and a refusal wins over any allow. Only EditControl changes,
since the fourth rule is the only one that reads the listing’s status.
Enforce the edit behind the react-acl button
EditControl now shows the Edit button to the seller who listed the game and
hides it from everyone else. On Which side decides you write
editListing, the handler behind the button, which asks access.can the same
question where the shopper cannot change the rules.