Skip to content

strblr/imperative-portal

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

imperative-portal

A lightweight React library for rendering components imperatively with promise-based control. Perfect for modals, dialogs, notifications, and any UI that needs programmatic lifecycle management.

Table of Contents

Motivation

React encourages declarative UI patterns, but sometimes you want to render components imperatively - triggered by user actions, API calls, or other side effects that wouldn't fit nicely into your component tree without heavy boilerplate.

Common use cases include:

  • Modals and dialogs that need to be shown programmatically
  • Toast notifications and alerts
  • Confirmation dialogs
  • Input dialogs
  • Loading spinners or progress indicators
  • Any UI that appears/disappears based on imperative logic

Traditional approaches often involve:

  • Managing local or global state for UI visibility
  • Using complex portal-based setups

imperative-portal simplifies this by providing a clean API where you think of React nodes as promises.

Setup

npm install imperative-portal

Import the root component:

import { ImperativePortal } from "imperative-portal";

Add the <ImperativePortal /> element in your app, where you want your imperative nodes to be rendered. Typically near the root or even in a regular portal, but it's up to you.

Note: If you need some React contexts inside the imperative nodes, put <ImperativePortal /> as a descendant of their providers.

function App() {
  return (
    <div>
      <ImperativePortal />
    </div>
  );
}

Basic example

Open any React node programmatically by passing it to show:

import { show } from "imperative-portal"

const promise = show(
  <Toast>
    <h2>Hello World!</h2>
    <button onClick={() => promise.resolve()}>Close</button>
  </Toast>
);

setTimeout(() => promise.resolve(), 5000)

await promise; // Resolved when "Close" is clicked, or 5 seconds have passed

show returns a promise that tracks and controls the lifecycle of the node. Calling promise.resolve or promise.reject settles the promise and unmounts the node.

You can also pass a node factory:

show(promise => (
  <Toast>
    <button onClick={() => promise.resolve()}>Close</button>
  </Toast>
));

Confirm dialog example

You can get back data from the imperative node via the promise:

function confirm(message: string) {
  return show<boolean>(promise => (
    <Dialog open onOpenChange={open => !open && promise.resolve(false)}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Confirm</DialogTitle>
          <DialogDescription>{message}</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">Cancel</Button>
          </DialogClose>
          <Button onClick={() => promise.resolve(true)}>Confirm</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  ));
}

if (await confirm("Delete this item?")) {
  console.log("Deleted!");
} else {
  console.log("Cancelled");
}

useImperativePromise hook

For components that need to control their lifecycle from within, you can use closures and props as shown above, or pass the promise directly as prop, but simpler would be to use the useImperativePromise hook:

import { show, useImperativePromise } from "imperative-portal";

function SelfManagedDialog() {
  const promise = useImperativePromise();
  return (
    <Dialog open onOpenChange={open => !open && promise.reject()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Self-managed dialog</DialogTitle>
          <DialogDescription>
            This dialog controls its lifecycle from within without props
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">Cancel</Button>
          </DialogClose>
          <Button onClick={() => promise.resolve()}>Confirm</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

try {
  await show(<SelfManagedDialog />);
} catch {
  console.log("Cancelled");
}

Input dialog example

Here's an advanced example that captures user input from a text field:

function NamePromptDialog() {
  const promise = useImperativePromise<string>();
  const [name, setName] = useState("");
  return (
    <Dialog open onOpenChange={open => !open && promise.reject()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Enter your name</DialogTitle>
          <DialogDescription>Please enter your name.</DialogDescription>
        </DialogHeader>
        <Input
          autoFocus
          value={name}
          onChange={e => setName(e.target.value)}
          placeholder="Your name..."
        />
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">Cancel</Button>
          </DialogClose>
          <Button onClick={() => promise.resolve(name)}>Submit</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

try {
  const name = await show<string>(<NamePromptDialog />);
  console.log(`Hello, ${name}!`);
} catch {
  console.log("Name prompt cancelled");
}

Node update

You can update the rendered node while it's still mounted:

const promise = show(<div>Loading...</div>);

// Later, update the content
promise.update(<div>Done!</div>);

// Close it
promise.resolve();

You can use the render function pattern for dynamic content:

const renderProgress = (value: number) => (
  <div>
    <div>Progress: {value}%</div>
    <progress value={value} max={100} />
  </div>
);

const promise = show(renderProgress(0));

// Later, update with new progress value
promise.update(renderProgress(50));

// Complete the progress
promise.update(renderProgress(100));

// Close it
promise.resolve();

Like show, update can also accept a node factory:

const promise = show(<div>Loading...</div>);

promise.update(promise => (
  <button onClick={() => promise.resolve()}>Done!</button>
));

Advanced features

Checking settlement status

const promise = show(<MyComponent />);

// Check if the node has been closed
if (promise.settled) {
  console.log("Portal is closed");
}

Wrap prop

The ImperativePortal component accepts an optional wrap prop that allows you to wrap active imperative nodes with custom JSX. Useful for basic customization.

import { AnimatePresence } from "motion/react";

function App() {
  return (
    <ImperativePortal
      wrap={nodes => <AnimatePresence>{nodes}</AnimatePresence>}
    />
  );
}

Advanced customization

For more advanced rendering scenarios that require access to individual node keys, promises or elements, use the useImperativePortal hook to create a custom imperative portal component:

import { useImperativePortal } from "imperative-portal";
import { AnimatePresence, motion } from "motion/react";

function CustomImperativePortal() {
  const nodes = useImperativePortal();
  return (
    <AnimatePresence>
      {nodes.length > 0 && (
        <motion.div
          key="backdrop"
          className="fixed inset-0 bg-black/50"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          onClick={() => nodes.forEach(n => n.promise.reject())}
        />
      )}
      {nodes.map((n, i) => (
        <motion.div
          key={n.key}
          initial={{ opacity: 0, scale: 0.8, y: 20 + i * 10 }}
          animate={{ opacity: 1, scale: 1, y: i * 10 }}
          exit={{ opacity: 0, scale: 0.8, y: 20 + i * 10 }}
        >
          {n.node}
        </motion.div>
      ))}
    </AnimatePresence>
  );
}

function App() {
  return (
    <div>
      <CustomImperativePortal />
    </div>
  );
}

Note: When wrapping individual nodes like in this example, make sure to include the key={n.key} prop on your wrapper element for proper React reconciliation.

Multiple portal systems

Use createImperativePortal to create isolated portal systems that can be mounted at different locations:

import { createImperativePortal } from "imperative-portal";

const [ModalPortal, showModal, useModalPortal] = createImperativePortal();
const [ToastPortal, showToast, useToastPortal] = createImperativePortal();

function App() {
  return (
    <div>
      <ModalPortal />
      <ToastPortal />
    </div>
  );
}

showModal(<MyModal />);
showToast(<MyToast />);

API reference

Component ImperativePortal

A React component that renders all active imperative nodes. Typically placed near the root of your app. Takes an optional wrap prop for basic customization.

Function show<T>(node: ReactNodeOrFactory<T>): ImperativePromise<T>

Renders a React node imperatively and returns a promise that tracks and controls the lifecycle of the node. You can pass either a React node directly or a function that receives the imperative promise and returns a React node.

Hook useImperativePortal(): ImperativeNode<any>[]

A React hook that returns the array of all active imperative nodes in the portal system. Each node is an object containing key, node (the React node), and promise properties. Useful for advanced customization.

Hook useImperativePromise<T>(): ImperativePromise<T>

A React hook that provides access to the imperative portal promise from within a rendered imperative node. Must be used within components that are rendered via the show function. Enables self-contained imperative components that can control their own lifecycle.

Function createImperativePortal()

Creates a new imperative portal system with its own store. Returns an [ImperativePortal, show, useImperativePortal] tuple.

Types

ImperativePromise<T>

Extends Promise<T> with additional properties:

  • settled: boolean - Whether the promise has been resolved or rejected.
  • resolve(value: T): void - Resolves the promise, unmounts the node.
  • reject(reason?: any): void - Rejects the promise, unmounts the node.
  • update(node: ReactNodeOrFactory<T>): void - Updates the node. You can pass either a React node directly or a function that receives the imperative promise and returns a React node.

ImperativeNode<T>

  • key: string - A unique identifier for the node, used for React reconciliation.
  • node: ReactNode - The React node.
  • promise: ImperativePromise<T> - The promise that controls the node's lifecycle.

ReactNodeOrFactory<T>

A React node or a function that receives an imperative promise and returns a React node:

type ReactNodeOrFactory<T> =
  | ReactNode
  | ((promise: ImperativePromise<T>) => ReactNode);

About

Render React nodes imperatively

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published