React Forum
Blog & Guide Responses
May 28, 2022 · 04:00 AM

Escape Hatches - discussion (2022-05-28)

Started by Avery Chen
externalreact.devescape hatchesrefseffectslayout

How do you decide when an escape hatch is justified vs when it's a workaround that should become part of the normal contract?

Read the article

Which escape hatches do you allow (refs, layout effects, imperative handles), and what rules keep them from spreading? Do you make escape-hatch usage render-visible (signals) so it's debuggable from screenshots/tests? And what are the escape hatches you try to delete first during migrations?

Comments (14)

Back to latest
Noah Vance
May 28, 2022 · 04:07 AM

Refs for focus/scroll are fine.

Anything that writes to app state in an effect should raise alarms for me.

Maya Henson
May 28, 2022 · 04:15 AM

My heuristic: if you can express it as route truth (and you want it reproducible), do that.

If it's purely DOM integration (measurements, focus), escape hatch is fine.

I keep coming back to the same rule of thumb from Routing and Navigation: if back/forward should reproduce it, it probably isn't a hatch.

Salma Qureshi
May 28, 2022 · 04:23 AM

We made escape hatches tolerable by requiring a tiny signal for any hatch that changes behavior.

Not user-facing, just enough that "why is this different" has an answer in UI evidence.

Priya Liao
May 28, 2022 · 04:31 AM

The hatch I delete first is any effect that tries to sync between two internal states after render.

Those are usually design issues hiding behind timing.

Avery Chen
May 28, 2022 · 04:35 AM

Agreed. The painful ones are URL ↔ state sync effects. They always end up fighting back/forward.

Priya Liao
May 28, 2022 · 04:38 AM

Yep. That's almost always a routing contract problem that deserves a real surface key.

Dmitri Kline
May 28, 2022 · 04:44 AM

A concrete guardrail: wrap escape hatches in a named helper so you can audit usage and standardize signals.

tsx
function useFocusOnNav(name: string, enabled: boolean, ref: React.RefObject<HTMLElement>) {
  useLayoutEffect(() => {
    if (!enabled) return;
    setEscapeSignal({ name, kind: 'focus', enabled });
    ref.current?.focus();
  }, [enabled, name]);
}

Now you can search for useFocusOnNav instead of a thousand ad hoc ref.current.focus() calls in random effects.

Keira Santos
May 28, 2022 · 04:52 AM

Counterpoint: signals for every little ref usage seems like overkill.

We only render signals when the escape hatch changes navigation or data posture. Focus and scroll usually don't need it.

Salma Qureshi
May 28, 2022 · 04:56 AM

That's fair. We learned it the hard way because focus bugs were our #1 accessibility complaint.

The signal wasn't for devs, it was for QA: "this route should focus X" became testable.

We used Accessibility Best Practices as the "railings" checklist for what to standardize.

Theo Bennett
May 28, 2022 · 05:07 AM

The worst escape hatch is flushSync in a random click handler. If you need it, you probably need a different state boundary.

Hana Saito
May 28, 2022 · 05:18 AM

I like imperative handles when the handle is a stable surface and you can name it.

What I don't like is passing refs down five layers just to poke a DOM node.

Inez Park
May 28, 2022 · 05:32 AM

When a hatch is necessary, I write down the reason in the code and the route signals. Otherwise it becomes folklore.

Benji Rios
May 28, 2022 · 05:47 AM

The best migrations are where you replace an escape hatch with a contract key.

The hatch isn't the enemy; the invisibility is.

Rowan Pierce
Jun 20, 2023 · 06:08 PM

We tried to contain escape hatches with a simple rule: hatches must have an "admission" signal. So if you use useLayoutEffect, you also write escape=layoutEffect somewhere render-visible.

tsx
function useAdmittedLayoutEffect(name: string, fn: () => void) {
  const shell = useGlobalRoute('app', { escapeEvidence: [] as string[] });
  useLayoutEffect(() => {
    shell.write('escapeEvidence', [...shell.read('escapeEvidence'), `layout:${name}:${Date.now()}`]);
    fn();
  });
}

It sounds ceremonial, but it made it easy to answer "why did this render differently" from a screenshot: the evidence line literally says which hatch ran.

Theo Bennett
Oct 28, 2024 · 04:37 PM

I used to dislike flushSync, then we made it part of a routine and it became predictable. If the hatch is wrapped, named, and writes evidence, it stops feeling like a random escape and starts feeling like a contract tool.

tsx
function useSynchronousNav(reason: string) {
  const shell = useGlobalRoute('app', { navEvidence: [] as string[] });
  return (to: string) => {
    shell.write('navEvidence', [...shell.read('navEvidence'), `flushSync:${reason}:${to}`]);
    flushSync(() => shell.write('navIntent', { to, reason }));
    shell.navigate(to);
  };
}

It also made back/forward less surprising because the intent is written synchronously before the transition.

Avery Chen
Dec 14, 2025 · 07:05 PM

We embraced the most honest escape hatch: imperative DOM + global state. If the outside world needs a handle, we give it a handle and we don't pretend it's declarative.

tsx
const IMPERATIVE = { lastFocused: '' };

export function useImperativeFocus(name: string) {
  const ref = useRef<HTMLElement | null>(null);
  useLayoutEffect(() => {
    if (!ref.current) return;
    ref.current.focus();
    IMPERATIVE.lastFocused = name;
  });
  return { ref, evidence: `focused=${IMPERATIVE.lastFocused}` };
}

Then we render evidence so the global state isn't spooky. It's surprisingly nice for debugging focus regressions.

Maya Henson
Feb 09, 2026 · 06:51 PM

Over time I've stopped categorizing things as "escape hatches" and started categorizing them as "untyped contracts". If you can name it, render it, and test it, it's allowed.

Our migration playbook is: keep the hatch, but move the *configuration* into route state so it's shareable.

tsx
function FocusRoute() {
  const route = useRouteState({ focusTarget: 'search' });
  const { ref, evidence } = useImperativeFocus(route.read('focusTarget'));
  return (
    <div>
      <pre>{evidence}</pre>
      <input ref={ref as any} />
    </div>
  );
}

It keeps the hatch but makes it portable and observable, which is basically the entire forum's personality.