DEV Community

sxq
sxq

Posted on

Flow Render: Render UI Components Like Calling an Async Functions

Flow Render provides a Promise-based approach to rendering UI. It lets you render components as if calling async functions, await their results, and resume the same async flow.

It consolidates interaction logic scattered across state, callbacks, and component hierarchies into linear async/await control flow. Business workflows and UI components each remain highly cohesive, while Promise results establish a clear boundary between them, reducing coupling and keeping complex interactions focused, readable, and maintainable.

const confirmed = await render(ConfirmDialog, {
  title: 'Delete your workspace?'
})

if (!confirmed) return

await deleteWorkspace()
Enter fullscreen mode Exit fullscreen mode

Use Flow Render when code genuinely needs to pause for a person:

  • Confirming a destructive action
  • Completing a form in a dialog
  • Choosing an account, plan, or destination
  • Accepting a permission request
  • Moving through a multi-step workflow
  • Recovering from an interrupted or failed operation

Flow Render focuses on interaction flows. Your screens, local state, server state, and routing stay where they already belong.

Contents


One minute demo

The complete React setup is two pieces: mount a Viewport once, then await a component from an event handler.

1. Install

npm install @flow-render/react
Enter fullscreen mode Exit fullscreen mode

2. Mount a viewport

Place Viewport inside your application providers, near the root of the tree.

import { Viewport } from '@flow-render/react'

export function App() {
  return (
    <>
      <AppProviders>
        <Routes />
        <Viewport />
      </AppProviders>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

3. Make a dialog resolve a value

import type { PromiseResolvers } from '@flow-render/react'

type ConfirmDialogProps = PromiseResolvers<boolean> & {
  title: "string"
  description?: string
}

export function ConfirmDialog({
  title,
  description,
  resolve,
  reject,
}: ConfirmDialogProps) {
  return (
    <dialog open aria-labelledby="confirm-title">
      <h2 id="confirm-title">{title}</h2>
      {description && <p>{description}</p>}

      <footer>
        <button type="button" onClick={() => resolve(false)}>
          Cancel
        </button>
        <button type="button" onClick={() => resolve(true)}>
          Confirm
        </button>
        <button type="button" onClick={() => reject(new Error('Dismissed'))}>
          Dismiss
        </button>
      </footer>
    </dialog>
  )
}
Enter fullscreen mode Exit fullscreen mode

4. Await it

import { render } from '@flow-render/react'

async function handleDelete() {
  const confirmed = await render(ConfirmDialog, {
    title: "'Delete this project?',"
    description: 'This cannot be undone.'
  })

  if (!confirmed) return

  await deleteProject()
}
Enter fullscreen mode Exit fullscreen mode

That is the whole idea. render() adds the component to the mounted viewport and returns a Promise. Calling resolve(value) settles that Promise with value; calling reject(reason) rejects it. When the Promise settles, Flow Render removes the component.


The missing await

JavaScript already gives asynchronous operations a direct, readable shape:

const response = await fetch('/api/projects')
const project = await response.json()

await animation.finished
await sleep(300)
Enter fullscreen mode Exit fullscreen mode

But UI is often expressed as disconnected state transitions:

setConfirmDialogOpen(true)
Enter fullscreen mode Exit fullscreen mode

That line does not say what happens next. The continuation lives elsewhere: in a callback, an effect, a prop chain, a reducer branch, or a state machine transition.

User interaction is asynchronous too. A person sees a UI, decides, and eventually produces one of several outcomes. Flow Render lets that interaction use the same control-flow primitive as the rest of modern JavaScript:

const answer = await askUser()
Enter fullscreen mode Exit fullscreen mode
const confirmed = await render(ConfirmDialog, { title: 'Ship this release?' })

if (!confirmed) return

await publishRelease()
Enter fullscreen mode Exit fullscreen mode

This is not a claim that every component should be awaited. A persistent sidebar, inline editor, sortable table, or page-level filter is usually ordinary stateful UI. The useful boundary is narrower:

Await UI when the next step of an async flow depends on a bounded user interaction finishing.


The problem

Consider a typical destructive action. The happy path is tiny: ask, wait, delete. The state-driven version must distribute that path over several places.

function ProjectActions({ projectId }: { projectId: string }) {
  const [confirmOpen, setConfirmOpen] = useState(false)
  const [isDeleting, setIsDeleting] = useState(false)
  const [error, setError] = useState<Error | null>(null)

  async function handleConfirm() {
    setIsDeleting(true)
    setError(null)

    try {
      await deleteProject(projectId)
      setConfirmOpen(false)
    } catch (error) {
      setError(error as Error)
    } finally {
      setIsDeleting(false)
    }
  }

  return (
    <>
      <button onClick={() => setConfirmOpen(true)}>Delete project</button>

      <ConfirmDialog
        open={confirmOpen}
        busy={isDeleting}
        error={error}
        onCancel={() => setConfirmOpen(false)}
        onConfirm={handleConfirm}
      />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

There is nothing inherently wrong with this code. React state is the right tool for long-lived, interactive screens. The friction appears when an event starts a short-lived interaction with a result and then needs to continue. The original story is split across state declarations, event handlers, props, and cleanup.

Flow Render keeps the orchestration together while leaving the dialog itself a normal component:

async function handleDelete(projectId: string) {
  const confirmed = await render(ConfirmDialog, {
    title: 'Delete project?',
    description: 'All project data will be permanently removed.'
  })

  if (!confirmed) return

  await deleteProject(projectId)
}
Enter fullscreen mode Exit fullscreen mode

The difference becomes more meaningful as a flow grows. A sequence of dialog, form, permission check, and network request reads from top to bottom rather than from state transition to state transition.


The Flow Render model

Flow Render has three moving parts.

Part Responsibility
render(Component, props) Creates one short-lived render task and returns its Promise.
Viewport Renders pending tasks at a chosen place in the application tree.
resolve / reject Props injected into a Promise-aware component so it can finish the task.
sequenceDiagram
  participant H as Event handler
  participant R as render()
  participant V as Viewport
  participant U as User

  H->>R: render(ConfirmDialog, props)
  R->>V: add ConfirmDialog
  V->>U: show dialog
  U->>V: click Confirm
  V->>R: resolve(true)
  R-->>H: Promise resolves with true
  H->>H: continue flow
Enter fullscreen mode Exit fullscreen mode

The component is still a framework component. It can use your design system, hooks, context, CSS, accessibility primitives, and animation library. Flow Render only owns its temporary mounting and the Promise boundary.

Resolve values, not side effects

A Promise-aware UI component should express its outcome through resolve or reject.

type PlanPickerProps = PromiseResolvers<{ planId: string }> & {
  plans: Array<{ id: string; name: string }>
}

function PlanPicker({ plans, resolve, reject }: PlanPickerProps) {
  return (
    <section aria-label="Choose a plan">
      {plans.map((plan) => (
        <button key={plan.id} onClick={() => resolve({ planId: plan.id })}>
          Choose {plan.name}
        </button>
      ))}
      <button onClick={() => reject(new Error('No plan selected'))}>
        Close
      </button>
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

The caller gets a typed value and owns what happens next:

const { planId } = await render(PlanPicker, { plans })
await changeSubscription(planId)
Enter fullscreen mode Exit fullscreen mode

This makes transient components easier to reuse: the picker chooses a plan; it does not also know how every caller updates billing, routing, analytics, or notifications.

Two integration styles

Executor mode is the recommended default. Declare resolve and reject in the component props. Flow Render injects them when the component is rendered.

type NameEditorProps = PromiseResolvers<string> & {
  initialValue: string
}

function NameEditor({ initialValue, resolve, reject }: NameEditorProps) {
  const [name, setName] = useState(initialValue)

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        resolve(name.trim())
      }}
    >
      <label>
        Project name
        <input value={name} onChange={(event) => setName(event.target.value)} />
      </label>
      <button type="button" onClick={() => reject(new Error('Editing cancelled'))}>
        Cancel
      </button>
      <button type="submit">Save</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Adapter mode is for an existing component whose callback props you cannot or do not want to change.

type LegacyConfirmProps = {
  open: boolean
  title: string
  onCancel: () => void
  onConfirm: () => void
}

function LegacyConfirmDialog(props: LegacyConfirmProps) {
  return (
    <dialog open={props.open}>
      <p>{props.title}</p>
      <button onClick={props.onCancel}>Cancel</button>
      <button onClick={props.onConfirm}>Confirm</button>
    </dialog>
  )
}

const confirmed = await render<LegacyConfirmProps, boolean>(
  LegacyConfirmDialog,
  (resolve) => ({
    open: true,
    title: 'Archive this project?',
    onCancel: () => resolve(false),
    onConfirm: () => resolve(true)
  })
)
Enter fullscreen mode Exit fullscreen mode

Adapter mode is deliberately a bridge. It lets Flow Render work with a component library or legacy component while preserving its existing API.


Before and after

A form modal

Without a Promise boundary, a parent often needs state for both visibility and the eventual form value.

const [editOpen, setEditOpen] = useState(false)
const [pendingProfile, setPendingProfile] = useState<Profile | null>(null)

function handleEdit() {
  setEditOpen(true)
}

function handleProfileSaved(profile: Profile) {
  setEditOpen(false)
  setPendingProfile(profile)
}

useEffect(() => {
  if (!pendingProfile) return

  void saveProfile(pendingProfile)
  setPendingProfile(null)
}, [pendingProfile])
Enter fullscreen mode Exit fullscreen mode

With Flow Render, the form result is simply a value.

async function handleEdit() {
  const profile = await render(ProfileEditor, { initialProfile: currentProfile })
  await saveProfile(profile)
}
Enter fullscreen mode Exit fullscreen mode

A guarded navigation

State-driven code usually needs a destination stored separately while a dialog is open.

const [nextPath, setNextPath] = useState<string | null>(null)

function requestNavigation(path: string) {
  if (!isDirty) {
    navigate(path)
    return
  }

  setNextPath(path)
  setLeaveDialogOpen(true)
}

function confirmNavigation() {
  setLeaveDialogOpen(false)
  if (nextPath) navigate(nextPath)
  setNextPath(null)
}
Enter fullscreen mode Exit fullscreen mode

With Flow Render, the destination stays local to the flow that needs it.

async function requestNavigation(path: string) {
  if (!isDirty) {
    navigate(path)
    return
  }

  const shouldLeave = await render(DiscardChangesDialog)

  if (shouldLeave) navigate(path)
}
Enter fullscreen mode Exit fullscreen mode

A short workflow

The linear version makes the product decision points visible.

async function inviteMember() {
  const member = await render(MemberFormDialog)

  const role = await render(RolePickerDialog, {
    email: member.email
  })

  const confirmed = await render(ConfirmDialog, {
    title: `Invite ${member.email} as ${role.name}?`
  })

  if (!confirmed) return

  await sendInvite({ ...member, roleId: role.id })
}
Enter fullscreen mode Exit fullscreen mode

No reducer state is needed merely to remember which step comes next. Each component remains independently testable and responsible for one interaction.


Quick start (React)

This guide uses React because it is the most common starting point. The same model is available for Vue, Preact, Svelte, and Solid.

Install the package

npm install @flow-render/react
Enter fullscreen mode Exit fullscreen mode

Or with another package manager:

pnpm add @flow-render/react
yarn add @flow-render/react
bun add @flow-render/react
Enter fullscreen mode Exit fullscreen mode

Mount Viewport once

The default render function uses the default Viewport exported by the package. Mount that viewport in a part of your tree that remains available for the interactions you want to run.

import { Viewport } from '@flow-render/react'

export function Root() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <App />
        <Viewport />
      </AuthProvider>
    </ThemeProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Do not place the viewport outside providers that your dialogs rely on. A component rendered by Flow Render appears under Viewport, so it receives the context available at that point in the tree.

Build one awaitable component

Add PromiseResolvers<Value> to the props. Its resolve callback accepts the value returned to the caller; reject rejects the pending Promise.

import { useState } from 'react'
import type { PromiseResolvers } from '@flow-render/react'

type QuantityDialogProps = PromiseResolvers<number> & {
  initialQuantity: number
}

export function QuantityDialog({
  initialQuantity,
  resolve,
  reject,
}: QuantityDialogProps) {
  const [quantity, setQuantity] = useState(initialQuantity)

  return (
    <dialog open>
      <form
        onSubmit={(event) => {
          event.preventDefault()
          resolve(quantity)
        }}
      >
        <label>
          Quantity
          <input
            type="number"
            min="1"
            value={quantity}
            onChange={(event) => setQuantity(Number(event.target.value))}
          />
        </label>
        <button type="button" onClick={() => reject(new Error('Quantity not selected'))}>
          Cancel
        </button>
        <button type="submit">Continue</button>
      </form>
    </dialog>
  )
}
Enter fullscreen mode Exit fullscreen mode

Await it from an event handler

import { render } from '@flow-render/react'

async function handleAddToCart() {
  const quantity = await render(QuantityDialog, {
    initialQuantity: 1
  })

  await addToCart({ sku: 'starter-kit', quantity })
}
Enter fullscreen mode Exit fullscreen mode

render() is most natural in a function already allowed to be asynchronous: a button handler, mutation callback, route guard, command handler, or an application service called from the client.

Handle dismissal intentionally

There are two good conventions. Pick one per component family and document it for your team.

Resolve a neutral value when cancellation is an expected product choice.

type ConfirmDialogProps = PromiseResolvers<boolean>

function ConfirmDialog({ resolve }: ConfirmDialogProps) {
  return (
    <dialog open>
      <button onClick={() => resolve(false)}>Cancel</button>
      <button onClick={() => resolve(true)}>Confirm</button>
    </dialog>
  )
}
Enter fullscreen mode Exit fullscreen mode

Reject when a caller must distinguish a completed interaction from dismissal or interruption.

try {
  const profile = await render(ProfileEditor)
  await saveProfile(profile)
} catch (error) {
  reportDismissal(error)
}
Enter fullscreen mode Exit fullscreen mode

Do not reject simply to model a normal “No” answer. A value such as false, undefined, or a discriminated result often produces clearer caller code.


Real-world examples

The examples below are intentionally small. They show where the Promise boundary belongs; the components can use any UI library or visual style.

Confirm a destructive action

async function handleRemoveMember(member: Member) {
  const confirmed = await render(ConfirmDialog, {
    title: `Remove ${member.name}?`,
    description: 'They will lose access immediately.'
  })

  if (!confirmed) return

  await removeMember(member.id)
  toast.success(`${member.name} was removed`)
}
Enter fullscreen mode Exit fullscreen mode

Create a resource in a modal

async function handleCreateProject() {
  const draft = await render(ProjectFormDialog, {
    initialValues: {
      name: '',
      visibility: 'private'
    }
  })

  const project = await createProject(draft)
  navigate(`/projects/${project.id}`)
}
Enter fullscreen mode Exit fullscreen mode

The form owns validation and editing state. The caller owns the network request and navigation because those are the next steps in the flow.

Require login before continuing

async function handleExport() {
  if (!session.user) {
    const authenticated = await render(LoginDialog, {
      redirectTo: location.pathname
    })

    if (!authenticated) return
  }

  await exportReport()
}
Enter fullscreen mode Exit fullscreen mode

LoginDialog can resolve a boolean, a user object, or an auth result. Return the smallest value the caller needs.

Ask for a permission

async function enableNotifications() {
  const approved = await render(NotificationPermissionDialog)

  if (!approved) return

  const permission = await Notification.requestPermission()

  if (permission === 'granted') {
    await subscribeToNotifications()
  }
}
Enter fullscreen mode Exit fullscreen mode

The product explanation is UI; the browser permission request is an asynchronous platform operation. They compose naturally.

Select a payment method, then pay

async function checkout(invoice: Invoice) {
  const paymentMethod = await render(PaymentMethodPicker, {
    methods: invoice.availablePaymentMethods
  })

  const confirmed = await render(ConfirmPaymentDialog, {
    amount: invoice.total,
    paymentMethod
  })

  if (!confirmed) return

  await payInvoice({ invoiceId: invoice.id, paymentMethodId: paymentMethod.id })
}
Enter fullscreen mode Exit fullscreen mode

Run a multi-step wizard

async function createWorkspace() {
  const details = await render(WorkspaceDetailsStep)
  const members = await render(InviteMembersStep, { workspaceName: details.name })
  const plan = await render(PlanPicker)

  const workspace = await createWorkspace({
    ...details,
    memberEmails: members.map((member) => member.email),
    planId: plan.id
  })

  navigate(`/workspaces/${workspace.id}`)
}
Enter fullscreen mode Exit fullscreen mode

For a tightly coupled wizard with a shared back button and persistent draft state, one stateful wizard component can be a better fit. Use separate awaited steps when each interaction has a distinct, reusable result and the orchestration benefits from being visible in one function.

Guard unsaved changes

async function requestCloseEditor() {
  if (!editor.isDirty) {
    closeEditor()
    return
  }

  const action = await render(UnsavedChangesDialog)

  if (action === 'save') {
    await editor.save()
  }

  if (action === 'discard' || action === 'save') {
    closeEditor()
  }
}
Enter fullscreen mode Exit fullscreen mode

A discriminated result makes multiple choices explicit.

type UnsavedAction = 'save' | 'discard' | 'stay'
type UnsavedChangesDialogProps = PromiseResolvers<UnsavedAction>
Enter fullscreen mode Exit fullscreen mode

Retry a failed operation

async function publishWithRecovery(post: DraftPost) {
  try {
    await publishPost(post)
  } catch (error) {
    const nextAction = await render(PublishFailedDialog, { error })

    if (nextAction === 'retry') {
      await publishPost(post)
    }

    if (nextAction === 'save-draft') {
      await saveDraft(post)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Pick a destination for an upload

async function handleUpload(file: File) {
  const folder = await render(FolderPickerDialog, {
    initialFolderId: currentFolder.id
  })

  await uploadFile({ file, folderId: folder.id })
}
Enter fullscreen mode Exit fullscreen mode

Finish onboarding

async function completeOnboarding() {
  const goal = await render(GoalPicker)
  const preferences = await render(NotificationPreferences)

  await updateOnboarding({ goal, preferences })
  navigate('/home')
}
Enter fullscreen mode Exit fullscreen mode

The code resembles the product journey because it is the product journey.


Choosing a renderer

Flow Render provides three renderer scopes. Choose the lifetime that matches the UI being launched.

Renderer Create it with Best for
Default renderer render and Viewport exports Application-wide dialogs, pickers, and flows.
Local renderer useRenderer() UI that should disappear with one page, panel, or feature.
Custom renderer createRenderer() Component libraries or subsystems that own their own rendering entry point.

Default renderer

The default renderer is ready to use after its matching Viewport is mounted.

import { render, Viewport } from '@flow-render/react'

export function App() {
  return (
    <>
      <Routes />
      <Viewport />
    </>
  )
}

export async function openGlobalConfirm() {
  return render(ConfirmDialog, { title: 'Continue?' })
}
Enter fullscreen mode Exit fullscreen mode

Use it for application-level interactions that should survive a particular page component unmounting.

Local renderer

useRenderer() gives a component a renderer bound to its own lifetime.

import { useRenderer } from '@flow-render/react'

function BillingPanel() {
  const [render, Viewport] = useRenderer()

  async function editInvoice() {
    const update = await render(InvoiceEditorDialog)
    await updateInvoice(update)
  }

  return (
    <section>
      <button onClick={editInvoice}>Edit invoice</button>
      <Viewport />
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

When the local viewport unmounts, unfinished tasks in that renderer are rejected with a cancellation error. This prevents temporary UI from leaking after its owner has gone away.

import { isCancelError, useRenderer } from '@flow-render/react'

function SearchPanel() {
  const [render, Viewport] = useRenderer()

  async function chooseFilter() {
    try {
      const filter = await render(FilterPicker)
      applyFilter(filter)
    } catch (error) {
      if (isCancelError(error)) return
      throw error
    }
  }

  return <><button onClick={chooseFilter}>Filter</button><Viewport /></>
}
Enter fullscreen mode Exit fullscreen mode

Custom renderer

createRenderer() creates a separate render function and Viewport pair. This is useful when a library wants to expose a focused API without coupling consumers to the app-wide renderer.

import { createRenderer } from '@flow-render/react'

const [renderBillingUi, BillingViewport] = createRenderer()

export function BillingProvider({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <BillingViewport />
    </>
  )
}

export function openUpgradeDialog() {
  return renderBillingUi(UpgradeDialog)
}
Enter fullscreen mode Exit fullscreen mode

This keeps the library's implementation detail behind its own openUpgradeDialog() function.


API reference

The public API is intentionally small. The React package exports the following primitives:

import {
  createRenderer,
  isCancelError,
  render,
  useRenderer,
  Viewport,
  type PromiseResolvers,
  type RenderOptions
} from '@flow-render/react'
Enter fullscreen mode Exit fullscreen mode

render

render(Component, propsOrAdapter?, options?) => Promise<Value>
Enter fullscreen mode Exit fullscreen mode

Renders Component into the associated viewport and returns a Promise for its result.

const name = await render(NameEditor, {
  initialValue: 'Untitled project'
})
Enter fullscreen mode Exit fullscreen mode

For a Promise-aware component, the plain object contains every prop except resolve and reject; Flow Render supplies those callbacks.

type NameEditorProps = PromiseResolvers<string> & {
  initialValue: string
}
Enter fullscreen mode Exit fullscreen mode

When a component does not expose resolver props, pass an adapter instead.

const option = await render<ExistingPickerProps, Option>(ExistingPicker, (resolve, reject) => ({
  options,
  onSelect: resolve,
  onClose: () => reject(new Error('Picker closed'))
}))
Enter fullscreen mode Exit fullscreen mode

PromiseResolvers<Value>

interface PromiseResolvers<Value = unknown> {
  readonly resolve: (value: Value) => void
  readonly reject: (reason?: unknown) => void
}
Enter fullscreen mode Exit fullscreen mode

Add this type to component props to have Value inferred at the call site.

type ColorPickerProps = PromiseResolvers<{ hex: string }> & {
  initialHex: string
}

async function chooseColor() {
  const color = await render(ColorPicker, { initialHex: '#0ea5e9' })
  // color is { hex: string }
}
Enter fullscreen mode Exit fullscreen mode

For a component that only confirms completion, use PromiseResolvers<void>.

type NoticeProps = PromiseResolvers<void> & { message: string }

function Notice({ message, resolve }: NoticeProps) {
  return <button onClick={() => resolve()}>{message}</button>
}
Enter fullscreen mode Exit fullscreen mode

Viewport

Viewport is the mount point for the default renderer. Render it in your framework tree before calling the default render function.

import { Viewport } from '@flow-render/react'

function RootLayout() {
  return (
    <Providers>
      <App />
      <Viewport />
    </Providers>
  )
}
Enter fullscreen mode Exit fullscreen mode

It renders the pending components directly. It does not impose a portal, overlay, or visual system. That is deliberate: your dialog component decides how it looks and behaves.

useRenderer

useRenderer() => [render, Viewport]
Enter fullscreen mode Exit fullscreen mode

Creates one renderer pair for the current component. Use it when temporary UI should be owned by a local feature.

const [render, Viewport] = useRenderer()
Enter fullscreen mode Exit fullscreen mode

See Local renderer for a complete example.

createRenderer

createRenderer() => [render, Viewport]
Enter fullscreen mode Exit fullscreen mode

Creates an independent renderer pair outside a component. Use it to encapsulate a subsystem or reusable package.

const [renderNotifications, NotificationsViewport] = createRenderer()
Enter fullscreen mode Exit fullscreen mode

The renderNotifications function only renders into NotificationsViewport.

RenderOptions

interface RenderOptions {
  exitDelay?: number
}
Enter fullscreen mode Exit fullscreen mode

Pass exitDelay in milliseconds when a component needs to remain mounted briefly after it resolves or rejects so its exit animation can run.

await render(FadeOutDialog, null, { exitDelay: 180 })
Enter fullscreen mode Exit fullscreen mode

The Promise settles immediately. exitDelay delays DOM removal only; it does not delay the caller's next line of code.

isCancelError

isCancelError(error) => boolean
Enter fullscreen mode Exit fullscreen mode

When a viewport unmounts with pending render tasks, Flow Render rejects those tasks with a cancellation error. Check it when local renderer cancellation is an expected lifecycle event.

try {
  await render(ShortLivedDialog)
} catch (error) {
  if (isCancelError(error)) return

  throw error
}
Enter fullscreen mode Exit fullscreen mode

isCancelError is for lifecycle cancellation. A component calling reject() with its own error preserves that error.


Design philosophy

Flow control is not screen state

React and other UI frameworks excel at deriving UI from state. Flow Render does not try to replace that model. It adds a convenient representation for a different concern: a short-lived interaction whose result determines what an asynchronous operation does next.

Use state for:

  • Persistent page data
  • Inputs that change while a screen remains mounted
  • Visual toggles and layout
  • Server cache and mutation status
  • Long-running processes shown over time

Use an awaited interaction for:

  • A question with a bounded answer
  • A modal form that returns one result
  • A picker that returns one selected value
  • A gate in a user-initiated workflow
  • A short sequence whose next step depends on the prior answer

The two models compose. An awaited ProfileEditor can have rich local state, validation, async autocomplete, and its own child components. The parent just receives the completed profile.

Components remain normal components

Flow Render does not require a special component base class, code generation, global singleton outside your framework, or a new templating syntax. A component receives props and renders UI. The only optional convention is accepting resolve and reject props.

That matters because your existing UI can participate through adapter mode. Your component library stays useful; you add a Promise boundary where it helps orchestration.

The caller owns the next step

A dialog should be able to answer “what did the user choose?” without also knowing whether the caller will navigate, call an API, invalidate a cache, record analytics, or open another dialog. Those decisions belong to the flow owner.

const decision = await render(AccessRequestDialog, { request })

if (decision === 'approve') {
  await approveAccess(request.id)
  analytics.track('access_approved')
}
Enter fullscreen mode Exit fullscreen mode

This keeps UI units reusable and keeps product logic readable in one place.

Cancellation is lifecycle, not an afterthought

Temporary UI must not outlive the tree responsible for it. When a local or custom viewport unmounts, unfinished tasks are rejected. A caller can explicitly handle that expected case with isCancelError.


Comparison

Flow Render is not a replacement for every common UI architecture. It is one small tool for a specific shape of problem.

Approach Strong fit Trade-off for short-lived UI flows
Local component state Persistent or colocated UI The continuation can spread across handlers, effects, and prop callbacks.
Callback props Small parent-child interactions Nested or cross-cutting flows become difficult to follow.
Global modal store A shared dialog shell Flow state often moves into action names and callback registries.
State machine Complex, long-lived state with explicit transitions Can be more ceremony than a bounded “ask, wait, continue” interaction needs.
Flow Render User-initiated UI that returns one result Requires a mounted viewport and deliberate error/cancellation handling.

Flow Render and React state

Use React state inside a dialog. Use Flow Render at the boundary between the dialog's eventual outcome and the caller's continuation.

function InviteDialog({ resolve }: PromiseResolvers<Invite>) {
  const [email, setEmail] = useState('')
  const [role, setRole] = useState<Role>('member')

  return (
    <form onSubmit={(event) => {
      event.preventDefault()
      resolve({ email, role })
    }}>
      {/* ordinary controlled form fields */}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Flow Render and state machines

State machines are a strong choice when a process is long-lived, concurrent, visualized as a state graph, or must remain resumable across navigation. A payment lifecycle, media upload queue, or editor with background synchronization may deserve an explicit machine.

For a short user-triggered interaction, this can be enough:

const confirmed = await render(ConfirmDialog)
if (confirmed) await submit()
Enter fullscreen mode Exit fullscreen mode

Start with the smallest model that makes the flow understandable. Introduce a state machine when the domain has genuine state-machine complexity, not just because a dialog has two buttons.

Why is this not built into React?

React deliberately models UI as a function of state. That is a good foundation. Flow Render is a tiny complementary layer that turns a temporary render task into a Promise so application code can express sequential interactions directly.

It does not replace React's rendering model, component composition, transitions, Suspense, routing, or state libraries. It gives a focused answer to a focused question: how should code wait for a UI interaction to finish?


Best practices

Keep flows close to the initiating action

Put the orchestration in the event handler, mutation callback, command, or service that owns the intent.

async function handleTransfer() {
  const recipient = await render(RecipientPicker)
  const amount = await render(AmountDialog, { recipient })
  await transfer({ recipientId: recipient.id, amount })
}
Enter fullscreen mode Exit fullscreen mode

Avoid storing intermediate flow values in unrelated global state just because multiple components participate.

Return useful, small values

Prefer a value that states the interaction result.

type DeleteResult = 'confirm' | 'cancel'
type DeleteDialogProps = PromiseResolvers<DeleteResult>
Enter fullscreen mode Exit fullscreen mode

Avoid returning UI objects, setters, or callbacks when a plain product value will do.

Make normal cancellation a value when possible

For a confirmation dialog, false is often simpler than rejecting:

const confirmed = await render(ConfirmDialog)
if (!confirmed) return
Enter fullscreen mode Exit fullscreen mode

Reserve rejection for interruption, failed validation strategies that escape the component, or a dismissal contract that the caller needs to distinguish.

Keep the component responsible for accessibility

Flow Render controls mounting, not focus management or dialog semantics. Use the accessible dialog, focus trap, escape-key behavior, labels, and design-system primitives appropriate to your application.

Put Viewport under the right providers

The viewport determines which context temporary components can read.

<QueryClientProvider client={queryClient}>
  <ThemeProvider>
    <App />
    <Viewport />
  </ThemeProvider>
</QueryClientProvider>
Enter fullscreen mode Exit fullscreen mode

Do not await from render

Start interactions from an event or an effect with a well-defined lifecycle, not while React is rendering.

function DeleteButton() {
  async function handleClick() {
    const confirmed = await render(ConfirmDialog)
    if (confirmed) await deleteCurrentProject()
  }

  return <button onClick={handleClick}>Delete</button>
}
Enter fullscreen mode Exit fullscreen mode

Handle local renderer cancellation

If a local viewport can disappear before the interaction completes, catch cancellation at the boundary where that is expected.

try {
  const choice = await localRender(ChoiceDialog)
  applyChoice(choice)
} catch (error) {
  if (!isCancelError(error)) throw error
}
Enter fullscreen mode Exit fullscreen mode

Advanced usage

Preserve an exit animation

When a component resolves, the caller should normally continue immediately. If the component has an exit animation, keep it mounted for the animation duration with exitDelay.

async function handleArchive() {
  const confirmed = await render(ArchiveDialog, null, {
    exitDelay: 220
  })

  if (confirmed) await archiveProject()
}
Enter fullscreen mode Exit fullscreen mode

The dialog's CSS or animation library controls the animation itself. Flow Render only schedules its later removal.

Wrap a component-library dialog

Adapter mode is useful for component libraries that expose callbacks instead of resolver props.

type DesignSystemDialogProps = {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAccept: () => void
}

const accepted = await render<DesignSystemDialogProps, boolean>(
  DesignSystemConfirm,
  (resolve) => ({
    open: true,
    onOpenChange: (open) => {
      if (!open) resolve(false)
    },
    onAccept: () => resolve(true)
  })
)
Enter fullscreen mode Exit fullscreen mode

Make sure only one callback represents each outcome. If onOpenChange(false) and onAccept() can both run, have the component or adapter normalize that behavior just as you would in ordinary component usage.

Create a domain-specific UI service

A small wrapper can give callers a stable, meaningful API while hiding the raw component.

export async function confirmProjectDeletion(project: Project) {
  return render(ConfirmDialog, {
    title: `Delete ${project.name}?`,
    description: 'This cannot be undone.'
  })
}

async function handleDelete(project: Project) {
  if (await confirmProjectDeletion(project)) {
    await deleteProject(project.id)
  }
}
Enter fullscreen mode Exit fullscreen mode

The wrapper is valuable when it eliminates repeated product language or enforces a shared interaction contract. Do not create a generic abstraction merely to hide one render() call.

Compose flows with ordinary async functions

An awaited UI flow is just an async function, so it composes with normal client-side services.

async function collectAndCreateProject() {
  const details = await render(ProjectDetailsDialog)
  const template = await render(TemplatePicker)

  return createProject({
    ...details,
    templateId: template.id
  })
}

async function handleNewProject() {
  const project = await collectAndCreateProject()
  navigate(`/projects/${project.id}`)
}
Enter fullscreen mode Exit fullscreen mode

Isolate a subsystem

Use a custom renderer when a subsystem needs its own lifecycle and public entry points.

const [renderSupport, SupportViewport] = createRenderer()

export function SupportProvider({ children }: { children: React.ReactNode }) {
  return <>{children}<SupportViewport /></>
}

export function openContactSupport() {
  return renderSupport(ContactSupportDialog)
}
Enter fullscreen mode Exit fullscreen mode

This pattern is especially useful in shared product modules: consumers mount one provider and call a narrow function, while the module decides which UI it renders.


Common mistakes

Calling the default renderer without mounting its viewport

The default render function needs the matching default Viewport mounted in the application tree.

// Required somewhere in the active client tree.
<Viewport />
Enter fullscreen mode Exit fullscreen mode

For a local or custom renderer, render the viewport returned by that same renderer pair.

Treating every UI change as a flow

This is not a replacement for useState.

// Good: a bounded interaction produces one value.
const color = await render(ColorPicker)

// Usually not a Flow Render task: a persistent screen preference.
setSidebarCollapsed(true)
Enter fullscreen mode Exit fullscreen mode

Hiding important errors

If a component rejects for a meaningful failure, let the caller handle or rethrow it. Only swallow cancellation errors that are expected because a local viewport unmounted.

try {
  await render(RequiredForm)
} catch (error) {
  if (isCancelError(error)) return
  throw error
}
Enter fullscreen mode Exit fullscreen mode

Letting a component perform every next step

If a modal both chooses a value, calls an API, navigates, invalidates caches, and dispatches analytics, it is hard to reuse. Let it return the choice. Let the caller continue the business flow.

Using exitDelay as an async wait

exitDelay keeps the node mounted after settlement. It does not block await render().

const result = await render(Dialog, null, { exitDelay: 300 })
// This runs immediately after resolve/reject, while the dialog may animate out.
Enter fullscreen mode Exit fullscreen mode

FAQ

Does Flow Render replace React state?

No. React state remains the right model for persistent, reactive UI. Flow Render is for short-lived interactions that return a result to an async flow.

Does Flow Render replace a state machine?

No. State machines are excellent for long-lived, concurrent, or explicitly modeled domain processes. Flow Render is lighter-weight for bounded UI interactions where the next step can be expressed as sequential code.

Can I use my existing dialog component?

Yes. Use adapter mode to map its callbacks to resolve and reject.

await render<ExistingDialogProps, Result>(ExistingDialog, (resolve, reject) => ({
  ...existingProps,
  onComplete: resolve,
  onDismiss: () => reject(new Error('Dismissed'))
}))
Enter fullscreen mode Exit fullscreen mode

Does it work with context and providers?

Yes. Render Viewport under the providers your temporary components need. Components rendered through that viewport receive the context available at that location.

What happens when a viewport unmounts?

Pending tasks for that renderer are rejected with a cancellation error and removed. Use isCancelError when that lifecycle event is expected.

Can a dialog return a typed object?

Yes. The result type is inferred from PromiseResolvers<Value>.

type AddressDialogProps = PromiseResolvers<Address>

const address = await render(AddressDialog)
Enter fullscreen mode Exit fullscreen mode

Can I render more than dialogs?

Yes. A component can be a sheet, picker, full-screen step, inline panel, or any other UI that has a bounded completion result. The viewport decides where it mounts; the component decides its presentation.

Why use rejection at all?

Use rejection when the caller should distinguish normal completion from dismissal, interruption, or an error. For a simple yes/no question, resolving false is often easier.

Is exitDelay required for animations?

Only when the component must remain mounted after it resolves or rejects so an exit animation can finish. Without it, Flow Render removes the component at settlement.


Framework support

Flow Render uses the same Promise-based model across five UI frameworks. This README uses React for the main examples; each package includes framework-specific setup and examples.

Install the package for your framework:

npm install @flow-render/react
npm install @flow-render/vue
npm install @flow-render/preact
npm install @flow-render/svelte
npm install @flow-render/solid
Enter fullscreen mode Exit fullscreen mode

The public concepts are the same: mount a viewport, render a component, await its result. Follow the package guide for framework-specific component syntax and setup.


When Flow Render is a good fit

Use it when all of these are true:

  1. A user action starts a flow.
  2. The flow needs a bounded UI interaction.
  3. That interaction has a result, cancellation, or failure.
  4. The next line of code depends on that outcome.
const destination = await render(DestinationPicker)
await moveItems({ itemIds, destinationId: destination.id })
Enter fullscreen mode Exit fullscreen mode

Consider another pattern when the UI is persistent, heavily concurrent, resumable across sessions, or needs a durable model of many independent transitions.

Github

Flow Render

Top comments (0)