For AI agents: the complete documentation index is available at /toi/llms.txt, the full documentation bundle is available at /toi/llms-full.txt, and this page is available as Markdown at /toi/guide/basic/resolving.md.
  • English
  • Resolving

    Every component toi mounts receives a resolve prop, typed as ToiResolve<Response>. Calling it settles the promise toi returned with the value you pass.

    const Confirm: FC<ToiProps<boolean>> = ({ ref, resolve }) => (
      <dialog ref={ref} open>
        <button onClick={() => resolve(true)}>OK</button>
        <button onClick={() => resolve(false)}>Cancel</button>
      </dialog>
    );
    
    const confirmed = await toi(Confirm);

    Resolving without a value

    Response defaults to void, so components that don't need to report anything back — such as a toast that's just dismissed — can call resolve() with no argument:

    import { toi, ToiHost } from '@praha/toi';
    
    import type { ToiProps } from '@praha/toi';
    import type { FC } from 'react';
    
    const Toast: FC<ToiProps> = ({ ref, resolve }) => (
      <div ref={ref}>
        <p>Saved!</p>
        <button onClick={() => resolve()}>Dismiss</button>
      </div>
    );
    
    export default function App() {
      return (
        <>
          <button onClick={() => toi(Toast)}>Save</button>
          <ToiHost />
        </>
      );
    }

    Rejecting

    Alongside resolve, every component also receives a reject prop, typed as ToiReject. Calling it settles the promise toi returned by rejecting it, so the await throws instead of returning a value.

    reject is for when the component can no longer answer — not for ordinary outcomes like the user closing a dialog. There are two typical cases:

    • Work done before resolving fails. A dialog that performs an action before resolving — submitting a form, deleting an item — has nothing to answer with if that action throws. Pass the error to reject so it reaches the caller.
    • The request is abandoned. The user navigates to another page, or the component is otherwise torn down before it was answered. Call reject() with no argument. Note that page transitions don't do this for you; see Closing on Navigation.
    const Confirm: FC<ToiProps<boolean>> = ({ ref, resolve, reject }) => (
      <dialog ref={ref} open>
        <button onClick={() => resolve(false)}>Cancel</button>
        <button
          onClick={async () => {
            try {
              await deleteItem();
              resolve(true);
            } catch (error) {
              reject(error);
            }
          }}
        >
          Delete
        </button>
      </dialog>
    );
    
    try {
      const deleted = await toi(Confirm);
      // Delete succeeded (true) or Cancel was clicked (false)
    } catch (error) {
      // deleteItem() failed
    }

    reject accepts any reason, just like Promise.reject. When called without one, the promise is rejected with a DOMException named AbortError — the same convention AbortSignal uses — so callers can tell an abandoned request apart from a genuine failure with error.name === 'AbortError'.

    For a dismissal the caller expects and handles — a confirm dialog's "Cancel" button, a toast being closed — resolve with a value (or with nothing) instead, as Confirm above does. That keeps the happy path free of try/catch and reserves rejection for things that actually went wrong.

    Note that an unhandled rejection surfaces as an error in the console, so make sure code awaiting a component that may reject catches it.

    Calling resolve or reject more than once

    Calling resolve or reject a second time has no effect — the first call of either wins, and the promise has already started settling. This makes it safe to wire them up to more than one event without guarding against double-firing yourself, for example both a button click and a backdrop click on a dialog.

    Exit animations

    Resolving (or rejecting) doesn't unmount the component right away. toi looks at the element attached to ref (which must implement the Animatable interface) and waits for every animation running on it or its descendants — except infinitely repeating ones — to finish, before removing the component from ToiHost and settling the promise.

    This means a CSS or Web Animations exit transition started alongside resolve gets a chance to play out fully. Click "Dismiss" below and watch the fade-out finish before the toast disappears:

    import { toi, ToiHost } from '@praha/toi';
    import { useState } from 'react';
    
    import type { ToiProps } from '@praha/toi';
    import type { FC } from 'react';
    
    const Toast: FC<ToiProps> = ({ ref, resolve }) => {
      const [closing, setClosing] = useState(false);
    
      return (
        <>
          <style>{`
            @keyframes toast-fade-out {
              to { opacity: 0; }
            }
            .toast--closing {
              animation: toast-fade-out 600ms ease-out forwards;
            }
          `}</style>
          <div ref={ref} className={closing ? 'toast--closing' : undefined}>
            <p>Saved!</p>
            <button
              onClick={() => {
                setClosing(true);
                resolve();
              }}
            >
              Dismiss
            </button>
          </div>
        </>
      );
    };
    
    export default function App() {
      return (
        <>
          <button onClick={() => toi(Toast)}>Save</button>
          <ToiHost />
        </>
      );
    }

    Clicking "Dismiss" adds the class that starts the fade-out animation and calls resolve in the same handler. toi doesn't unmount the toast until that animation finishes, so the component (and the promise) stays alive for exactly as long as the exit transition takes — with no animationend bookkeeping on your side.

    If ref was never attached to an element, or the element has no running animations, resolve settles the promise on the very next animation frame.