Renderables vs Constructs

OpenTUI provides two ways to build your UI: the imperative Renderable API and the declarative Construct API. Both approaches have different tradeoffs.

Imperative (Renderables)

You create Renderable instances with a RenderContext and compose them using add(). You mutate state and behavior directly on instances through setters and methods.

import { BoxRenderable, TextRenderable, InputRenderable, createCliRenderer, type RenderContext } from "@opentui/core"

const renderer = await createCliRenderer()

const loginForm = new BoxRenderable(renderer, {
  id: "login-form",
  width: 40,
  height: 10,
  padding: 1,
})

// Compose multiple renderables into one
function createLabeledInput(renderer: RenderContext, props: { label: string; placeholder: string; id: string }) {
  const container = new BoxRenderable(renderer, {
    id: `${props.id}-container`,
    flexDirection: "row",
  })

  container.add(
    new TextRenderable(renderer, {
      id: `${props.id}-label`,
      content: props.label + " ",
    }),
  )

  container.add(
    new InputRenderable(renderer, {
      id: `${props.id}-input`,
      placeholder: props.placeholder,
      width: 20,
    }),
  )

  return container
}

const username = createLabeledInput(renderer, {
  id: "username",
  label: "Username:",
  placeholder: "Enter username...",
})
loginForm.add(username)

// You must navigate to the nested component to focus it
username.getRenderable("username-input")?.focus()

renderer.root.add(loginForm)

Characteristics

  • Requires RenderContext at creation time
  • Direct mutation of instances
  • Manual navigation for nested component access
  • Explicit control over component lifecycle

Declarative (Constructs)

Builds a lightweight VNode graph using functional constructs. Instances don’t exist until you add the node to the tree. VNodes queue method calls and replay them when instantiated.

import { Text, Input, Box, createCliRenderer, delegate } from "@opentui/core"

const renderer = await createCliRenderer()

function LabeledInput(props: { id: string; label: string; placeholder: string }) {
  return delegate(
    { focus: `${props.id}-input` },
    Box(
      { flexDirection: "row" },
      Text({ content: props.label + " " }),
      Input({
        id: `${props.id}-input`,
        placeholder: props.placeholder,
        width: 20,
      }),
    ),
  )
}

const usernameInput = LabeledInput({
  id: "username",
  label: "Username:",
  placeholder: "Enter username...",
})

// delegate() automatically routes focus to the nested input
usernameInput.focus()

const loginForm = Box(
  { width: 40, height: 10, padding: 1 },
  usernameInput,
  LabeledInput({
    id: "password",
    label: "Password:",
    placeholder: "Enter password...",
  }),
)

renderer.root.add(loginForm)

Characteristics

  • No RenderContext needed until instantiation
  • VNodes queue method calls
  • delegate() routes APIs to nested components
  • Declarative, React-like syntax

The delegate() function

The delegate() function makes constructs ergonomic by routing method calls from the parent to specific children:

function Button(props: { id: string; label: string; onClick: () => void }) {
  return delegate(
    {
      focus: `${props.id}-box`, // Route focus() to the box
    },
    Box(
      {
        id: `${props.id}-box`,
        border: true,
        onMouseDown: props.onClick,
      },
      Text({ content: props.label }),
    ),
  )
}

const button = Button({ id: "submit", label: "Submit", onClick: handleSubmit })
button.focus() // Focuses the inner Box

When to use which

Use Renderables when

  • You need fine-grained control over component lifecycle
  • You’re building low-level custom components
  • You need to access renderable methods immediately
  • Performance is critical and you want to avoid VNode overhead

Use Constructs when

  • You prefer declarative, compositional code
  • You’re building higher-level UI components
  • You want cleaner, more readable component definitions
  • You’re familiar with React/Solid patterns

Mixing both

You can mix both approaches in the same application:

import { BoxRenderable, Text, Input } from "@opentui/core"

// Create a renderable container
const container = new BoxRenderable(renderer, {
  id: "container",
  flexDirection: "column",
})

// Add constructs to it
container.add(Text({ content: "Title" }), Input({ placeholder: "Type here..." }))

renderer.root.add(container)