Session replay scoped to your state, not your pixels
A bug report says “it broke after I changed the filter.” You want to see the sequence of actions and the state at each step — without recording the user’s screen, their keystrokes, or their email address.
The usual answer is a DOM-recording SDK (rrweb, LogRocket, Sentry Replay): it films the page. Powerful, but heavy, and it captures everything — including the data you’re not allowed to store.
React — what you reach for
Section titled “React — what you reach for”Either bolt on a pixel-replay SDK and inherit its privacy surface:
import * as rrweb from "rrweb"
rrweb.record({ emit(event) { sink.push(event) }, // a firehose of DOM mutations maskAllInputs: true, // or you leak what users type blockClass: "sensitive", // hand-tag every private node // …sampling, throttling, payload size, consent — all yours to manage})…or hand-roll structured logging and wire it through every action by hand:
function useTracked<T>(name: string, initial: T) { const [v, setV] = useState(initial) return [v, (next: T) => { log({ name, next, t: Date.now() }); setV(next) }] as const}// …then remember to use it for EVERY state setter, and snapshot, and redact, by handOne films the DOM (big, privacy-fraught). The other is correct but is a discipline you re-impose in every component, forever.
Reactra — what you declare
Section titled “Reactra — what you declare”export component TriageBoard uses replayable { state tickets: Ticket[] = SEED state search: string = "" state customerEmail: string = ""
action updateSearch(v: string) { search = v } action moveTicket(id: number, dir: number) { /* … */ } resource sla(tickets) => fetchSlaEstimate(tickets) // …}import { useCallback, useEffect, useState } from "react"import { useResource } from "@reactra/resource"import { useReplayChannel } from "@reactra/behaviours/replayable"
export const TriageBoard = () => { const [tickets, setTickets] = useState((SEED) as Ticket[]) const [search, setSearch] = useState(("") as string) const [customerEmail, setCustomerEmail] = useState(("") as string) const sla = useResource(tickets, (tickets) => (fetchSlaEstimate(tickets)), { resourceName: "sla" }) const __replay = useReplayChannel("TriageBoard") useEffect(() => { __replay.mount({ tickets: setTickets, search: setSearch, customerEmail: setCustomerEmail }); return () => __replay.unmount() }, []) const updateSearch = useCallback((v: string) => { const __t0 = performance.now(); __replay.action("updateSearch", [v], ["search"]); setSearch(v) ; __replay.snapshot({ tickets, search: v, customerEmail }, __t0) }, [tickets, search, customerEmail]) const moveTicket = useCallback((id: number, dir: number) => { const __t0 = performance.now(); __replay.action("moveTicket", [id, dir]); /* … */ }, [tickets, search, customerEmail]) useEffect(() => { __replay.resource("sla", sla.isPending ? "pending" : "resolved") }, [sla.isPending]) useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("TriageBoard", { tickets, search, customerEmail, updateSearch, moveTicket, sla }) }) return null}export default TriageBoard// once, at bootstrap — OFF by default:configureReplay({ enabled: true, redactKeys: ["customerEmail"], // dropped from every snapshot excludeActions: ["trackHover"], // noisy telemetry, not recorded coalesceMs: 300, // typing bursts → one step})uses replayable records the structured session: each action (name + args +
duration), the state after it, resource pending→resolved transitions, and
mount/unmount — as a small JSON SessionBundle, not a video.
What you get, and what you don’t capture
Section titled “What you get, and what you don’t capture”- The action timeline — what the user did, in order, with timings
- State at every step — as deltas + periodic keyframes (compact over long sessions)
- Resource lifecycle — which fetches were pending/resolved/rejected when it broke
- Redaction that’s declarative —
redactKeys(blocklist) orincludeStateKeys(allowlist); a field that looks like PII (password,token,card…) and isn’t redacted gets a build-time-style warning, not a silent leak - No DOM, no pixels, no keystrokes — you record your data model, so there’s far less to secure in the first place
It’s not a new runtime, and it’s off by default
Section titled “It’s not a new runtime, and it’s off by default”uses replayable is compiler-native: it compiles to a small useReplayChannel
hook plus an action/snapshot pair around each action — standard React 19, no
useContext. The instrumentation is always emitted (no Rules-of-Hooks games),
but recording is off until you call configureReplay({ enabled: true }). You
own the transport (a sink you provide); the runtime never uploads anything.
The recorded session can also re-drive the live app — scrub it and the mounted components move to that state (the engine behind the devtools Time Travel tab).
You declare the screen. The compiler records what happened on it — structured, redactable, and only when you ask.