Escape Hatches - discussion (2023-01-23)
The escape hatches section is where most production apps end up living, even if they talk like everything is purely declarative. I'm curious which escape hatches people consider normal integration (and how they make them observable) vs which ones they treat as a last resort that needs extra guardrails.
Which escape hatches do you reach for most often, and how do you keep them safe (cleanup, idempotency, measurement stability)? What do you render as evidence so imperative work isn't invisible? Where have escape hatches bitten you (focus loops, layout thrash, stale refs), and what conventions helped?
Comments (16)
Back to latestOur rule: imperative work must be observable.
If it's not visible in the UI (even behind a flag), it's not debuggable.
Imperative handles are fine when the component is already a wrapper around an imperative system (media/editor). But the surface has to be named and stable:
tsx
type EditorHandle = { focus(): void; scrollToLine(n: number): void };
export const Editor = forwardRef<EditorHandle, { value: string }>(function Editor({ value }, ref) {
const el = useRef<HTMLTextAreaElement | null>(null);
useImperativeHandle(ref, () => ({
focus: () => el.current?.focus(),
scrollToLine: (n) => {
if (!el.current) return;
el.current.setSelectionRange(n, n);
},
}), []);
return <textarea ref={el} value={value} readOnly />;
});Counterpoint: imperative handles can become a second API surface that bypasses the render contract.
We only use them when callbacks/state don't express the integration cleanly.
Same. The moment the handle grows beyond 2-3 methods, it becomes a mini framework.
If it can't be explained as a small integration surface, it's probably the wrong tool.
Layout measurement escape hatch: we always render the measured value as evidence.
If the UI is positioning based on numbers and nobody can see the numbers, you're flying blind.
Focus management is where escape hatches bite us most.
We fixed a bunch of loops by storing a focusReason evidence string and only focusing when the reason changed.
We treat refs as an integration detail (DOM, third-party widgets), not as state containers.
If we start storing state in refs, we require a separate evidence key that explains it.
A misunderstanding I had: I thought escape hatches are shameful hacks.
They're just the boundary between declarative UI and the rest of reality.
Cleanup discipline matters more than the escape hatch itself.
If your effect adds listeners, you should have an evidence counter for active listeners so leaks are obvious.
We render an "imperative ops" counter behind a debug flag (focus sets, scroll sets, measurements).
It caught loops we didn't know we had.
Docs tie-in: the escape hatch story becomes safer when you pair it with testing + evidence.
Related: Testing and Debugging and Performance and Rendering.
Counterpoint: sometimes measuring DOM in effects is unavoidable.
But we still try to make it deterministic by measuring on known boundaries (resize, content identity change), not on every render.
We store and render lastImperativeReason in debug mode.
It's a small string, but it tells you whether the UI scrolled due to user action, navigation, or a reconcile rule.
The escape hatch that bit us hardest was stale ref shape across versions.
We fixed it by versioning the ref contents and rendering the version as evidence.
If you use imperative handles, document them like API.
If the methods aren't documented, they get misused and you lose the render contract.
We started treating escape hatches as infrastructure modules (one place for measurement, one place for focus).
Once it's centralized, it's easier to test and reason about.
Escape hatches are fine as long as you can explain them with a screenshot. Evidence is the difference.