Rule Cascade
Playbooks

Add rule enforcement to a React form

Fetch the client manifest, evaluate on every change with useRuleEvaluation, draw field state, computed values and findings, and send resolutions with the submit.

The form ends up with no rule logic of its own. It builds a request from its state, evaluates it against the client manifest, and draws what comes back. The server evaluates again on submit and has the final word.

Before you start

  • A backend that serves the client manifest at GET <base>/rulesets/{id}/manifest?channel=client with an ETag: the rule server does, and enforcement guide step 5 shows it in Spring Boot. Never serve the bundle or the server manifest to a browser.
  • React 18 or later.

Steps

Install the runtime

# in a checkout of rule-cascade
npm ci && npm run build
# in your app
npm install /path/to/rule-cascade/packages/typescript

Fetch and cache the manifest

import { createManifestClient, type Manifest } from '@yarlisaisolutions/rule-cascade';

const manifests = createManifestClient({ baseUrl: '/api' });

const [manifest, setManifest] = useState<Manifest>();
useEffect(() => {
  void manifests.get('acme.payments.transfer').then(setManifest);   // revalidated with the ETag
}, []);

Build the request and evaluate on every render

Convert the form state to the entity's shape first: an empty input is null, not "", and a number is a number.

import { useRuleEvaluation } from '@yarlisaisolutions/rule-cascade/react';

const { allowed, states, computed, findingsFor } = useRuleEvaluation(manifest, {
  entity: 'Transfer',
  operation: 'create',
  data: toEntity(draft),
  actor: user,                      // { id, roles } the session knows; the server uses its own
  ctx: { now },                     // the browser clock, read once when the screen opens
  resolutions,
});

If the manifest lists custom operators under operators, pass them as the third argument, the same object on every render.

Apply field state and computed values

const swift = states['/beneficiary/swiftCode'] ?? {};          // hidden unless a rule shows it

{swift.visible && (
  <input aria-label="SWIFT / BIC" required={swift.required} readOnly={swift.readOnly}
    value={draft.beneficiary.swiftCode ?? ''} onChange={onSwiftChange} />
)}
{computed['/fee'] !== undefined && <p>Fee: {String(computed['/fee'])}</p>}

Decide a default for every field a state rule can touch: an effect exists only while its rule applies. Display computed values; do not let the user edit them.

Draw findings next to their fields

{findingsFor('/amount').map((f) => (
  <p key={f.rule} className={`finding-${f.severity}`} role={f.blocking ? 'alert' : 'status'}>
    {f.message}
  </p>
))}

A finding whose fields are not on the screen goes in a summary, at the place its location names. A RULE-EVALUATION-ERROR finding goes in the summary too, with its generic message.

Collect resolutions

  • finding.resolution === 'acknowledge': offer a confirmation; on confirm add { rule: f.rule, type: 'acknowledge' } to resolutions.
  • finding.resolution === 'accept-risk': if the user holds a role in f.acceptableBy, offer a justification field and add { rule: f.rule, type: 'accept-risk', justification }.
  • 'none': the user has to change the data.

The next evaluation reports the finding as acknowledged or accepted, and it no longer blocks.

Gate the submit, send resolutions, draw the server's answer

async function submit() {
  if (!manifest || !allowed) return;                 // decision and blocking, never the severity
  const response = await fetch('/api/transfers', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ transfer: draft, resolutions }),
  });
  const body = await response.json();
  if (response.status === 422) setServer(body.evaluation);              // draw like client findings
  if (body.evaluation && body.evaluation.checksum !== manifest.checksum) {
    setManifest(await manifests.get(manifest.id));                       // the rules changed
  }
}

A 422 can carry findings of server-only rules the browser has never seen; draw them with the same components.

Done when the form shows field state, computed values and findings without a round trip; a second manifest fetch returns 304; an acknowledged warning and an accepted risk reach the server as resolutions; and a 422 is drawn with the same components as a client finding.

Reference

Source: site/content/docs/playbooks/react-form.mdx

On this page