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
RenderContextat 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
RenderContextneeded 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)