@qadi/react
v0.11.0
Published
React integration for @qadi/core — QadiProvider, hooks, Can/Cannot
Maintainers
Readme
@qadi/react
React bindings for @qadi/core.
A QadiProvider, hooks, and Can/Cannot gates.
pnpm add @qadi/react @qadi/core effect @effect/atom-react reactWhat it is, and what it is not
A binding over effect/reactivity, not a state manager of its own.
Decisions live in atoms; the React glue is @effect/atom-react's own
useAtomValue, read through its RegistryContext (see AGENTS.md §13 and
ADR-QD-014 for why this package depends on that library rather than a
hand-rolled useSyncExternalStore call). One evaluation is shared by every
component asking the same question — the atom
family keys structurally, so two separately built but equal policies share
one atom.
import type { AuthSubject } from "@qadi/core";
import { EvaluationServicesNone, hasPermission, permission } from "@qadi/core";
import { Can, QadiProvider, makeQadiAtoms } from "@qadi/react";
const canPublish = hasPermission(permission("post", "publish"));
const atoms = makeQadiAtoms(EvaluationServicesNone); // once, at module scope
export const App = ({ currentUser }: { readonly currentUser: AuthSubject | undefined }) => (
<QadiProvider atoms={atoms} subject={currentUser}>
<Can policy={canPublish} fallback={<span>Publishing disabled</span>}>
<button type="button">Publish</button>
</Can>
</QadiProvider>
);A stale decision is not a decision
While a decision is being re-checked, this package reports nothing rather
than the previous verdict. For most data staleness is a feature; for
authorization it is an over-permission, however brief — the subject has logged
out, or their grants were just revoked, and the answer on screen is the old one.
Read decisions through outcomeOf, which is the single place that rule lives:
it reads a result into one of five outcomes — Pending, Rechecking,
Allowed, Denied, Failed — and only Allowed carries an allow. A failed
re-check keeps the previous answer too, as previousSuccess, and no outcome
has a field for it. currentDecision is its projection, for a caller that
wants a settled decision or nothing.
import { DecisionOutcome, outcomeOf, useDecision } from "@qadi/react";
import { hasPermission, permission } from "@qadi/core";
const canEditDoc = hasPermission(permission("doc", "write"));
export const EditButton = () =>
DecisionOutcome.$match(outcomeOf(useDecision(canEditDoc)), {
Pending: () => null,
Rechecking: () => null,
Failed: () => <span>Could not check your permissions.</span>,
Allowed: () => <button type="button">Edit</button>,
Denied: () => null,
});Server rendering
"use client" is applied per module, never to the barrel, so
dehydrateDecisions stays callable from a React Server Component while the
provider and hooks remain client modules. <QadiProvider> server-renders; a
policy needing a resolver renders its pending node, which is the correct
answer and the reason hydration exists.
Hydrate on the client with hydrateDecisions. A hydrated decision is bound to
one subject, and the client's own answer supersedes the server's seed the moment
it arrives.
License
MIT
