Skip to content

Repository files navigation

use-modal-procedure

npm version license bundle size peer dependency

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-procedure

Table of contents


The problem

Modal flows in React usually split logic across three places:

  1. Boolean state (isOpen) in the parent
  2. Callbacks (onConfirm, onClose) wired through props
  3. 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 modalProps onto your existing modal component
  • Call controls.open() or controls.next() from async handlers and await the result
  • Branch on result.action — not on ambiguous null checks

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/await modal control — linear code paths instead of callback chains
  • ProcedureResult<T> — discriminated union: accept | next | cancel
  • NULL_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 for key={procedureId} form resets
  • Bring your own UI — MUI, Radix, native <dialog>, or hand-rolled markup
  • TypeScript-first — explicit TResult generic; 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-procedure

Peer 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> | null

Runtime 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 on accept and next
  • TProps — your modal props; must extend ModalProcedureBaseProps<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

MIT © dvegap95


About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages