Invoice editor

One graft per concern: the invoices, the dialog, the draft, the tab, the discard alert. A search param says which invoice is open, and an unknown id falls back to a new one.

Mounted

Edit one and watch the address bar. Then reload on ?invoice=0143, and try ?invoice=9999 to see the fallback. Five providers, nested in the order dictated by who reads whom — composing grafts is ordinary nesting, and nothing in the library arranges it.

tsx
<GraftStoreList.Provider>
  <GraftAlertDiscard.Provider>
    <GraftDialogInvoice.Provider>
      <InvoiceList />

      {isActive && (
        <GraftStepInvoice.Provider>
          <GraftStoreInvoice.Provider>
            <InvoiceDialog />
          </GraftStoreInvoice.Provider>
        </GraftStepInvoice.Provider>
      )}
    </GraftDialogInvoice.Provider>
  </GraftAlertDiscard.Provider>
</GraftStoreList.Provider>

One graft per concern

The dialog owns whether it is open and whether the draft is dirty, and overrides close to ask before throwing work away. The callers below never learn that it changed.

tsx
// -constants/graft_dialog.ts
export const GraftDialogInvoice = createGraft({
  name: "GraftDialogInvoice",
  graft: useGraft,
  inject: [useInjectUrl, useInjectDeepLink],
});

function useGraft() {
  const discard = GraftAlertDiscard.use();
  const dialog = useDialog<Invoice>();

  const [isDirty, setIsDirty] = useState(false);

  const close = useCallback(async (force = false) => {
    if (!force && isDirty && !(await discard.open())) return;

    dialog.close();
  }, [dialog, discard, isDirty]);

  return { ...dialog, close, isDirty, setIsDirty };
}

The draft sits under it and reads it. It starts from whatever the dialog was opened with, and marks the dialog dirty on every edit — two grafts, one direction.

tsx
// -constants/graft_store.ts
function useGraft() {
  const invoices = GraftStoreList.use();
  const dialog = GraftDialogInvoice.use();
  const [value, setValue] = useState<Invoice | null>(dialog.storage);

  const edit = useCallback((patch: Partial<Invoice>) => {
    dialog.setIsDirty(true);
    setValue((previous) => (previous ? { ...previous, ...patch } : previous));
  }, [dialog]);

  return { value, setValue, setCustomer, setStatus, save };
}

The url as derived state

Two injected hooks, each taking the slice of the value it needs: one keeps the search param in step with the dialog, the other decides on arrival what an id means — an existing invoice is opened for editing, anything else falls back to a new one.

tsx
// the url follows the dialog
function useInjectUrl({ isActive, isNew, storage }: { isActive: boolean; isNew: boolean; storage: Invoice | null }) {
  const navigate = useNavigate();
  const { invoice } = useSearch({ strict: false });

  const target = isNew ? "new" : storage?.id;

  useEffect(() => {
    if (isActive && target && invoice !== target) void navigate({ search: { invoice: target }, replace: true });
    if (!isActive && invoice) void navigate({ search: {}, replace: true });
  }, [invoice, isActive, navigate, target]);
}

// and on arrival, the dialog follows the url
function useInjectDeepLink({ open }: { open: (invoice: Invoice) => void }) {
  const { byId } = GraftStoreList.use();
  const { invoice } = useSearch({ strict: false });
  const hasDeepLinked = useRef(false);

  useEffect(() => {
    if (hasDeepLinked.current) return;

    hasDeepLinked.current = true;
    if (!invoice) return;

    const existing = invoice === "new" ? null : byId(invoice);

    open(existing ?? BLANK_INVOICE);
  }, [byId, invoice, open]);
}

They belong to the provider, not to the dialog, which is why they survive it closing. An effect that unmounted with the dialog could never be the thing that reopens it.

The hooks underneath

Nothing here comes from the library. The tab is a useState, the list is a useState with a save, and these two are worth a file of their own.

tsx
// -constants/use_dialog.ts — open, closed, and what it was opened with
export const useDialog = <T>() => {
  const id = useId();
  const [state, setState] = useState<{ isActive: boolean; storage: T | null }>({ isActive: false, storage: null });

  const open = useCallback((storage: T | null = null) => setState({ isActive: true, storage }), []);
  const close = useCallback(() => setState((previous) => ({ ...previous, isActive: false })), []);

  return { id, isActive: state.isActive, storage: state.storage, open, close };
};

// -constants/use_confirm.ts — a question that resolves with its answer
export const useConfirm = () => {
  const id = useId();
  const [isActive, setIsActive] = useState(false);
  const resolverRef = useRef<((confirmed: boolean) => void) | null>(null);

  const open = useCallback(
    () =>
      new Promise<boolean>((resolve) => {
        resolverRef.current?.(false);
        resolverRef.current = resolve;
        setIsActive(true);
      }),
    [],
  );

  const close = useCallback((confirmed: boolean) => {
    setIsActive(false);
    resolverRef.current?.(confirmed);
    resolverRef.current = null;
  }, []);

  return { id, isActive, open, onConfirm: () => close(true), onDismiss: () => close(false) };
};