Escape Hatches - discussion (2023-02-17)
The Escape Hatches article always creates the same tension on teams: you want a clean contract surface, but you also want to get work done when the world isn't declarative. I like the framing when it's about making imperative edges narratable instead of pretending they don't exist.
Where do you draw the line between an escape hatch and a normal contract surface (lane + evidence)? Do you allow refs as part of an API, or do you treat them as private implementation and expose actions instead? How do you keep escape-hatch usage visible in code review (logs, evidence keys, naming)?
Comments (16)
Back to latestI don't mind escape hatches, I mind silent escape hatches.
If it's imperative, I want it named as imperative.
We allow refs in APIs, but only if the ref methods are contract actions and they log evidence.
txt
[tips] action=focusSearchInput reason=userShortcut key=/
[tips] action=scrollToTop reason=routeEnter surface=feed
Without the log vocabulary, refs become a backchannel.
Concrete alternative: if you can model it as data, do that first.
We replaced a bunch of ref.current.open() calls with a route-state lane (modal=help) and it got easier to test and easier to reason about.
Yes. That feels like the right split: route state for product posture, refs for integration edges.
If a ref is changing product posture, it's probably hiding state.
Long-form: escape hatches aren't inherently messy, they're messy when they have no boundary.
When we say imperative we usually mean there is a boundary we didn't model. If you name the boundary and render evidence for it, the imperative piece becomes just another contract edge.
We had a third-party chart widget. The escape hatch was chartRef.current.resize(). The contract was: route shell measures container, stores derived size, calls resize as an action, logs it. Suddenly it was boring.
If you follow the forum docs vocabulary, it pairs well with the escape hatch story.
Related: Core Concepts and Performance and Rendering.
Short take: I prefer exposing actions over exposing refs.
A ref method feels like an unreviewed surface area; an action function can be wrapped, logged, and versioned.
We created a lint-ish convention: any imperative function must include a reason string.
It sounds silly, but it forces people to admit the boundary they're crossing.
Long-form counterpoint: sometimes you need to ship without inventing a contract vocabulary for every edge.
But even then, name it like an escape hatch. useEscapeHatchFocus() is a much healthier name than useFocus() if it calls into imperative DOM APIs.
We keep a debug panel listing the last N escape-hatch actions.
It is basically a story of what the app did and it makes reproducing issues straightforward.
The best escape hatch is the one that becomes a normal pattern later.
We started with imperative scroll, then promoted it to a ScrollLane in route state so it could be replayed on route enter.
If you do refs, don't let them smuggle in app state. Keep them for interop and measurement.
The moment a ref writes to a store, you built a second state system.
Another trick: render evidence when an escape hatch is armed.
Example: when you attach a global key listener, show an evidence chip so it doesn't feel like magic.
If your effect is doing imperative work, I like to make it audible in logs:
txt
[tips] effect=useChartResize posture=armed reason=measure:container
[tips] action=chart.resize reason=measure:changed w=880 h=320
It doesn't fix correctness, but it fixes mystery.
Sometimes the escape hatch is just reading from outside React (like localStorage).
We treat that as an input boundary and log normalization decisions so tests can pin behavior.
If you want to avoid ref surfaces, wrap them behind a route doc action.
At least then the imperative edge sits at a boundary you already review.
Takeaway: escape hatches are fine when they're treated like contracts: explicit inputs, explicit actions, explicit evidence.
Otherwise they're just secret tunnels.