Skip to content

Recommended pattern for anchoring Callables to trigger elements (Popover, Dropdown, etc.) #126

Description

@MrNaceja

Recommended pattern for anchoring Callables to trigger elements (Popover, Dropdown, etc.)

Context

I'm building anchored UI components as Callables—specifically a Popover that needs to position itself relative to a trigger element. Your context-menu example shows an elegant pattern where coordinates are passed as props and the Callable owns the rendering, following the principle:

"The trigger owns the coordinates; the Callable owns the rendering."

This works beautifully for positioned menus. However, for components like Popover that rely on Radix primitives (PopoverAnchor, PopoverTrigger), the recommended pattern isn't immediately clear. I've experimented with multiple approaches and want to understand which one aligns with react-call philosophy.


The Problem

When building anchored Callables, there's tension between:

  1. Radix's anchor primitives — designed for persistent DOM references
  2. React-call's philosophy — where the caller owns context and the Callable owns rendering
  3. Lifecycle management — Callables mount/unmount; anchor elements may be destroyed

Example scenario: A user clicks a button. A Popover should appear anchored to that button's position, remain open until dismissed, and resolve with a value.


Three Approaches I've Explored

1. PopoverTrigger (Invisible Anchor)

export const PopoverCallable = createCallable<Props, Response>(
  ({ call, title, children }) => {
    const [open, setOpen] = useState(true)

    return (
      <Popover open={open} onOpenChange={setOpen}>
        {/* Hidden trigger serves as anchor for Radix */}
        <PopoverTrigger asChild>
          <div className="hidden" />
        </PopoverTrigger>

        <PopoverContent>{children}</PopoverContent>
      </Popover>
    )
  }
)

Pros:

  • ✅ Leverages Radix's positioning engine
  • ✅ Collision detection, auto-placement work automatically
  • ✅ Familiar to developers using Radix

Cons:

  • ❌ Hidden DOM elements feel indirect
  • ❌ Positioning relative to invisible trigger is unpredictable
  • ❌ Doesn't follow "trigger owns coordinates" principle
  • ❌ Radix expects the trigger to be the click source

2. PopoverAnchor (Element Reference)

interface PopoverCallableAnchoredProps {
  title: string
  children: React.ReactNode
  anchorElement?: HTMLElement | null
}

export const PopoverCallableAnchored = createCallable<Props, Response>(
  ({ call, title, children, anchorElement }) => {
    const [open, setOpen] = useState(true)

    return (
      <Popover open={open} onOpenChange={setOpen}>
        {anchorElement ? (
          <PopoverAnchor asChild>
            {/* Problem: can't render to external DOM */}
            <div ref={...} />
          </PopoverAnchor>
        ) : (
          <PopoverTrigger asChild>
            <button className="hidden" />
          </PopoverTrigger>
        )}
        <PopoverContent>{children}</PopoverContent>
      </Popover>
    )
  }
)

Usage:

const button = document.querySelector('#my-button')
await PopoverCallable.call({ 
  anchorElement: button,
  title: 'Menu',
})

Pros:

  • ✅ Clean caller API
  • ✅ Maintains Radix's collision detection

Cons:

  • ❌ Can't render PopoverAnchor to external elements
  • ❌ Lifecycle mismatch (Callable unmounts, element persists)
  • ❌ Requires manual element selection (error-prone)
  • ❌ Element might be destroyed while Callable is open

3. Positioned (Coordinates Pattern)

interface PopoverCallablePositionedProps {
  x: number
  y: number
  title: string
  children: React.ReactNode
}

export const PopoverCallablePositioned = createCallable<Props, Response>(
  ({ call, x, y, title, children }) => {
    const [open, setOpen] = useState(true)
    const ref = useRef<HTMLDivElement>(null)

    useEffect(() => {
      const onDocClick = (e: MouseEvent) => {
        if (ref.current && !ref.current.contains(e.target as Node)) {
          call.end(false)
        }
      }
      document.addEventListener('mousedown', onDocClick)
      return () => document.removeEventListener('mousedown', onDocClick)
    }, [call])

    return (
      <div
        ref={ref}
        style={{
          position: 'fixed',
          top: `${y}px`,
          left: `${x}px`,
          zIndex: 50,
        }}
      >
        <div>{title}</div>
        <div>{children}</div>
      </div>
    )
  }
)

Usage (matching context-menu pattern):

const handleClick = async (e: React.MouseEvent) => {
  const confirmed = await PopoverCallablePositioned.call({
    x: e.clientX,
    y: e.clientY,
    title: 'Menu',
    children: 'Select an option',
  })
}

return <button onClick={handleClick}>Open</button>

Pros:

  • ✅ Follows your documented "trigger owns coordinates" principle
  • ✅ Clean separation of concerns (caller context → Callable rendering)
  • ✅ No hidden DOM elements
  • ✅ Consistent with context-menu example
  • ✅ Trivial lifecycle management
  • ✅ Simple implementation

Cons:

  • ❌ Loses Radix's collision detection & auto-placement
  • ❌ Manual positioning & offset handling required
  • ❌ Requires caller to compute coordinates
  • ❌ Doesn't feel like a "Popover" (more like positioned dialog)

References

Official Radix Documentation

  • Radix UI Popover — Component API
    • PopoverTrigger: The button that toggles the popover
    • PopoverAnchor: Optional element to position content against
    • PopoverContent: Positioned content (portal-rendered)

shadcn/ui Integration

react-call Precedent

  • Context Menu Example
    • Caller captures e.clientX / e.clientY from event
    • Passes as props to Callable
    • Callable renders fixed at coordinates
    • Principle: "The trigger owns the coordinates; the Callable owns the rendering"

Questions for the Community

1. Coordinate-based positioning as the standard for anchored Callables?

Should all anchored UI components (Popover, Dropdown, Tooltip, etc.) follow the context-menu pattern—passing (x, y) coordinates from the caller?

  • This would mean abandoning Radix primitives entirely for anchoring
  • Accepting that developers manually handle positioning/collisions
  • Is this intentional? Or are Radix primitives expected to work with Callables in a way I'm missing?

2. When element anchoring is truly needed (e.g., dynamic width matching)?

What's the recommended pattern when you need to:

  • Anchor to a trigger element's dimensions (not just coordinates)
  • Match the Popover width to the trigger width
  • Respond to trigger resizing (ResizeObserver)

Is this a "don't" for Callables? Or is there a pattern I haven't considered?

3. Radix anchor primitives + Callables = ❌?

I suspect Radix's PopoverTrigger and PopoverAnchor are fundamentally incompatible with react-call's model because:

  • They expect persistent DOM presence
  • They require click source for focus management
  • Callables mount/unmount based on async control flow

Is this correct? Should the docs explicitly recommend against using Radix's anchor primitives with Callables?


Proposal

Add a recommended pattern section to the documentation:

### Anchored Components (Popover, Dropdown, Tooltip)

For components that must appear at a specific screen position:

#### ✅ Recommended: Caller passes coordinates

The caller captures the trigger position from the DOM event and passes 
it to the Callable. The Callable owns rendering at that position.

See: [Context Menu Example](https://react-call.desko.dev/examples/context-menu)

#### ❌ Avoid: Radix anchor primitives

PopoverTrigger and PopoverAnchor expect persistent DOM presence and 
click source tracking. Callables mount/unmount on async control flow, 
creating lifecycle conflicts.

Additional Context

  • Framework: React 19 + TypeScript
  • UI Library: shadcn/ui (Radix primitives)
  • Use Case: Building a component library where form modals, context menus, and popovers are all Callables
  • Goal: Establish clear best practices for contributors and library users

Summary

I believe Approach #3 (Positioned with coordinates) is the right pattern for react-call Callables, as it:

  • Aligns with your documented philosophy
  • Keeps concerns cleanly separated
  • Simplifies lifecycle management
  • Requires no hidden DOM elements

But I want to confirm: Is this the intended recommendation? Should Radix primitives be avoided for Callables, or is there a technique I'm missing?

Would appreciate your guidance! 🙏

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions