use Graft Dialog
Whether something is open, and the payload it was opened with.
API
Options
| prop | type | default | |
|---|---|---|---|
isActiveOnLoad? | boolean | false | open on the first render |
Returns
| prop | type | default | |
|---|---|---|---|
isActive? | boolean | — | whether it is open |
storage? | T | null | — | the payload open was called with |
open? | (payload?: T) => void | — | stores the payload, then opens |
close? | () => void | — | closes, leaving the payload in place |
id? | string | — | stable 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.
// -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>
)}
/>
);
};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.
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.
// -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.