use-modal-procedure
v0.2.0
Published
Headless React hook to await modal results with async/await. Multi-step yields, typed ProcedureResult, zero dependencies. No provider required.
Downloads
828
Maintainers
Readme
use-modal-procedure
npm: use-modal-procedure · GitHub: dvegap95/use-modal-procedure · License: MIT
Headless React hook to await modal results with async/await. Multi-step yields, typed ProcedureResult, zero dependencies.
Turn modal interactions into linear async/await flows — with first-class support for multi-step procedures, intermediate yields, and typed results.
A headless React hook. No provider. No UI lock-in. Zero runtime dependencies.
npm install use-modal-procedureTable of contents
- The problem
- The idea
- Features
- When to use it
- Installation
- Core concepts
- Quick start — confirm dialog
- Procedure loop — multi-step flows
- Retry on error
- Cancellation
- TypeScript
- API reference
- Design notes
- FAQ
- License
The problem
Modal flows in React usually split logic across three places:
- Boolean state (
isOpen) in the parent - Callbacks (
onConfirm,onClose) wired through props - Imperative coordination to sequence "show → wait → act → hide"
For a simple confirm dialog this is manageable. For anything richer — wizards, collect-until-done forms, retry-after-error loops — the logic fragments. You end up with nested callbacks, refs to bridge async gaps, or state machines bolted onto components that were meant to stay dumb.
What you often want instead is the same mental model as any other async operation:
const result = await askUser();
if (result.confirmed) await doWork();This library makes that possible while keeping your modal components as plain, reusable UI.
The idea
useModalProcedure returns [controls, modalProps]:
- Spread
modalPropsonto your existing modal component - Call
controls.open()orcontrols.next()from async handlers andawaitthe result - Branch on
result.action— not on ambiguousnullchecks
Two interaction modes, intentionally separate:
| Mode | Entry point | Typical use |
| ---- | ----------- | ----------- |
| Single-use | controls.open() | Confirm, pick one value, blocking dialog |
| Procedure | controls.next() in a loop | Wizards, yields, retry loops, collect-until-done |
Do not mix open and next in the same flow.
Features
async/awaitmodal control — linear code paths instead of callback chainsProcedureResult<T>— discriminated union:accept|next|cancelNULL_RESULT— typed pre-loop sentinel; generics inferred without annotations- Optional
onNext— yield intermediate data while the modal stays open - Per-step
onCancel— colocate undo/rollback with the step that created state procedureId— opt-in step counter forkey={procedureId}form resets- Bring your own UI — MUI, Radix, native
<dialog>, or hand-rolled markup - TypeScript-first — explicit
TResultgeneric; null-safe type guards included - ~4 KB minified — no portal lib, no global store, no provider tree
When to use it
Good fit
- Confirm / delete / unsaved-changes guards before async work
- Multi-step modals where the caller orchestrates the flow
- "Add another" collection UIs that yield data until the user clicks Done
- Retry loops where the modal stays open after a server error
- Any flow where you want
await modal()semantics with full typing
Probably not needed
- Always-mounted modals toggled purely by local UI state
- App-wide modal managers with a central registry (different trade-off)
- One-line alerts with no return value and no async coordination
Installation
npm install use-modal-procedurePeer dependency: react >= 18
Core concepts
ProcedureResult<T>
Every open() / next() call resolves to:
type ProcedureResult<T> =
| { action: 'accept'; data: T } // user finished — process/persist
| { action: 'next'; data: T } // intermediate yield — accumulate only
| { action: 'cancel' }; // user dismissed — reset/abort| action | Meaning for the caller |
| -------- | ---------------------- |
| cancel | Abort. Do not process data. |
| accept | Done. Process or persist data. |
| next | Yield. Transform/accumulate data locally; loop continues. |
NULL_RESULT
Before a procedure loop, initialize without manual generics:
let result = controls.NULL_RESULT;
// inferred as ProcedureResult<YourType> | nullRuntime value is null. The name signals "no step yet" — not "empty data" (like an empty array).
Modal callbacks
| Callback | When the modal calls it | Maps to action |
| -------- | ----------------------- | ---------------- |
| onAccept(value) | User confirms / finishes | accept |
| onNext(value) | User yields an intermediate step | next |
| onCancel() | User dismisses | cancel |
onNext is optional. Simple confirm dialogs only need onAccept and onCancel.
open vs next
open()— always opens the modal. Use once per single-use interaction.next()— re-arms the pending promise. Opens on the first call if the modal is closed; stays open on subsequent calls.
Seed initial modal data via hook defaults or setProps — not via special-casing the first next() call.
Quick start — confirm dialog
import { isAccept, isCancel, useModalProcedure } from 'use-modal-procedure';
import type { ModalProcedureBaseProps } from 'use-modal-procedure';
type ConfirmResult = { confirmed: boolean };
type ConfirmModalProps = ModalProcedureBaseProps<ConfirmResult> & { message: string };
function ConfirmModal({ open, message, onAccept, onCancel }: ConfirmModalProps) {
if (!open) return null;
return (
<div role="dialog" aria-modal="true">
<p>{message}</p>
<button type="button" onClick={() => onAccept?.({ confirmed: true })}>
Confirm
</button>
<button type="button" onClick={() => onCancel?.()}>
Cancel
</button>
</div>
);
}
function DeleteButton() {
const [controls, modalProps] = useModalProcedure<ConfirmResult, ConfirmModalProps>();
const handleDelete = async () => {
const result = await controls.open({ message: 'Delete this item?' });
if (isCancel(result)) return;
if (isAccept(result)) await deleteItem(result.data);
};
return (
<>
<button type="button" onClick={handleDelete}>Delete</button>
<ConfirmModal {...modalProps} message={modalProps.message ?? ''} />
</>
);
}Procedure loop — multi-step flows
Use next() only. Put await controls.next() as the first line inside the loop:
import { isAccept, isCancel, isNext, useModalProcedure } from 'use-modal-procedure';
import type { ModalProcedureBaseProps } from 'use-modal-procedure';
type Item = { id: string };
type CollectProps = ModalProcedureBaseProps<Item> & { items: Item[] };
function CollectModal({ open, onNext, onAccept, onCancel }: CollectProps) {
if (!open) return null;
return (
<div role="dialog" aria-modal="true">
<button type="button" onClick={() => onNext?.({ id: crypto.randomUUID() })}>
Add item
</button>
<button type="button" onClick={() => onAccept?.({ id: 'summary' })}>
Done
</button>
<button type="button" onClick={() => onCancel?.()}>
Cancel
</button>
</div>
);
}
function CollectItems() {
const [controls, modalProps] = useModalProcedure<Item, CollectProps>({ items: [] });
const { procedureId } = controls;
const handleCollect = async () => {
const collected: Item[] = [];
let result = controls.NULL_RESULT;
for (;;) {
result = await controls.next();
if (isCancel(result)) break;
if (isAccept(result)) {
await finalize([...collected, result.data]);
break;
}
if (isNext(result)) {
collected.push(result.data);
}
}
};
return (
<>
<button type="button" onClick={handleCollect}>Collect items</button>
{/* procedureId: opt-in reset between steps */}
<CollectModal {...modalProps} items={modalProps.items ?? []} key={procedureId} />
</>
);
}Retry on error
Same loop shape. On accept, try to persist; on failure, update props and continue:
const [controls, modalProps] = useModalProcedure<FormData, FormModalProps>({ email: '' });
let result = controls.NULL_RESULT;
for (;;) {
result = await controls.next();
if (isCancel(result)) return;
if (isNext(result)) {
draft = result.data;
continue;
}
// isAccept — attempt persist
try {
await submit(result.data);
return;
} catch {
controls.setProps({ error: 'Submission failed. Try again.' });
}
}Cancellation
Three levels — use what fits the flow:
1. Discriminated result (default)
if (isCancel(result)) {
reset();
return;
}2. Per-step onCancel
Runs when that specific step is cancelled — good for colocated undo:
result = await controls.next(undefined, {
onCancel: () => undoLastDraftItem(),
});3. Hook-level onCancel
Applies to every step unless overridden per call:
useModalProcedure(defaults, {
onCancel: () => resetGlobalDraft(),
});TypeScript
Generics
useModalProcedure<TResult, TProps>(defaultProps?, options?)TResult— the data shape returned onacceptandnextTProps— your modal props; must extendModalProcedureBaseProps<TResult>
type MyResult = { id: string } | { variant: 'skip' };
type MyModalProps = ModalProcedureBaseProps<MyResult> & {
title: string;
isLoading?: boolean;
};
const [controls, props] = useModalProcedure<MyResult, MyModalProps>({
title: 'Edit item',
});Union result types work cleanly with an explicit TResult — no conditional inference hacks.
Type guards
All guards are null-safe (NULL_RESULT returns false):
import { isAccept, isNext, isCancel } from 'use-modal-procedure';
if (isAccept(result)) {
result.data; // narrowed to T
}Extending modal props
type ModalProcedureBaseProps<TResult = true> = {
open: boolean;
onAccept?: (value: TResult) => void;
onCancel?: () => void;
onNext?: (value: TResult) => void;
};Intersect with your own props:
type Props = ModalProcedureBaseProps<UserInput> & {
existingUsers: string[];
error?: string;
};API reference
useModalProcedure(defaultProps?, options?)
Returns [controls, modalProps].
Options
| Option | Default | Description |
| ------ | ------- | ----------- |
| autoClose | 'both' | When the modal closes: 'onAccept', 'onCancel', 'both', 'none' |
| onCancel | — | Hook-level handler on every cancel |
autoClose applies to onAccept / onCancel only — never to onNext yields.
Controls
| Member | Type | Description |
| ------ | ---- | ----------- |
| NULL_RESULT | ProcedureResult<T> \| null | Pre-loop sentinel; typed null |
| open | (props?, options?) => Promise<ProcedureResult<T>> | Single-use: opens modal |
| next | (props?, options?) => Promise<ProcedureResult<T>> | Procedure: re-arms promise |
| complete | (result: T) => void | Resolve externally as accept |
| setProps | Dispatch<SetStateAction<Partial<TProps> \| null \| undefined>> | Update props while open |
| close | () => void | Close without resolving |
| procedureId | number | Increments each arm; use for key resets |
ArmOptions (second argument to open / next)
| Option | Description |
| ------ | ----------- |
| onCancel | Per-step cancel handler |
Exports
// Hook
export { useModalProcedure };
// Guards
export { isAccept, isNext, isCancel };
// Types
export type {
ModalProcedureBaseProps,
ProcedureResult,
AutoCloseOption,
ArmOptions,
UseModalProcedureOptions,
Controls,
UseModalProcedureReturn,
};Design notes
A few intentional choices behind the API:
Why a discriminated union instead of null?
null conflates cancellation with "no value" and breaks down in loops where you need to distinguish yield (next) from completion (accept). An action field makes control flow explicit and TypeScript-friendly.
Why separate open and next?
start or a single method would muddy the common case — a one-shot confirm dialog. open() reads naturally for that. next() is the specialized sibling for procedures, with identical call signature every iteration.
Why NULL_RESULT instead of annotating | null?
The sentinel carries the hook's TResult generic so callers don't repeat ProcedureResult<Item> | null at every loop site. Zero runtime cost — it is typed null.
Why no provider? Modal state is local to the interaction. Scoping it to a hook keeps tree composition flat and avoids global singletons or zustand-style stores for something that is inherently ephemeral.
Why is procedureId on controls, not modal props?
Injecting a counter into every modal's prop interface leaks an implementation detail. Consumers who need step-based resets opt in via key={procedureId} or useEffect — standard React patterns.
Roadmap
Future: procedure iterator (controls.steps()) — An async-iterator wrapper over next() for for await...of accumulation is under consideration. Cancel will be modeled as data ({ cancelled: true }), not exceptions. The raw next() + isCancel / isAccept / isNext loop remains the recommended procedure API until then.
FAQ
Can I use this with MUI / Radix / Headless UI?
Yes. Extend ModalProcedureBaseProps<T> and spread modalProps into your component. The hook does not render anything.
Does it trap focus or handle accessibility? No — that stays in your modal component. The hook only coordinates visibility, props, and promise resolution.
What happens if I call open and next in the same flow?
Unsupported. Pick one mode per interaction. Mixing them leads to confusing open-state behavior.
Can multiple modals run at once?
Each useModalProcedure instance is independent. Use one hook per modal.
Does complete() close the modal?
No — it resolves the pending promise as { action: 'accept', data } without triggering autoClose. Call close() separately if needed.
Is there a React Native version? Not yet. The hook only depends on React state/refs and should be portable; RN support may come if there is demand.
License
- npm: https://www.npmjs.com/package/use-modal-procedure
- GitHub: https://github.com/dvegap95/use-modal-procedure
