use Graft Dialog

Whether something is open, and the payload it was opened with.

API

Options

proptypedefault
isActiveOnLoad?booleanfalseopen on the first render

Returns

proptypedefault
isActive?booleanwhether it is open
storage?T | nullthe payload open was called with
open?(payload?: T) => voidstores the payload, then opens
close?() => voidcloses, leaving the payload in place
id?stringstable id, for aria-labelledby

Usage

Open with something

A dialog is rarely opened in the abstract: it is opened with something, and that something has to survive the trip. open(payload) stores it, and everyone below reads it from storage — no prop threaded down, no id looked up twice, no second source of truth about which row is on screen.

tsx
// -constants/graft_ticket.ts
export const GraftTicket = createGraft({ name: "GraftTicket", graft: useGraftDialog<Ticket> });

// -components/ticket_trigger.tsx
export const TicketTrigger = ({ tickets }: { tickets: readonly Ticket[] }) => {
  const { open } = GraftTicket.use();

  return (
    <List
      array={tickets}
      itemExtractor={({ row }) => (
        <Item key={row.number} onClick={() => open(row)}>
          {`#${row.number} — ${row.subject}`}
        </Item>
      )}
    />
  );
};
previewpick a ticket, then close it and read the chip
nothing opened yet

Two of them, side by side

The same graft mounted twice, over two different lists: each provider keeps the ticket it was opened with, and closing one leaves the other exactly as it was. Nothing had to be namespaced or keyed for that — instances are what a provider is.

previewbugs on one side, requests on the other, one payload each
nothing opened yet
nothing opened yet

Details

The payload is a ref

open(payload) writes the payload before flipping the flag, so it is already there on the render that shows the dialog — no effect, no second pass, no empty first frame.

It is held in a ref rather than in state, which has two consequences worth knowing. Writing it does not schedule a render on its own, and close() does not clear it: what you opened with survives the exit, so a closing animation still has something to show. The next open replaces it.

tsx
// -components/ticket_layer.tsx
export const TicketLayer = () => {
  const { close, id, isActive, storage } = GraftTicket.use();

  return (
    <Layer open={isActive} onClose={close} aria-labelledby={id}>
      <Header.Title id={id} title={`Ticket #${storage?.number ?? "-"}`} />
      <p>{storage?.subject}</p>
    </Layer>
  );
};

// -components/ticket_recap.tsx — reads the payload with the dialog closed
export const TicketRecap = () => {
  const { isActive, storage } = GraftTicket.use();

  if (!storage) return <Chip>nothing opened yet</Chip>;

  return <Chip>{`${isActive ? "open" : "last opened"}: #${storage.number}`}</Chip>;
};

It only holds the answer

The hook knows whether you want the dialog open. It does not know what a dialog is, when its content leaves the screen, or whether anything is animating — that belongs to whatever renders it, and a component that animates its exit is free to keep its children a little longer than the flag says.

Which is also why close is the natural place to extend: wrap it in a hook of your own to ask for confirmation, block on a pending mutation, or refuse outright. Callers below keep calling close and never learn the difference.