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.
<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
// 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.
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