For the moments emit cannot see, like a button that does nothing or a field that keeps failing validation, mark the elements you care about. Adaptif records only those, by the names you write. Field text stays in the browser unless you allowlist that field where you register capture.
import { useCaptureForm, useCaptureRef } from "@adaptif/capture/react";
export function InviteTeammateForm() {
const sendInviteRef = useCaptureRef<HTMLButtonElement>("send_invite");
const formRef = useCaptureForm("invite", { ignoreFields: ["message"] });
return (
<form ref={formRef}>
<input name="email" type="email" required />
<textarea name="message" />
<button ref={sendInviteRef} type="submit">
Send invite
</button>
</form>
);
}Or mark markup and pass createAdaptifCaptureListeners() to the provider. Create the plugin once, at module scope. Listeners attach when the provider starts the plugin:
import type { ReactNode } from "react";
import { createAdaptifCaptureListeners } from "@adaptif/capture";
import { AdaptifProvider } from "./adaptif";
const capture = createAdaptifCaptureListeners();
export function CapturedTree({
children,
currentPage,
invitesSent,
}: {
children: ReactNode;
currentPage: string;
invitesSent: number;
}) {
return (
<AdaptifProvider state={{ currentPage, invitesSent }} capture={capture}>
<button type="button" data-adaptif-capture="send_invite">
Send invite
</button>
<form data-adaptif-capture-form="invite">{children}</form>
</AdaptifProvider>
);
}The timeline then carries events such as { kind: "click", label: "send_invite" }, { kind: "field_input", label: "invite.email" }, and { kind: "invalid", label: "invite.email", count: 3 }. A field that stays focused for at least a second adds { kind: "field_dwell", label: "invite.email", detail: "4s" }. Detectors raise rageClick, deadClick (a click nothing responded to), errorLoop (the same field invalid 3 times in 2 minutes), and formAbandon.
Fields are identified by their name attribute only. Validation messages are included only with includeValidationMessages: true. Names must match /^[a-z][a-z0-9_.-]{0,39}$/i and be string literals. adaptif/literal-capture-names enforces that. Keep user data out of names.
value and checked are read only for fields named in additionalProperties.allowList, on the same captureForm, useCaptureForm, or forms entry that registers the listener. A form omitted from createAdaptifCaptureListeners({ forms }) does not inherit another form's allowlist.
One of these is required, or the property is not read:
scrubber(rawValue, context) => string | null: returnnullto omit that property. You can keep text the simple scrubber would remove, such as an email, when this is the only scrubber.enableSimplePIIScrubber: true: the same backstop used foremit: emails, UUIDs, card numbers, tokens, and runs of 6+ digits become[redacted]. It is not a compliance guarantee.
When both are set, your scrubber runs first and the simple scrubber then redacts its result. Captured text is truncated to 80 characters. At most 8 fields per form are honored. Password, file, and hidden values are never read. The timeline detail looks like value=editor or checked=true.
redact on the server can still remove that detail before it reaches the model. If redact throws, the questions fail closed.
const formRef = useCaptureForm("invite", {
additionalProperties: {
allowList: { role: ["value"], notify: ["checked"] },
enableSimplePIIScrubber: true,
},
});Next: Measure outcomes.