Typed async action flows for React forms and buttons, with coordinated pending states, confirmations, cancellation, and feedback.
Description: A typed React library for coordinating async form actions, confirmations, cancellation, and accessible feedback.
Tags: react · typescript · react-library · async-actions · form-handling · vite · nextjs · accessible-ui · headless-ui
Website: react-submit-flow · Documentation · Examples
The repository is a pnpm workspace containing:
- the published package:
packages/react-submit-flow - the documentation website:
apps/website
For package-level installation and consumer-first usage, see the library README.
Provided by react-submit-flow |
Still owned by the consuming app |
|---|---|
Typed named action orchestration (useSubmitFlow) |
Form field registration/state management |
| Busy locking per flow and explicit run results | Domain validation and business rules |
| Confirmation lifecycle and retained confirmation support | Transport details (fetch, RPC, SDK choices) |
Cancellation wiring (AbortSignal, throwIfCancelled) |
Server-side idempotency/rollback strategy |
| Built-in provider dialogs, queueing, and toast host | Routing, cache invalidation, optimistic updates |
| Optional UI components and optional CSS/themes | App-level design system and accessibility reviews |
- Node.js
^22.18.0,^24.11.0, or>=26.0.0for the Vite+ toolchain - pnpm 10.26.2 (managed through Corepack or your package manager)
vp install
vp run devThe development script builds the package, then starts the Vite package watcher and Next.js website. Open the local URL printed by Next.js to browse the docs and live examples. Use vp run dev for this Next.js project; vp dev starts Vite's built-in server. You can also use pnpm install and pnpm dev.
| Command | Purpose |
|---|---|
vp run dev |
Build the package, watch library changes, and run the website. |
vp run build |
Build the library and the production website. |
vp run build:package |
Build the Vite package and declarations. |
vp run build:website |
Build the Next.js website. |
vp run typecheck |
Emit package declarations, then typecheck the library and website. |
vp run lint |
Lint library and website source with Vite+. |
vp run check |
Run typecheck, lint, and production builds through Vite+. |
vp run test |
Run package tests (currently a placeholder script). |
vp run test:e2e |
Run website e2e tests (currently a placeholder script). |
vp run check:package |
Inspect files included in the npm package. |
If you run the website alone, build the workspace package first:
vp run build:package
vp -C apps/website run dev- Monorepo: pnpm workspace (
pnpm-workspace.yaml) withpackages/*andapps/* - Library: React + TypeScript, built with Vite and
tsc - Website: Next.js App Router + MDX, statically exported for GitHub Pages
- Quality gates: Vite+ formatting and linting, TypeScript checks, and CI workflows
- Package distribution: ESM package exports (
.+./headless+ CSS subpaths)
| Path | Role |
|---|---|
apps/website/ |
Docs + examples site that consumes the workspace package. |
packages/react-submit-flow/ |
Publishable library source, types, CSS, themes, and package build scripts. |
.github/workflows/ |
CI validation and GitHub Pages deployment workflows. |
package.json |
Root orchestration scripts across package + website. |
pnpm-workspace.yaml |
Workspace package discovery. |
pnpm-lock.yaml |
Locked dependency graph for reproducible installs. |
tsconfig.base.json |
Shared TypeScript compiler baseline. |
vite.config.ts |
Shared Vite+ lint and formatting configuration. |
app/: Next.js routes (/docs,/examples, root pages)content/docs/: MDX source for documentation sectionscomponents/,lib/,mdx-components.tsx: site rendering and MDX plumbingnext.config.mjs: static export setup + GitHub Pages basePath handling
src/index.tsx: main UI entry (provider, hooks, components, public types)src/headless.ts: headless entry (useSubmitFlow+ types only)src/core/controller.ts: runtime state machine and action orchestration coresrc/components/components.tsx: form/control primitivessrc/feedback/provider.tsx: native<dialog>host, queueing, toastssrc/types.ts: canonical public types (FlowStatus,RunResult, action contracts)src/styles.css,src/themes/*: optional default styling and theme presetsscripts/clean.mjs,scripts/copy-css.mjs: build helpers fordist/
ci.yml: checkout, install, lint, typecheck, build, package-content verificationdeploy-pages.yml: static website build + artifact upload + Pages deployment
A single useSubmitFlow(actions, options) call creates one independent flow instance backed by FlowController.
React event (submit/click)
-> useSubmitFlow().run(action, input)
-> FlowController.run(...)
-> optional confirm step (provider or adapter)
-> action.run(input, context)
-> map to RunResult (success/error/cancelled/skipped)
-> provider/adapters feedback (toast/inline/none)
-> consumer handles typed result
FlowStatus: idle | confirming | pending | cancelling | success | error | cancelled
idle -> confirming -> pending -> success | error
idle -> pending -> cancelling -> cancelled
confirming -> cancelled (declined or dismissed)
isBusy stays true during confirm/pending/cancelling cleanup. If another run starts while busy, run() resolves as { status: 'skipped', reason: 'busy' }.
packages/react-submit-flow/src/core/controller.ts owns the runtime contract:
- resolves action lookups and rejects unknown action names
- creates per-run
AbortControllerandrunId - applies confirmation + retained-confirmation logic
- manages status snapshots consumed by React via
useSyncExternalStore - resolves discriminated results (
success/error/cancelled/skipped) - dispatches feedback through provider bridge or adapters
- executes observers (
onSuccess,onError,onCancel) safely
ActionDefinition<Input, Output> drives inference:
const flow = useSubmitFlow({
save: {
run: async (input: { name: string }) => ({ id: "1", name: input.name }),
},
});
const result = await flow.run("save", { name: "Ada" });
// result narrows by status; on success, result.data is typed from run() output.- Action names are inferred from object keys.
- Inputs are inferred per action for
run(action, input). - Outputs are inferred per action and surfaced in success variants.
RunResult variants:
{ status: 'success'; action; data; runId }{ status: 'error'; action; error; runId }{ status: 'cancelled'; action; reason: 'declined' | 'user' | 'unmounted'; runId }{ status: 'skipped'; reason: 'busy' }
Every run receives context.signal and context.throwIfCancelled().
AbortSignaltells abort-aware APIs (for examplefetch) to stop pending work.throwIfCancelled()is for boundaries between sequential side effects.
run: async (input, context) => {
const draft = await saveDraft(input, { signal: context.signal });
context.throwIfCancelled();
return publishDraft(draft.id, { signal: context.signal });
};Cancellation is cooperative: aborting cannot undo already-completed external writes, so the flow stays busy until the runner settles and then resolves cancelled.
An action can define confirm as:
- static options object, or
- function
(input) => ConfirmOptionsfor dynamic confirmations
closeOnConfirm determines whether the confirmation closes immediately after user acceptance:
true(default behavior): close then executefalse: retain dialog throughpending/cancelling/errorphases
Precedence for closeOnConfirm:
action.closeOnConfirmaction.confirm.closeOnConfirmSubmitFlowProviderdialogDefaults.closeOnConfirm- fallback
true
For retained behavior with adapters, implement adapters.retainedConfirmation (not only adapters.confirm).
SubmitFlowProvider installs a FeedbackStore bridge that:
- queues dialog requests and shows one active dialog at a time
- supports confirmation and alert requests (
useSubmitFeedback) across owners - renders native
<dialog>for built-in confirmations/alerts - hosts shared toast notifications per provider instance
Toast behavior:
- non-error toasts auto-dismiss (
toastDuration, default5000ms) - error toasts persist until dismissed
- visible toast count is capped (
maxVisibleToasts, default3) - hover/focus pauses auto-dismiss timers
SubmitFlowForm: native<form>wrapper,defaultAction, and typedgetInputmapping fromFormDataSubmitFlowRoot: context-only wrapper for non-form action groupsSubmitButton: submit button tied todata-sf-actionActionButton: direct action trigger with optional explicit inputCancelButton: requests cancellation when busy, falls back toonCancelwhen idleBackButton: executesonBack, disabled while busySubmitFeedback: inline pending/result feedback surface with render customization
Use react-submit-flow/headless when bringing your own UI:
- exports
useSubmitFlowand execution types - does not import built-in provider UI or CSS
- can run actions without provider if no built-in confirm/alert is needed
Adapters allow integration with existing dialog/toast stacks:
adapters.confirmadapters.alertadapters.retainedConfirmationadapters.notifications
useSubmitFeedback() (inside SubmitFlowProvider) provides:
confirm(options) -> Promise<boolean>alert(options) -> Promise<{ status: 'acknowledged' } | { status: 'dismissed'; reason }>
This is useful for non-flow interactions that still need consistent provider-backed dialogs.
Built-in UI includes:
- semantic dialog roles (
dialog/alertdialog) andaria-modal - labelled dialog title/description wiring
role="status"orrole="alert"+ polite/assertivearia-livefor feedback and toasts- keyboard dismissal handling based on dialog policy (
closeOnEscape, retained phase rules) - focus-aware toast timing pause/resume
Accessibility remains a shared responsibility: verify adapter-based implementations and app-level compositions in your product context.
CSS is optional but recommended for polished defaults.
import "react-submit-flow/styles.css";
import "react-submit-flow/themes/forest.css"; // optional preset
// or: import 'react-submit-flow/themes/high-contrast.css'Public style entry points:
react-submit-flow/styles.cssreact-submit-flow/themes/forest.cssreact-submit-flow/themes/high-contrast.css
| Import path | Exports |
|---|---|
react-submit-flow |
useSubmitFlow, useSubmitFeedback, SubmitFlowProvider, UI components, public types |
react-submit-flow/headless |
headless useSubmitFlow + flow/action types |
| CSS subpaths | default stylesheet + theme presets |
packages/react-submit-flow/package.json build pipeline:
scripts/clean.mjsremovesdist/tsc -p tsconfig.build.jsonemits declarationsvite buildemits JS bundlesscripts/copy-css.mjscopiesstyles.cssand theme files todist/
From repository root:
pnpm install
pnpm dev
pnpm typecheck
pnpm lint
pnpm build
pnpm checkBefore opening a contribution, run:
pnpm checkCommon patterns documented and exercised in the website examples/docs:
- React Hook Form
- TanStack Query
- Next.js Server Functions
- Custom dialog/toast systems via adapters
- Cancellation is cooperative; it is not rollback.
- Built-in dialogs use native
<dialog>; validate interoperability if your app already uses another modal stack. - Package and website test scripts are placeholders today; CI relies on lint/typecheck/build/package checks.
- Strong TypeScript inference around action names/inputs/outputs
- Explicit result union instead of implicit promise error channels
- Composable architecture: built-in UI for speed, headless entry for customization
- Clear separation between flow orchestration and application business logic
- Keep changes scoped and typed.
- Prefer updating docs/examples alongside behavior changes.
- Use the website (
apps/website) to validate docs and interactive behavior.
MIT. See packages/react-submit-flow/LICENSE.