react-call v2

Context menu

A positioned menu opened on right-click. The caller forwards the cursor coordinates so the Callable renders at the click site.

→ no action yet

The Callable

declared once, mounted in the React tree
ContextMenu.tsx
import { useEffect, useRef } from 'react'
import { createCallable } from 'react-call'
interface Action {
id: string
label: string
destructive?: boolean
}
interface Props {
x: number
y: number
actions: readonly Action[]
}
export const ContextMenu = createCallable<Props, string | null>(
({ call, x, y, actions }) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
call.end(null)
}
}
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') call.end(null)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onEsc)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onEsc)
}
}, [call])
return (
<div
ref={ref}
role="menu"
style={{ top: y, left: x }}
className="fixed z-50 min-w-[180px] rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] p-1 shadow-2xl"
>
{actions.map((action) => (
<button
key={action.id}
type="button"
role="menuitem"
onClick={() => call.end(action.id)}
className={
action.destructive
? 'block w-full rounded px-3 py-1.5 text-left text-sm text-red-500 transition-colors hover:bg-red-500/10'
: 'block w-full rounded px-3 py-1.5 text-left text-sm text-[var(--color-fg)] transition-colors hover:bg-[var(--color-bg-subtle)]'
}
>
{action.label}
</button>
))}
</div>
)
},
)
ContextMenu.displayName = 'ContextMenu'

The Root

mounted in your app tree, once
App.tsx
import { ContextMenu } from './ContextMenu'
import { ItemRow } from './ItemRow'
export default function App() {
return (
<>
<ContextMenu />
<ItemRow />
</>
)
}

The caller

anywhere in your app, imperative
ItemRow.tsx
import { type MouseEvent, useState } from 'react'
import { ContextMenu } from './ContextMenu'
const ACTIONS = [
{ id: 'rename', label: 'Rename' },
{ id: 'duplicate', label: 'Duplicate' },
{ id: 'archive', label: 'Archive' },
{ id: 'delete', label: 'Delete', destructive: true },
] as const
export const ItemRow = () => {
const [lastAction, setLastAction] = useState<string | null>(null)
const handleContextMenu = async (e: MouseEvent) => {
e.preventDefault()
const action = await ContextMenu.call({
x: e.clientX,
y: e.clientY,
actions: ACTIONS,
})
if (action) setLastAction(action)
}
return (
<div className="flex flex-col items-center gap-3">
<button
type="button"
onContextMenu={handleContextMenu}
className="cursor-default select-none rounded-md border border-dashed border-[var(--color-border-strong)] bg-[var(--color-bg-subtle)] px-8 py-6 text-center text-sm text-[var(--color-fg-muted)]"
>
Right-click me
</button>
<span className="font-mono text-xs text-[var(--color-fg-subtle)]">
{lastAction ? (
<span className="text-[var(--color-accent)]">→ {lastAction}</span>
) : (
'→ no action yet'
)}
</span>
</div>
)
}

The caller knows where the click was

A context menu has to appear at the cursor — and the only place the cursor position exists is the DOM event, which fires at the call site, not inside the Callable. So the caller reads e.clientX / e.clientY and hands them over as plain x / y props; the Callable just renders fixed at those coordinates. It never reaches into the DOM to find the pointer.

That is the role .call(props) plays in general: it is the seam where caller-side context — a cursor position, the row you right-clicked, any data the event carries — crosses into the Callable. The event happens in your component; .call() snapshots what the Callable needs and opens it.

When to reach for it

Reach for this whenever a surface must appear anchored to a point: a right-click menu, a popover beside a clicked element, a picker that springs from a button. The trigger owns the coordinates; the Callable owns the rendering.

Related examples