Dialogs with Radix, Base UI, Headless UI or native
The same FormDialog for four dialog libraries, with the lock and the form remount wired up.
submitly ships no dialog. It gives you three headless pieces that connect to any dialog's open-state API:
| Piece | Job |
|---|---|
useControllableDialog({ open, onOpenChange, locked }) | Owns the open state (controlled or not), ignores changes while locked, and returns openKey for remounting the form. |
useDialogLockScope() | Creates the lock scope and exposes locked (for rendering) and isLocked() (for guards). |
DialogLockProvider | Hands the scope to useCrudForm / useCrudAction below it, which lock automatically while pending. |
Every variant below is a drop-in components/form-dialog.tsx with the same props, so the forms, tables and recipes
elsewhere in this documentation work unchanged with whichever library you pick. The wiring is always the same three
steps:
const lock = useDialogLockScope().const dialog = useControllableDialog({ open, onOpenChange, locked: lock.isLocked }), and give the libraryopen={dialog.open}andonOpenChange={dialog.setOpen}. Escape, overlay click and close buttons all go throughsetOpen, which does nothing while locked.- Wrap the content in
<DialogLockProvider scope={lock.scope}>and passdialog.openKeyout as the form'skey.
npm i @radix-ui/react-dialogshadcn/ui's Dialog is a styled Radix Dialog: use its Dialog, DialogContent and DialogTitle in place of the
primitives below.
"use client";
import * as Dialog from "@radix-ui/react-dialog";
import type { ReactNode } from "react";
import { DialogLockProvider, useControllableDialog, useDialogLockScope } from "submitly";
export type FormDialogProps = {
title: string;
trigger?: (open: () => void) => ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: (props: { formKey: number; onClose: () => void }) => ReactNode;
};
export function FormDialog({ title, trigger, open, onOpenChange, children }: FormDialogProps) {
const lock = useDialogLockScope();
const dialog = useControllableDialog({ open, onOpenChange, locked: lock.isLocked });
return (
<>
{trigger?.(() => dialog.setOpen(true))}
<Dialog.Root open={dialog.open} onOpenChange={dialog.setOpen}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content aria-busy={lock.locked || undefined} aria-describedby={undefined}>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Close asChild>
<button type="button" disabled={lock.locked} aria-label="Close">
x
</button>
</Dialog.Close>
<DialogLockProvider scope={lock.scope}>
{children({ formKey: dialog.openKey, onClose: () => dialog.setOpen(false) })}
</DialogLockProvider>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
);
}Radix unmounts the content when the dialog closes, so the form inside is mounted fresh on every open even without
openKey. Keep the key anyway: it also covers controlled dialogs that stay mounted.
Using it
Any of the four works the same way:
<FormDialog
title="New product"
trigger={(open) => (
<button type="button" onClick={open}>
Add product
</button>
)}
>
{({ formKey, onClose }) => <ProductForm key={formKey} onDone={onClose} />}
</FormDialog>ProductForm calls useCrudForm. While it submits, the dialog is locked, so Escape, the overlay and the close button
do nothing. When the action succeeds, the lock is released, onSuccess calls onClose, and the dialog closes.
Checklist for a new library
If your dialog library is not listed, check these:
- It offers a controlled
open/onOpenChange(oronClose). Every mainstream library does. - All dismissal routes call that handler: Escape, outside click, the close button, and your own
onSuccess. Point them all atdialog.setOpen. - The content is unmounted while closed, or you use
openKeyas the form'skeyso it remounts on open. - The context passes through the library's portal. React context does, so
DialogLockProvidercan sit inside the portalled content.