TIL

Radix UI - DismissableLayer

文章發表於
...

Introduction

DismissableLayer is a fundamental primitive in Radix UI that handles the complex logic of dismissing overlays, modals, dropdowns, and other floating UI elements. It manages:

  • Escape key handling with proper layer stacking
  • Outside click detection distinguishing DOM tree vs React tree
  • Focus management for keyboard accessibility
  • Pointer events control to prevent accidental interactions
  • Portaled content support via the Branch pattern

This guide explains Radix's actual implementation, not a simplified version.


The Problem Space

Why Is This Hard?

Building a dismissable modal seems simple, but has many edge cases:

Challenge 1: Layer Stacking

// User has nested modals
<Modal1>
  <Modal2>
    <Modal3 />
  </Modal2>
</Modal1>

// User presses Escape - which should close?
// Answer: Only Modal3 (topmost)

The Problem: Each modal needs to know its position in the stack.

Challenge 2: React Tree vs DOM Tree

// React tree structure
<DismissableLayer>
  <button onClick={() => handleClick()}>Inside</button>
</DismissableLayer>

// User clicks button - how does DismissableLayer know
// the click happened "inside" before the event bubbles to document?

The Problem: Need to detect if click is inside React component tree, not just DOM tree.

Challenge 3: Portaled Content

<DismissableLayer>
  <DatePicker>
    {/* Calendar portals to document.body */}
    <Portal>
      <Calendar /> {/* Outside modal in DOM! */}
    </Portal>
  </DatePicker>
</DismissableLayer>

// User clicks Calendar - should modal close?
// Answer: NO! Calendar is logically "inside" even though DOM-wise "outside"

The Problem: Need to mark portaled content as "safe" from dismissal.

Challenge 4: Mount Race Conditions

<button onPointerDown={() => setShowModal(true)}>
  Open Modal
</button>

// Timeline:
// t=0ms: User clicks button
// t=1ms: pointerdown event fires
// t=2ms: Button handler runs → Modal mounts
// t=3ms: Modal adds document listener
// t=4ms: Same pointerdown event bubbles to document
// t=5ms: Modal thinks click is "outside" → closes immediately! 😱

The Problem: Modal mounts during the same event that should open it.

+++ Example

‎Gemini - direct access to Google AI

The "Crash" Timeline (Without the Fix)

Imagine the DOM as a vertical tree. An event (like a click) starts at the bottom and bubbles up to the top.

Here is what happens in milliseconds without the fix:

🛑 THE TRAP (How it fails)

      [Document Root]  <-- 5. Event arrives here. 
             |             The NEW listener is already waiting! 
             |             It fires: "Clicked outside? YES. Close Modal."
             |
      [   Body      ]  <-- 4. Event Bubbles Up
             |
             |         <-- 3. React Effect Runs (Synchronously)
             |             Modal Mounts & Adds Listener to [Document Root]
             |
      [   Button    ]  <-- 2. React Handler Runs (onClick)
             |             State changes: setShowModal(true)
             |
      [ Mouse Click ]  <-- 1. User clicks here (Target: Button)

The problem: React renders so fast that the Modal adds its "close on outside click" listener to the Document before the click event (which triggered the modal to open) finishes traveling up to the Document.

The Modal sees the very event that created it as an "outside click."


The "Safe" Timeline (With setTimeout)

This is what the code in DismissableLayerFixed.jsx (lines 112-114) does. It pushes the listener registration to the end of the queue.

✅ THE FIX (How it works)

      [Document Root]  <-- 5. Event arrives here.
             |             Browser checks for listeners...
             |             Found NONE! (Because we waited)
             |             Event dies harmlessly. 👻
             |
      [   Body      ]  <-- 4. Event Bubbles Up
             |
             |         <-- 3. React Effect Runs
             |             Modal Mounts.
             |             We say: "Wait! Don't add listener yet."
             |             (setTimeout, 0)
             |
      [   Button    ]  <-- 2. React Handler Runs
             |             State changes: setShowModal(true)
             |
      [ Mouse Click ]  <-- 1. User clicks here
             |
             .
             .
             .
      [   LATER     ]  <-- 6. Tick Tock (Event Loop finishes)
                           NOW we add the listener to [Document Root].
                           Safe for next click!

The Code Responsible

This specific block in your file DismissableLayerFixed.jsx creates that "Safe" timeline:

// DismissableLayerFixed.jsx

const timerId = window.setTimeout(() => {
  // This runs in Step 6 (LATER), after the event has finished bubbling
  ownerDocument.addEventListener('pointerdown', handlePointerDown);
}, 0);

+++

Challenge 5: Touch Devices

// Touch timeline:
// t=0ms:   User touches screen
// t=50ms:  User lifts finger (touchend)
// t=400ms: click event fires (if no scroll detected)

// Without special handling:
// - Modal closes at t=50ms
// - pointer-events restored immediately
// - click at t=400ms activates button behind modal! 😱

The Problem: 350ms delay between touch and click on mobile.


Core Architecture

Radix's Design Philosophy

Radix uses a mutable state + custom events pattern instead of React state management. Here's why:

Approach A: React State (Naive)

// ❌ This causes performance issues
function Provider() {
  const [layers, setLayers] = useState(new Set());
  
  return (
    <Context.Provider value={{ layers, setLayers }}>
      {children}
    </Context.Provider>
  );
}

// Every layer mount/unmount causes:
// 1. setState call
// 2. Context value changes
// 3. ALL consumers re-render
// 3 modals mounting = 3 full context re-render cycles

Problems:

  • Every layer change triggers re-renders of ALL layers
  • Provider itself re-renders unnecessarily
  • No control over when components update

Approach B: Mutable Sets + Custom Events (Radix)

// ✅ Radix's actual approach
const DismissableLayerContext = React.createContext({
  layers: new Set<DismissableLayerElement>(),
  layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
  branches: new Set<DismissableLayerBranchElement>(),
});

// No Provider! Just uses default value
// Each component:
// 1. Mutates the Set directly
// 2. Dispatches custom event to notify others
// 3. Each layer decides independently when to re-render

Advantages:

  • No Context Provider re-renders
  • Each layer controls its own updates
  • Minimal re-renders (only when needed)
  • Simpler mental model (no state management)

The Force Update Pattern

Since Radix uses mutable Sets, components need a way to re-render when the Set changes:

// Force a re-render by updating dummy state
const [, force] = React.useState({});

// Listen for changes from other layers
React.useEffect(() => {
  const handleUpdate = () => force({}); // 👈 Force re-render
  document.addEventListener(CONTEXT_UPDATE, handleUpdate);
  return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate);
}, []);

// When this layer or any other changes:
function dispatchUpdate() {
  const event = new CustomEvent(CONTEXT_UPDATE);
  document.dispatchEvent(event); // 👈 Broadcast
}

How it works:

  1. Modal A adds itself to context.layers (mutates Set)
  2. Modal A calls dispatchUpdate() (broadcasts event)
  3. All mounted DismissableLayers hear the event
  4. Each calls force({}) to re-render themselves
  5. Each recalculates isHighestLayer with fresh Set contents

The Context Pattern

Context Structure

const DismissableLayerContext = React.createContext({
  layers: new Set<DismissableLayerElement>(),
  layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
  branches: new Set<DismissableLayerBranchElement>(),
});

Three Sets:

  1. layers - All mounted DismissableLayer instances
    • Used for: Layer stacking, Escape key priority
    • Order: Insertion order (first mounted = bottom)
  2. layersWithOutsidePointerEventsDisabled - Layers with disableOutsidePointerEvents={true}
    • Used for: Body pointer-events management
    • Controls which layers can receive pointer events
  3. branches - All mounted Branch instances (portaled content markers)
    • Used for: Outside click detection
    • Prevents dismissal when clicking portaled content

No Provider Needed!

// ❌ You might expect this:
<DismissableLayerProvider>
  <App />
</DismissableLayerProvider>

// ✅ But Radix just uses default context value:
<App /> {/* Works immediately! */}

Why no Provider?

The default context value contains empty Sets that are shared across all consumers. Since JavaScript objects are passed by reference:

// All components get THE SAME Set instances
const context1 = useContext(DismissableLayerContext);
const context2 = useContext(DismissableLayerContext);

context1.layers === context2.layers // true! Same Set

When one component mutates the Set, all components see the change (because thㄎey share the same Set instance).

+++ Why?

The DismissableLayer in Radix UI does not require an explicit Provider because its DismissableLayerContext is initialized with default values that are mutable objects (Set instances). Since JavaScript objects are passed by reference, all consumers of this context will receive references to the same Set instances. When any component modifies these Set instances (e.g., adding or removing a layer), all other components consuming the context will see these changes immediately because they are referencing the same underlying data structures. This behavior is shown in the dismissable-layer.tsx file, where DismissableLayerContext is initialized with:

const DismissableLayerContext = React.createContext({
layers: new Set<DismissableLayerElement>(),
layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
branches: new Set<DismissableLayerBranchElement>(),
});

This approach allows the DismissableLayer system to manage its global state (like active layers and branches) without needing a top-level Provider component to explicitly pass down values. The system relies on direct mutation of these shared Set objects, and then dispatches an update event (CONTEXT_UPDATE) to force consumers to re-render, as seen in the useEffect hooks within DismissableLayer and the dispatchUpdate function in dismissable-layer.tsx.

+++


+++ ## Hooks Deep Dive

+++ ### Hook 1: useEscapeKeydown

Purpose: Listen for Escape key and call handler.

function useEscapeKeydown(
  onEscapeKeyDown?: (event: KeyboardEvent) => void,
  ownerDocument: Document = globalThis?.document
) {
  const callbackRef = React.useRef(onEscapeKeyDown);
  
  // Keep ref in sync with latest callback
  React.useEffect(() => {
    callbackRef.current = onEscapeKeyDown;
  }, [onEscapeKeyDown]);

  React.useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && callbackRef.current) {
        callbackRef.current(event);
      }
    };

    ownerDocument.addEventListener('keydown', handleKeyDown);
    return () => ownerDocument.removeEventListener('keydown', handleKeyDown);
  }, [ownerDocument]);
}

Design Decisions:

Q: Why keydown instead of keyup?

  • keydown fires immediately when key is pressed (instant feedback)
  • keyup fires when released (feels laggy)
  • OS dialogs use keydown behavior

Q: Why useRef for the callback?

// Problem: Callback changes frequently
<DismissableLayer onEscapeKeyDown={() => handleClose()} />

// Without ref: Effect re-runs every render
useEffect(() => {
  document.addEventListener('keydown', callback); // New callback each render!
  return () => document.removeEventListener('keydown', callback);
}, [callback]); // Re-runs constantly!

// With ref: Effect runs once, always uses latest callback
const callbackRef = useRef(callback);
useEffect(() => { callbackRef.current = callback; }, [callback]);

useEffect(() => {
  const handler = (e) => callbackRef.current?.(e); // Always latest!
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
}, []); // Runs once!

Q: Why ownerDocument parameter?

  • Supports iframes (each has its own document)
  • SSR safety (document might not exist)
  • Testability (can pass mock document)

+++

+++ ### Hook 2: usePointerDownOutside

‎Google Gemini

‎Gemini - direct access to Google AI

Purpose: Detect clicks outside React component tree.

function usePointerDownOutside(
  onPointerDownOutside?: (event: PointerDownOutsideEvent) => void,
  ownerDocument: Document = globalThis?.document
) {
  const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener;
  const isPointerInsideReactTreeRef = React.useRef(false);
  const handleClickRef = React.useRef(() => {});

  React.useEffect(() => {
    const handlePointerDown = (event: PointerEvent) => {
      if (event.target && !isPointerInsideReactTreeRef.current) {
        const eventDetail = { originalEvent: event };

        function handleAndDispatchPointerDownOutsideEvent() {
          handleAndDispatchCustomEvent(
            POINTER_DOWN_OUTSIDE,
            handlePointerDownOutside,
            eventDetail,
            { discrete: true }
          );
        }

        // Special handling for touch devices
        if (event.pointerType === 'touch') {
          ownerDocument.removeEventListener('click', handleClickRef.current);
          handleClickRef.current = handleAndDispatchPointerDownOutsideEvent;
          ownerDocument.addEventListener('click', handleClickRef.current, { once: true });
        } else {
          handleAndDispatchPointerDownOutsideEvent();
        }
      } else {
        // Click was inside, cleanup pending touch handlers
        ownerDocument.removeEventListener('click', handleClickRef.current);
      }
      
      isPointerInsideReactTreeRef.current = false;
    };

    // Delay registration to avoid mount race condition
    const timerId = window.setTimeout(() => {
      ownerDocument.addEventListener('pointerdown', handlePointerDown);
    }, 0);

    return () => {
      window.clearTimeout(timerId);
      ownerDocument.removeEventListener('pointerdown', handlePointerDown);
      ownerDocument.removeEventListener('click', handleClickRef.current);
    };
  }, [ownerDocument, handlePointerDownOutside]);

  return {
    // This sets the flag during React's capture phase
    onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true),
  };
}

The Core Trick: Capture Phase Detection

// Event flow:
// 1. User clicks <button> inside modal
// 2. CAPTURE PHASE: onPointerDownCapture fires → flag = true
// 3. BUBBLE PHASE: document listener fires → checks flag
// 4. Flag is true → click was inside → don't dismiss

// If user clicks outside:
// 1. User clicks <div> outside modal
// 2. No capture handler fires → flag stays false
// 3. Document listener fires → flag is false → dismiss!

<div 
  onPointerDownCapture={() => isPointerInsideReactTreeRef.current = true}
>
  {/* Anything clicked here sets flag to true */}
</div>

Why setTimeout(register, 0)?

Prevents mount race condition:

// Without delay:
<button onPointerDown={() => setShowModal(true)}>Open</button>

// Timeline:
// t=0: Click button
// t=1: pointerdown fires
// t=2: Button handler runs → Modal mounts
// t=3: Modal registers document listener immediately
// t=4: Same pointerdown event bubbles to document
// t=5: Modal listener fires → sees click "outside" → closes! 😱

// With setTimeout(register, 0):
// t=0: Click button
// t=1: pointerdown fires
// t=2: Button handler runs → Modal mounts
// t=3: Modal schedules registration for next tick
// t=4: Same pointerdown bubbles to document
// t=5: Modal listener NOT registered yet → safe!
// t=6: Next tick → Modal registers listener ✅

Touch Device Handling

// Touch timeline:
// t=0ms:   touchstart
// t=50ms:  touchend
// t=400ms: click (if no scroll detected)

if (event.pointerType === 'touch') {
  // Wait for click event to ensure no scroll happened
  ownerDocument.addEventListener('click', handler, { once: true });
} else {
  // Mouse/pen - dismiss immediately
  handler();
}

Why wait for click on touch?

  1. Touch might become a scroll → click never fires → no dismissal ✅
  2. Ensures pointer-events stays disabled for full 350ms delay
  3. Prevents accidental activation of buttons behind modal

+++

+++ ### Hook 3: useFocusOutside

FE-28

‎Gemini - direct access to Google AI

Purpose: Detect when focus moves outside React component tree.

function useFocusOutside(
  onFocusOutside?: (event: FocusOutsideEvent) => void,
  ownerDocument: Document = globalThis?.document
) {
  const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener;
  const isFocusInsideReactTreeRef = React.useRef(false);

  React.useEffect(() => {
    const handleFocus = (event: FocusEvent) => {
      if (event.target && !isFocusInsideReactTreeRef.current) {
        const eventDetail = { originalEvent: event };
        handleAndDispatchCustomEvent(FOCUS_OUTSIDE, handleFocusOutside, eventDetail, {
          discrete: false,
        });
      }
    };
    
    // Use 'focusin' because 'focus' doesn't bubble!
    ownerDocument.addEventListener('focusin', handleFocus);
    return () => ownerDocument.removeEventListener('focusin', handleFocus);
  }, [ownerDocument, handleFocusOutside]);

  return {
    onFocusCapture: () => (isFocusInsideReactTreeRef.current = true),
    onBlurCapture: () => (isFocusInsideReactTreeRef.current = false),
  };
}

Why focusin instead of focus?

// ❌ 'focus' doesn't bubble!
document.addEventListener('focus', handler); // NEVER FIRES!

// ✅ 'focusin' bubbles
document.addEventListener('focusin', handler); // Works!
EventBubbles?Use Case
focus❌ NoElement-specific listeners
focusin✅ YesDocument-level listeners
blur❌ NoElement-specific listeners
focusout✅ YesDocument-level listeners

Why both onFocusCapture AND onBlurCapture?

// Scenario: Focus moves from inside → outside
<div
  onFocusCapture={() => flag = true}   // Set when entering
  onBlurCapture={() => flag = false}   // Clear when leaving
>
  <button>A</button>
  <button>B</button>
</div>
<button>C</button>

// User tabs: A → B → C

// Focus on A:
// - onFocusCapture fires → flag = true
// - focusin on document → flag is true → inside ✅

// Focus on B (still inside):
// - A blurs, B focuses
// - But onBlurCapture doesn't fire (focus still in tree)
// - flag stays true ✅

// Focus on C (outside):
// - B loses focus
// - onBlurCapture fires → flag = false
// - C gains focus
// - focusin on document → flag is false → OUTSIDE! 🎯

No setTimeout needed

Unlike usePointerDownOutside, no race condition:

// Can't focus an element in same event that creates it
<button onClick={() => setShowModal(true)}>Open</button>

// Timeline:
// t=0: Click button
// t=1: Modal mounts
// t=2: Button still has focus (can't instantly focus modal)
// ✅ No race condition!

+++

+++

+++ ## Main Component Implementation

Complete DismissableLayer Component

const DismissableLayer = React.forwardRef<DismissableLayerElement, DismissableLayerProps>(
  (props, forwardedRef) => {
    const {
      disableOutsidePointerEvents = false,
      onEscapeKeyDown,
      onPointerDownOutside,
      onFocusOutside,
      onInteractOutside,
      onDismiss,
      ...layerProps
    } = props;
    
    const context = React.useContext(DismissableLayerContext);
    const [node, setNode] = React.useState<DismissableLayerElement | null>(null);
    const ownerDocument = node?.ownerDocument ?? globalThis?.document;
    const [, force] = React.useState({});
    const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node));
    
    // Calculate layer positions
    const layers = Array.from(context.layers);
    const [highestLayerWithOutsidePointerEventsDisabled] = 
      [...context.layersWithOutsidePointerEventsDisabled].slice(-1);
    const highestLayerWithOutsidePointerEventsDisabledIndex = 
      layers.indexOf(highestLayerWithOutsidePointerEventsDisabled!);
    const index = node ? layers.indexOf(node) : -1;
    const isBodyPointerEventsDisabled = context.layersWithOutsidePointerEventsDisabled.size > 0;
    const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex;

    // Hook: Pointer down outside (with branch checking)
    const pointerDownOutside = usePointerDownOutside((event) => {
      const target = event.target as HTMLElement;
      const isPointerDownOnBranch = [...context.branches].some((branch) => 
        branch.contains(target)
      );
      if (!isPointerEventsEnabled || isPointerDownOnBranch) return;
      
      onPointerDownOutside?.(event);
      onInteractOutside?.(event);
      if (!event.defaultPrevented) onDismiss?.();
    }, ownerDocument);

    // Hook: Focus outside (with branch checking)
    const focusOutside = useFocusOutside((event) => {
      const target = event.target as HTMLElement;
      const isFocusInBranch = [...context.branches].some((branch) => 
        branch.contains(target)
      );
      if (isFocusInBranch) return;
      
      onFocusOutside?.(event);
      onInteractOutside?.(event);
      if (!event.defaultPrevented) onDismiss?.();
    }, ownerDocument);

    // Hook: Escape key (only highest layer responds)
    useEscapeKeydown((event) => {
      const isHighestLayer = index === context.layers.size - 1;
      if (!isHighestLayer) return;
      
      onEscapeKeyDown?.(event);
      if (!event.defaultPrevented && onDismiss) {
        event.preventDefault();
        onDismiss();
      }
    }, ownerDocument);

    // Effect: Register layer and manage pointer events
    React.useEffect(() => {
      if (!node) return;
      
      if (disableOutsidePointerEvents) {
        if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
          originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
          ownerDocument.body.style.pointerEvents = 'none';
        }
        context.layersWithOutsidePointerEventsDisabled.add(node);
      }
      
      context.layers.add(node);
      dispatchUpdate();
      
      return () => {
        if (
          disableOutsidePointerEvents &&
          context.layersWithOutsidePointerEventsDisabled.size === 1
        ) {
          ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
        }
      };
    }, [node, ownerDocument, disableOutsidePointerEvents, context]);

    // Effect: Cleanup on unmount (separate to preserve creation order)
    React.useEffect(() => {
      return () => {
        if (!node) return;
        context.layers.delete(node);
        context.layersWithOutsidePointerEventsDisabled.delete(node);
        dispatchUpdate();
      };
    }, [node, context]);

    // Effect: Listen for context updates
    React.useEffect(() => {
      const handleUpdate = () => force({});
      document.addEventListener(CONTEXT_UPDATE, handleUpdate);
      return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate);
    }, []);

    return (
      <Primitive.div
        {...layerProps}
        ref={composedRefs}
        style={{
          pointerEvents: isBodyPointerEventsDisabled
            ? isPointerEventsEnabled
              ? 'auto'
              : 'none'
            : undefined,
          ...props.style,
        }}
        onFocusCapture={composeEventHandlers(props.onFocusCapture, focusOutside.onFocusCapture)}
        onBlurCapture={composeEventHandlers(props.onBlurCapture, focusOutside.onBlurCapture)}
        onPointerDownCapture={composeEventHandlers(
          props.onPointerDownCapture,
          pointerDownOutside.onPointerDownCapture
        )}
      />
    );
  }
);

Key Implementation Details

+++ #### 1. Layer Stack Calculation

const layers = Array.from(context.layers);
const index = node ? layers.indexOf(node) : -1;
const isHighestLayer = index === context.layers.size - 1;

// Example with 3 modals:
// layers = [Modal1, Modal2, Modal3]
// Modal1: index=0, size=3, isHighestLayer = 0 === 2 → false
// Modal2: index=1, size=3, isHighestLayer = 1 === 2 → false
// Modal3: index=2, size=3, isHighestLayer = 2 === 2 → true ✅

+++

+++ #### 2. Pointer Events Management

const isBodyPointerEventsDisabled = context.layersWithOutsidePointerEventsDisabled.size > 0;
const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex;

// Logic:
// If ANY layer wants disableOutsidePointerEvents:
//   - Set body.style.pointerEvents = 'none'
//   - Find highest layer with this prop
//   - Layers >= that index get pointer-events: auto
//   - Layers < that index get pointer-events: none

Example:

<Layer1 /> {/* index=0, no special prop */}
<Layer2 disableOutsidePointerEvents /> {/* index=1 */}
<Layer3 /> {/* index=2, no special prop */}

// highestLayerWithOutsidePointerEventsDisabledIndex = 1
// Layer1: index=0 < 1 → pointer-events: none (disabled)
// Layer2: index=1 >= 1 → pointer-events: auto (enabled)
// Layer3: index=2 >= 1 → pointer-events: auto (enabled)

+++

+++ #### 3. Two Separate Effects

‎Gemini - direct access to Google AI

Why two effects instead of one?

// Effect 1: Register + manage pointer events
React.useEffect(() => {
  context.layers.add(node);
  if (disableOutsidePointerEvents) {
    context.layersWithOutsidePointerEventsDisabled.add(node);
  }
  // ...
}, [node, disableOutsidePointerEvents]); // 👈 Runs when prop changes

// Effect 2: Cleanup
React.useEffect(() => {
  return () => {
    context.layers.delete(node);
    context.layersWithOutsidePointerEventsDisabled.delete(node);
  };
}, [node]); // 👈 Only runs on unmount

The problem with combining them:

// ❌ Bad: Combined effect
React.useEffect(() => {
  context.layers.add(node);
  if (disableOutsidePointerEvents) {
    context.layersWithOutsidePointerEventsDisabled.add(node);
  }
  return () => {
    context.layers.delete(node);
    context.layersWithOutsidePointerEventsDisabled.delete(node);
  };
}, [node, disableOutsidePointerEvents]);

// If disableOutsidePointerEvents changes:
// 1. Cleanup runs → removes from layers Set
// 2. Setup runs → adds back to layers Set
// Result: Layer moves to END of Set (wrong position in stack!)

With separate effects:

// Effect 1 runs when prop changes:
// - Updates pointer events membership
// - Doesn't touch layers Set

// Effect 2 ONLY runs on unmount:
// - Preserves layer order in Set
// - Only removes when component unmounts

+++ Example

This is a fantastic question. It touches on a subtle behavior of React useEffect combined with how JavaScript Set (or Arrays) preserves order.

Here is the core concept: The DismissableLayer system relies on the ORDER of the layers Set to know which modal is the "top" one.

If you remove a layer and add it back, it moves to the end of the line.

The Visual Scenario

Imagine you have two modals open:

  1. Bottom Modal (A): A Settings form.
  2. Top Modal (B): A "Are you sure?" confirmation dialog on top of A.

Correct Stack Order: [Layer A, Layer B]

Result: Pressing Escape closes Layer B (the last one).

Let's simulate what happens when Layer A updates (e.g., you toggle a switch inside the Settings modal), causing disableOutsidePointerEvents to change.

1. The "Combined" Effect (BUGGY ❌)

This effect manages the stack and the prop in one place.

// Inside Layer A
useEffect(() => {
  // 1. SETUP
  console.log('Adding Layer A');
  globalStack.add('Layer A');

  // 2. PROP LOGIC
  if (props.disablePointer) { /* do something */ }

  // 3. CLEANUP
  return () => {
    console.log('Removing Layer A');
    globalStack.delete('Layer A');
  };
}, [props.disablePointer]); // 👈 Re-runs when prop changes!

The Timeline of the Bug:

  1. Mount A: Stack is ['Layer A'].
  2. Mount B: Stack is ['Layer A', 'Layer B']. (B is on top).
  3. Update A: You toggle a setting in Layer A. props.disablePointer changes.
    • React runs Cleanup for A: Removes A. Stack is ['Layer B'].
    • React runs Setup for A: Adds A. Stack is ['Layer B', 'Layer A'].

The Result:

The stack is now ['Layer B', 'Layer A']. The system thinks Layer A is on top!

The Crash: You press Escape. It closes the bottom modal (A), leaving the top modal (B) floating in the air. 😱

2. The "Separate" Effects (FIXED ✅)

We split the logic. One effect manages existence (the stack). The other manages behavior (the prop).

// Effect 1: Registration (Only runs ONCE)
useEffect(() => {
  console.log('Adding Layer A');
  globalStack.add('Layer A');

  return () => {
    console.log('Removing Layer A');
    globalStack.delete('Layer A');
  };
}, []); // 👈 Empty dependency array. NEVER re-runs while mounted.


// Effect 2: Prop Logic (Runs whenever needed)
useEffect(() => {
  if (props.disablePointer) {
    // Just handle the pointer events style.
    // DO NOT touch the globalStack here.
    document.body.style.pointerEvents = 'none';
  }
}, [props.disablePointer]); // 👈 Can run as much as it wants

The Timeline of the Fix:

  1. Mount A: Effect 1 runs. Stack is ['Layer A'].
  2. Mount B: Effect 1 runs. Stack is ['Layer A', 'Layer B'].
  3. Update A: You toggle a setting in Layer A.
    • Effect 1: Does nothing. (Dependencies didn't change).
    • Effect 2: Re-runs to handle the pointer styles.

The Result:

The stack remains ['Layer A', 'Layer B'].

The Success: You press Escape. It closes Layer B (the top one).

Summary

By separating the effects, we ensure that Layer A maintains its "seniority" (its position at index 0) in the stack, even if its internal props change.

+++


+++

+++

+++ ## Branch Component

Purpose: Mark portaled content as "logically inside" the modal.

const DismissableLayerBranch = React.forwardRef
  DismissableLayerBranchElement,
  DismissableLayerBranchProps
>((props, forwardedRef) => {
  const context = React.useContext(DismissableLayerContext);
  const ref = React.useRef<DismissableLayerBranchElement>(null);
  const composedRefs = useComposedRefs(forwardedRef, ref);

  React.useEffect(() => {
    const node = ref.current;
    if (node) {
      context.branches.add(node);
      return () => {
        context.branches.delete(node);
      };
    }
  }, [context.branches]);

  return <Primitive.div {...props} ref={composedRefs} />;
});

How Branch Works

// User's code:
<DismissableLayer onDismiss={() => console.log('closed')}>
  <h1>Modal</h1>
  <DatePicker />
</DismissableLayer>

// DatePicker internally portals:
function DatePicker() {
  return (
    <>
      <input />
      <Portal>
        <DismissableLayer.Branch>
          <Calendar /> {/* Outside modal in DOM */}
        </DismissableLayer.Branch>
      </Portal>
    </>
  );
}

DOM Structure:

<body>
  <div id="root">
    <DismissableLayer>
      <h1>Modal</h1>
      <input />
    </DismissableLayer>
  </div>
  
  <!-- Portaled! -->
  <DismissableLayer.Branch>
    <Calendar />
  </DismissableLayer.Branch>
</body>

Detection Logic:

const pointerDownOutside = usePointerDownOutside((event) => {
  const target = event.target; // Where user clicked
  
  // Check: Is target inside any Branch?
  const isPointerDownOnBranch = [...context.branches].some((branch) => 
    branch.contains(target) // DOM .contains() check
  );
  
  if (isPointerDownOnBranch) return; // Don't dismiss!
  
  // Not in a branch → dismiss
  onDismiss?.();
}, ownerDocument);

The Magic:

// User clicks Calendar
const target = calendarElement;

// Loop through all branches
for (const branch of context.branches) {
  if (branch.contains(target)) {
    // branch is the <DismissableLayer.Branch> div
    // target is the <Calendar> element (inside the branch)
    // branch.contains(target) returns true! ✅
    // Don't dismiss!
    return;
  }
}

+++

+++ ## Performance Considerations

Why Mutable Sets?

Benchmark: 10 nested modals mounting

// Approach A: React State (immutable)
// Each mount: setState → Context changes → All consumers re-render
// Modal 1: 1 re-render
// Modal 2: 2 re-renders (Modal 1 re-renders again)
// Modal 3: 3 re-renders (Modal 1 & 2 re-render again)
// ...
// Total: 1+2+3+4+5+6+7+8+9+10 = 55 re-renders

// Approach B: Mutable Sets + Custom Events (Radix)
// Each mount: Mutate Set → Dispatch event → Only that modal renders
// Modal 1: 1 re-render
// Modal 2: 1 re-render
// Modal 3: 1 re-render
// ...
// Total: 10 re-renders

// Performance: 5.5x fewer re-renders!

Force Update Cost

Is forcing updates expensive?

const [, force] = React.useState({});

// force({}) does:
// 1. Creates new object: {} (cheap)
// 2. Triggers re-render of this component only
// 3. No Context Provider re-renders
// 4. No sibling component re-renders

// Cost: ~same as normal setState
// Benefit: Precise control over when to update

Custom Event Overhead

function dispatchUpdate() {
  const event = new CustomEvent(CONTEXT_UPDATE);
  document.dispatchEvent(event);
}

// Cost: ~0.1ms (negligible)
// Benefit: Decoupled communication between layers

Trade-offs

AspectReact StateMutable Sets + Events
Re-rendersAll consumersOnly listeners
Provider overheadRe-renders on every changeNo provider needed
Mental modelStandard ReactRequires understanding custom events
DebuggingReact DevToolsNeed custom logging
Type safetyFull TypeScript supportSame

Radix's choice: Performance > React conventions


+++

Complete Implementation

Full Production Code

import * as React from 'react';
import { composeEventHandlers } from '@radix-ui/primitive';
import { Primitive, dispatchDiscreteCustomEvent } from '@radix-ui/react-primitive';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown';

/* -------------------------------------------------------------------------------------------------
 * DismissableLayer
 * -----------------------------------------------------------------------------------------------*/

const DISMISSABLE_LAYER_NAME = 'DismissableLayer';
const CONTEXT_UPDATE = 'dismissableLayer.update';
const POINTER_DOWN_OUTSIDE = 'dismissableLayer.pointerDownOutside';
const FOCUS_OUTSIDE = 'dismissableLayer.focusOutside';

let originalBodyPointerEvents: string;

const DismissableLayerContext = React.createContext({
  layers: new Set<DismissableLayerElement>(),
  layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
  branches: new Set<DismissableLayerBranchElement>(),
});

type DismissableLayerElement = React.ComponentRef<typeof Primitive.div>;
type PrimitiveDivProps = React.ComponentPropsWithoutRef<typeof Primitive.div>;

interface DismissableLayerProps extends PrimitiveDivProps {
  disableOutsidePointerEvents?: boolean;
  onEscapeKeyDown?: (event: KeyboardEvent) => void;
  onPointerDownOutside?: (event: PointerDownOutsideEvent) => void;
  onFocusOutside?: (event: FocusOutsideEvent) => void;
  onInteractOutside?: (event: PointerDownOutsideEvent | FocusOutsideEvent) => void;
  onDismiss?: () => void;
}

const DismissableLayer = React.forwardRef<DismissableLayerElement, DismissableLayerProps>(
  (props, forwardedRef) => {
    const {
      disableOutsidePointerEvents = false,
      onEscapeKeyDown,
      onPointerDownOutside,
      onFocusOutside,
      onInteractOutside,
      onDismiss,
      ...layerProps
    } = props;
    
    const context = React.useContext(DismissableLayerContext);
    const [node, setNode] = React.useState<DismissableLayerElement | null>(null);
    const ownerDocument = node?.ownerDocument ?? globalThis?.document;
    const [, force] = React.useState({});
    const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node));
    
    const layers = Array.from(context.layers);
    const [highestLayerWithOutsidePointerEventsDisabled] = 
      [...context.layersWithOutsidePointerEventsDisabled].slice(-1);
    const highestLayerWithOutsidePointerEventsDisabledIndex = 
      layers.indexOf(highestLayerWithOutsidePointerEventsDisabled!);
    const index = node ? layers.indexOf(node) : -1;
    const isBodyPointerEventsDisabled = context.layersWithOutsidePointerEventsDisabled.size > 0;
    const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex;

    const pointerDownOutside = usePointerDownOutside((event) => {
      const target = event.target as HTMLElement;
      const isPointerDownOnBranch = [...context.branches].some((branch) => 
        branch.contains(target)
      );
      if (!isPointerEventsEnabled || isPointerDownOnBranch) return;
      
      onPointerDownOutside?.(event);
      onInteractOutside?.(event);
      if (!event.defaultPrevented) onDismiss?.();
    }, ownerDocument);

    const focusOutside = useFocusOutside((event) => {
      const target = event.target as HTMLElement;
      const isFocusInBranch = [...context.branches].some((branch) => 
        branch.contains(target)
      );
      if (isFocusInBranch) return;
      
      onFocusOutside?.(event);
      onInteractOutside?.(event);
      if (!event.defaultPrevented) onDismiss?.();
    }, ownerDocument);

    useEscapeKeydown((event) => {
      const isHighestLayer = index === context.layers.size - 1;
      if (!isHighestLayer) return;
      
      onEscapeKeyDown?.(event);
      if (!event.defaultPrevented && onDismiss) {
        event.preventDefault();
        onDismiss();
      }
    }, ownerDocument);

    React.useEffect(() => {
      if (!node) return;
      
      if (disableOutsidePointerEvents) {
        if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
          originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
          ownerDocument.body.style.pointerEvents = 'none';
        }
        context.layersWithOutsidePointerEventsDisabled.add(node);
      }
      
      context.layers.add(node);
      dispatchUpdate();
      
      return () => {
        if (
          disableOutsidePointerEvents &&
          context.layersWithOutsidePointerEventsDisabled.size === 1
        ) {
          ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
        }
      };
    }, [node, ownerDocument, disableOutsidePointerEvents, context]);

    React.useEffect(() => {
      return () => {
        if (!node) return;
        context.layers.delete(node);
        context.layersWithOutsidePointerEventsDisabled.delete(node);
        dispatchUpdate();
      };
    }, [node, context]);

    React.useEffect(() => {
      const handleUpdate = () => force({});
      document.addEventListener(CONTEXT_UPDATE, handleUpdate);
      return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate);
    }, []);

    return (
      <Primitive.div
        {...layerProps}
        ref={composedRefs}
        style={{
          pointerEvents: isBodyPointerEventsDisabled
            ? isPointerEventsEnabled
              ? 'auto'
              : 'none'
            : undefined,
          ...props.style,
        }}
        onFocusCapture={composeEventHandlers(props.onFocusCapture, focusOutside.onFocusCapture)}
        onBlurCapture={composeEventHandlers(props.onBlurCapture, focusOutside.onBlurCapture)}
        onPointerDownCapture={composeEventHandlers(
          props.onPointerDownCapture,
          pointerDownOutside.onPointerDownCapture
        )}
      />
    );
  }
);

DismissableLayer.displayName = DISMISSABLE_LAYER_NAME;

/* -------------------------------------------------------------------------------------------------
 * DismissableLayerBranch
 * -----------------------------------------------------------------------------------------------*/

const BRANCH_NAME = 'DismissableLayerBranch';

type DismissableLayerBranchElement = React.ComponentRef<typeof Primitive.div>;
interface DismissableLayerBranchProps extends PrimitiveDivProps {}

const DismissableLayerBranch = React.forwardRef
  DismissableLayerBranchElement,
  DismissableLayerBranchProps
>((props, forwardedRef) => {
  const context = React.useContext(DismissableLayerContext);
  const ref = React.useRef<DismissableLayerBranchElement>(null);
  const composedRefs = useComposedRefs(forwardedRef, ref);

  React.useEffect(() => {
    const node = ref.current;
    if (node) {
      context.branches.add(node);
      return () => {
        context.branches.delete(node);
      };
    }
  }, [context.branches]);

  return <Primitive.div {...props} ref={composedRefs} />;
});

DismissableLayerBranch.displayName = BRANCH_NAME;

/* -----------------------------------------------------------------------------------------------*/

type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>;
type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>;

function usePointerDownOutside(
  onPointerDownOutside?: (event: PointerDownOutsideEvent) => void,
  ownerDocument: Document = globalThis?.document
) {
  const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener;
  const isPointerInsideReactTreeRef = React.useRef(false);
  const handleClickRef = React.useRef(() => {});

  React.useEffect(() => {
    const handlePointerDown = (event: PointerEvent) => {
      if (event.target && !isPointerInsideReactTreeRef.current) {
        const eventDetail = { originalEvent: event };

        function handleAndDispatchPointerDownOutsideEvent() {
          handleAndDispatchCustomEvent(
            POINTER_DOWN_OUTSIDE,
            handlePointerDownOutside,
            eventDetail,
            { discrete: true }
          );
        }

        if (event.pointerType === 'touch') {
          ownerDocument.removeEventListener('click', handleClickRef.current);
          handleClickRef.current = handleAndDispatchPointerDownOutsideEvent;
          ownerDocument.addEventListener('click', handleClickRef.current, { once: true });
        } else {
          handleAndDispatchPointerDownOutsideEvent();
        }
      } else {
        ownerDocument.removeEventListener('click', handleClickRef.current);
      }
      
      isPointerInsideReactTreeRef.current = false;
    };

    const timerId = window.setTimeout(() => {
      ownerDocument.addEventListener('pointerdown', handlePointerDown);
    }, 0);

    return () => {
      window.clearTimeout(timerId);
      ownerDocument.removeEventListener('pointerdown', handlePointerDown);
      ownerDocument.removeEventListener('click', handleClickRef.current);
    };
  }, [ownerDocument, handlePointerDownOutside]);

  return {
    onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true),
  };
}

function useFocusOutside(
  onFocusOutside?: (event: FocusOutsideEvent) => void,
  ownerDocument: Document = globalThis?.document
) {
  const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener;
  const isFocusInsideReactTreeRef = React.useRef(false);

  React.useEffect(() => {
    const handleFocus = (event: FocusEvent) => {
      if (event.target && !isFocusInsideReactTreeRef.current) {
        const eventDetail = { originalEvent: event };
        handleAndDispatchCustomEvent(FOCUS_OUTSIDE, handleFocusOutside, eventDetail, {
          discrete: false,
        });
      }
    };
    
    ownerDocument.addEventListener('focusin', handleFocus);
    return () => ownerDocument.removeEventListener('focusin', handleFocus);
  }, [ownerDocument, handleFocusOutside]);

  return {
    onFocusCapture: () => (isFocusInsideReactTreeRef.current = true),
    onBlurCapture: () => (isFocusInsideReactTreeRef.current = false),
  };
}

function dispatchUpdate() {
  const event = new CustomEvent(CONTEXT_UPDATE);
  document.dispatchEvent(event);
}

function handleAndDispatchCustomEvent<E extends CustomEvent, OriginalEvent extends Event>(
  name: string,
  handler: ((event: E) => void) | undefined,
  detail: { originalEvent: OriginalEvent } & (E extends CustomEvent<infer D> ? D : never),
  { discrete }: { discrete: boolean }
) {
  const target = detail.originalEvent.target;
  const event = new CustomEvent(name, { bubbles: false, cancelable: true, detail });
  
  if (handler) {
    target.addEventListener(name, handler as EventListener, { once: true });
  }

  if (discrete) {
    dispatchDiscreteCustomEvent(target, event);
  } else {
    target.dispatchEvent(event);
  }
}

const Root = DismissableLayer;
const Branch = DismissableLayerBranch;

export {
  DismissableLayer,
  DismissableLayerBranch,
  Root,
  Branch,
};
export type { DismissableLayerProps };

+++ ## Usage Examples

Example 1: Basic Modal

function BasicModal() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open Modal</button>

      {open && (
        <DismissableLayer
          onDismiss={() => setOpen(false)}
          style={{
            position: 'fixed',
            inset: 0,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: 'rgba(0, 0, 0, 0.5)',
          }}
        >
          <div
            style={{
              background: 'white',
              borderRadius: 8,
              padding: 24,
              minWidth: 300,
            }}
            onClick={(e) => e.stopPropagation()}
          >
            <h2>Modal Title</h2>
            <p>Press Escape, click outside, or tab outside to dismiss</p>
            <button onClick={() => setOpen(false)}>Close</button>
          </div>
        </DismissableLayer>
      )}
    </>
  );
}

Example 2: Nested Modals

function NestedModals() {
  const [modal1, setModal1] = useState(false);
  const [modal2, setModal2] = useState(false);

  return (
    <>
      <button onClick={() => setModal1(true)}>Open Modal 1</button>

      {modal1 && (
        <DismissableLayer
          onDismiss={() => setModal1(false)}
          style={{
            position: 'fixed',
            top: 100,
            left: 100,
            background: 'lightblue',
            padding: 20,
            border: '2px solid blue',
          }}
        >
          <h3>Modal 1</h3>
          <button onClick={() => setModal2(true)}>Open Modal 2</button>
          <button onClick={() => setModal1(false)}>Close</button>

          {modal2 && (
            <DismissableLayer
              onDismiss={() => setModal2(false)}
              style={{
                position: 'fixed',
                top: 200,
                left: 200,
                background: 'lightgreen',
                padding: 20,
                border: '2px solid green',
              }}
            >
              <h3>Modal 2</h3>
              <p>Press Escape - only this closes!</p>
              <button onClick={() => setModal2(false)}>Close</button>
            </DismissableLayer>
          )}
        </DismissableLayer>
      )}
    </>
  );
}

Example 3: Modal with Portaled Dropdown

import { createPortal } from 'react-dom';

function ModalWithDropdown() {
  const [modal, setModal] = useState(false);
  const [dropdown, setDropdown] = useState(false);

  return (
    <>
      <button onClick={() => setModal(true)}>Open Modal</button>

      {modal && (
        <DismissableLayer
          onDismiss={() => setModal(false)}
          style={{
            position: 'fixed',
            inset: 0,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: 'rgba(0, 0, 0, 0.5)',
          }}
        >
          <div
            style={{ background: 'white', padding: 24 }}
            onClick={(e) => e.stopPropagation()}
          >
            <h2>Modal with Dropdown</h2>
            <button onClick={() => setDropdown(!dropdown)}>
              Toggle Dropdown
            </button>
          </div>
        </DismissableLayer>
      )}

      {/* Dropdown portaled to body */}
      {dropdown &&
        createPortal(
          <DismissableLayer.Branch>
            <div
              style={{
                position: 'fixed',
                top: 200,
                left: 400,
                background: 'yellow',
                padding: 10,
                border: '1px solid orange',
              }}
            >
              <p>I'm portaled! Click me - modal stays open! ✅</p>
              <button onClick={() => setDropdown(false)}>Close</button>
            </div>
          </DismissableLayer.Branch>,
          document.body
        )}
    </>
  );
}

Example 4: Dangerous Action Modal

function DangerousActionModal() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => alert('DATA DELETED!')}>
        🗑️ Delete All Data
      </button>
      <button onClick={() => setOpen(true)}>Show Confirmation</button>

      {open && (
        <DismissableLayer
          disableOutsidePointerEvents={true} // 👈 Two-click protection
          onDismiss={() => setOpen(false)}
          style={{
            position: 'fixed',
            inset: 0,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: 'rgba(0, 0, 0, 0.8)',
          }}
        >
          <div
            style={{
              background: 'white',
              padding: 32,
              borderRadius: 8,
              maxWidth: 400,
            }}
            onClick={(e) => e.stopPropagation()}
          >
            <h2>⚠️ Warning</h2>
            <p>
              This will delete ALL your data. This action cannot be undone.
            </p>
            <div style={{ display: 'flex', gap: 10, marginTop: 20 }}>
              <button onClick={() => setOpen(false)}>Cancel</button>
              <button
                onClick={() => {
                  alert('Confirmed deletion!');
                  setOpen(false);
                }}
                style={{ background: 'red', color: 'white' }}
              >
                Yes, Delete Everything
              </button>
            </div>
          </div>
        </DismissableLayer>
      )}
    </>
  );
}

Try it:

  1. Open modal
  2. Click "Delete All Data" button → Modal closes, nothing happens
  3. Click "Delete All Data" again → Alert fires ✅

+++

Key Takeaways

When to Use DismissableLayer

Use for:

  • Modal dialogs
  • Dropdown menus
  • Popovers
  • Side panels
  • Any overlay that should close on outside interaction

Don't use for:

  • Tooltips (use onPointerLeave instead)
  • Inline dropdowns that shouldn't dismiss on click
  • Non-dismissable modals (loading screens)

Critical Design Patterns

  1. Mutable Sets + Custom Events - Performance over React conventions
  2. Capture Phase Detection - React tree vs DOM tree distinction
  3. Force Update Pattern - Precise control over re-renders
  4. Branch Pattern - Safe bubbles for portaled content
  5. Two Effect Pattern - Preserve creation order in layer stack
  6. Touch Handling - Wait for click event to handle scroll cancellation
  7. Mount Race Prevention - Delay listener registration by one tick

Performance Characteristics

  • Re-renders: O(1) per layer mount (not O(n))
  • Memory: O(n) where n = number of open layers
  • Event overhead: ~0.1ms per dispatchUpdate
  • Scales to: 100+ nested layers without issues

Conclusion

DismissableLayer is a masterclass in solving complex UI problems with elegant patterns. Radix chose performance and precision over React conventions, resulting in a utility that handles edge cases most developers never consider.

Key innovations:

  • Mutable Sets avoid Context re-render cascades
  • Custom events enable decoupled communication
  • Capture phase distinguishes React tree from DOM tree
  • Branch pattern solves the portaled content problem
  • Separate effects preserve layer creation order

This implementation is production-ready and handles all real-world scenarios including nested modals, touch devices, keyboard navigation, and portaled content.

Next Steps:

  • Study other Radix primitives (Portal, FocusScope, Presence)
  • Build your own component library using these patterns
  • Contribute to Radix UI on GitHub

Resources:

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee