imperative-portal
v1.5.0
Published
Render react nodes imperatively
Downloads
837
Maintainers
Readme
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
- Setup
- Basic example
- Confirm dialog example
- useImperativePromise hook
- Input dialog example
- Node update
- Advanced features
- Multiple portal systems
- API reference
- Types
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-portalImport 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 passedshow 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);