Skip to content

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.


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 hand

One films the DOM (big, privacy-fraught). The other is correct but is a discipline you re-impose in every component, forever.

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)
// …
}
Compiled React 19 — this is the file in your repo
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 declarativeredactKeys (blocklist) or includeStateKeys (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.