npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

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


  • npm: https://www.npmjs.com/package/use-modal-procedure
  • GitHub: https://github.com/dvegap95/use-modal-procedure