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.

One graft per concern

The list, the dialog, the discard alert, the tab and the draft are each their own graft, and each reads the ones above it — so the order is dictated by who reads whom. Composing grafts is ordinary nesting: nothing in the library arranges it for you.

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

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

The editor

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

function useGraft() {
  const discard = GraftAlertDiscard.use();
  const dialog = useGraftDialog<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 };
}

// graft_store.ts
function useGraft() {
  const dialog = GraftDialogInvoice.use();
  const store = useGraftStore<Invoice | null>({ defaultValue: dialog.storage });

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

  return { ...store, setCustomer, setStatus, save };
}

The url as derived state

The injected hook keeps the search param in step with the graft, and on a deep link it decides what the id means: an existing invoice is opened for editing, anything else falls back to a new one.

tsx
function useInject() {
  const { isActive, isNew, open, storage } = GraftDialogInvoice.use();
  const { byId } = GraftStoreList.use();
  const { invoice } = useSearch({ strict: 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]);

}

Demo

Edit one and watch the address bar. Then reload on ?invoice=0143, and try ?invoice=9999 to see the fallback.

previewsave adds a new invoice to the list, or updates an existing one