submitly

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:

PieceJob
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).
DialogLockProviderHands 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:

  1. const lock = useDialogLockScope().
  2. const dialog = useControllableDialog({ open, onOpenChange, locked: lock.isLocked }), and give the library open={dialog.open} and onOpenChange={dialog.setOpen}. Escape, overlay click and close buttons all go through setOpen, which does nothing while locked.
  3. Wrap the content in <DialogLockProvider scope={lock.scope}> and pass dialog.openKey out as the form's key.
npm i @radix-ui/react-dialog

shadcn/ui's Dialog is a styled Radix Dialog: use its Dialog, DialogContent and DialogTitle in place of the primitives below.

components/form-dialog.radix.tsx
"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 (or onClose). Every mainstream library does.
  • All dismissal routes call that handler: Escape, outside click, the close button, and your own onSuccess. Point them all at dialog.setOpen.
  • The content is unmounted while closed, or you use openKey as the form's key so it remounts on open.
  • The context passes through the library's portal. React context does, so DialogLockProvider can sit inside the portalled content.
Edit on GitHub

On this page