Components
Introduction
A component is a Rust function that returns Dom.
There's no component trait. There's no derive macro. There's no special syntax. You compose components by calling functions. You reuse them through normal module visibility. The only value the framework actually inspects is the Dom you return.
use azul::prelude::*;
fn card(title: &str, body: &str) -> Dom {
Dom::create_div()
.with_class("card".into())
.with_child(Dom::create_h2_with_text(title))
.with_child(Dom::create_p_with_text(body))
}
let _ = Dom::create_body()
.with_child(card("First", "alpha"))
.with_child(card("Second", "beta"));
That's the whole model. The rest of this page covers two layers built on top.
The first layer is state. How do you thread persistent data through a component, and how do nested components forward events back up to their caller? The second layer, covered later under Component Packs, is registration. When you want a component visible to the XML loader or the live debugger by name, you wrap it in a ComponentDef and put it in a ComponentLibrary.
Pure parameters
For purely visual components, pass values as parameters and return the constructed Dom. No RefAny is needed because no data has to persist across frames.
use azul::prelude::*;
pub fn badge(text: &str, kind: BadgeKind) -> Dom {
let class = match kind {
BadgeKind::Info => "badge badge-info",
BadgeKind::Warn => "badge badge-warn",
BadgeKind::Error => "badge badge-error",
};
Dom::create_span_with_text(text).with_class(class.into())
}
pub enum BadgeKind { Info, Warn, Error }
The caller picks the variant. The component just produces nodes. Tests are easy: call the function, walk the returned Dom, assert on shape.
Owning state
When a component has internal state (an expansion flag, a counter value, a current selection), wrap it in a struct and pass it as a RefAny to the component's callbacks.
The state lives wherever the caller put it. The component's dom() method takes a clone of the RefAny and threads it through to each callback that needs it.
use azul::prelude::*;
pub struct Counter { value: i64 }
impl Counter {
pub fn dom(state: RefAny) -> Dom {
let label = match state.downcast_ref::<Counter>() {
Some(c) => format!("{}", c.value),
None => return Dom::create_div(),
};
Dom::create_div()
.with_child(Dom::create_span_with_text(label))
.with_child(
Dom::create_button_no_a11y("+1".into())
.with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), state, increment)
)
}
}
extern "C" fn increment(mut data: RefAny, _info: CallbackInfo) -> Update {
let mut c = match data.downcast_mut::<Counter>() { Some(c) => c, None => return Update::DoNothing };
c.value += 1;
Update::RefreshDom
}
The component doesn't own its state. The caller owns it. The component renders against it and wires callbacks back to it. That's what makes components composable. Anyone who can hand a Counter a RefAny<Counter> can build one.
For state that the node itself should carry (the typing buffer of a text input, a per-row checkbox, the scroll offset of a list), use with_dataset(OptionRefAny::Some(refany)) instead. The node-attached dataset is reachable inside the callback through info.get_dataset(info.get_hit_node()). That's the pattern used by the built-in widgets. See Built-in Widgets.
The backreference pattern
A component that wraps another component holds a RefAny to its own parent. When the inner component finishes its work, it follows that backreference to forward a higher-level event upward.
use azul::prelude::*;
pub type OnNumberChange = extern "C" fn(RefAny, CallbackInfo, i64) -> Update;
pub struct NumberInput {
value: i64,
on_change: Option<(RefAny, OnNumberChange)>,
}
impl NumberInput {
pub fn new(value: i64) -> Self { Self { value, on_change: None } }
pub fn set_on_change(&mut self, parent: RefAny, cb: OnNumberChange) {
self.on_change = Some((parent, cb));
}
pub fn dom(self) -> Dom {
let label = format!("{}", self.value);
let state = RefAny::new(self);
Dom::create_input_no_a11y("number".into(), "n".into(), label.clone().into())
.with_attribute(AttributeType::Value(label.into()))
.with_dataset(OptionRefAny::Some(state))
.with_callback(EventFilter::Focus(FocusEventFilter::FocusLost), RefAny::new(()), validate)
}
}
extern "C" fn validate(_unused: RefAny, mut info: CallbackInfo) -> Update {
let hit = info.get_hit_node();
let typed = match info.get_string_contents(hit) {
Some(s) => s, None => return Update::DoNothing,
};
let parsed: i64 = match typed.as_str().parse() { Ok(n) => n, Err(_) => return Update::DoNothing };
let mut ds = match info.get_dataset(hit) { Some(d) => d, None => return Update::DoNothing };
let on_change = {
let mut me = match ds.downcast_mut::<NumberInput>() { Some(m) => m, None => return Update::DoNothing };
me.value = parsed;
me.on_change.clone()
};
if let Some((parent, cb)) = on_change {
return cb(parent, info, parsed);
}
Update::RefreshDom
}
The private callback validate only knows about NumberInput. It parses the text the user typed. It updates its own state. Then it follows the backreference to the application-level callback.
The application sees a clean signature: (parent: RefAny, info, value: i64). There's no string handling on the application side. There's no awareness of the inner widget's internals.
A worked example: AgeInput over NumberInput
The application wraps NumberInput once more, this time to enforce a domain rule.
use azul::prelude::*;
pub struct AgeInput { age: i64 }
extern "C" fn layout(mut data: RefAny, _: LayoutCallbackInfo) -> Dom {
let age = match data.downcast_ref::<AgeInput>() { Some(a) => a.age, None => return Dom::create_body() };
let mut input = NumberInput::new(age);
input.set_on_change(data.clone(), on_age_changed);
Dom::create_body().with_child(input.dom())
}
extern "C" fn on_age_changed(mut data: RefAny, _info: CallbackInfo, new_age: i64) -> Update {
let mut a = match data.downcast_mut::<AgeInput>() { Some(a) => a, None => return Update::DoNothing };
if new_age < 0 || new_age > 150 { return Update::DoNothing; }
a.age = new_age;
Update::RefreshDom
}
The chain AgeInput -> NumberInput -> <input> is a State Graph. Each layer holds one backreference, pointing at the layer above.
Events travel the chain in reverse. The <input> loses focus. NumberInput::validate runs. AgeInput::on_age_changed runs. Nothing leaks across layers, and nothing has to be threaded through layout.
This is the same pattern walked through in Architecture, now in real Rust.
Returning multiple roots
A Dom has a single root. To return a sequence of siblings, wrap them in a neutral container or collect into a Dom.
use azul::prelude::*;
pub fn breadcrumb(parts: &[&str]) -> Dom {
parts.iter()
.enumerate()
.map(|(i, &p)| {
if i == 0 {
Dom::create_span_with_text(p)
} else {
Dom::create_div()
.with_class("crumb".into())
.with_child(Dom::create_span_with_text(" / "))
.with_child(Dom::create_span_with_text(p))
}
})
.collect::<Dom>()
}
collect::<Dom> produces a <div> whose children are the iterator's items. There's no fragment type and no portal type. If you don't want the wrapper to affect layout, give it display: contents.
Component-origin tracking
When a component's dom() returns, the framework can stamp the root nodes of its output with a component-origin record. That's the field the inspector populates with the qualified component id (like "shadcn:card") and the JSON-serialised data model.
The origin tag has three uses. The live debugger uses it to display a Component Tree alongside the DOM Tree. The code-generation roundtrip uses it to recover the source invocation. And clicking a node to jump to the component that produced it relies on it.
The origin is set automatically when a component is registered through the component system, whether from XML or from the builder. Plain functions don't need to opt in.
Component Packs
A component pack is a ComponentLibrary in the codebase. It's a named collection of ComponentDefs.
Packs are how the framework finds a component by name. <card title="..."/> in XML resolves through a pack. shadcn:avatar in the live debugger resolves through a pack. builtin:div resolves through a pack. They're also how a component invocation can roundtrip through the design-time tools and come back out as source code.
There's a second authoring surface in the same pipeline. A component can be declared in .azul markup with a typed argument list, registered into a ComponentLibrary, and called by name.
<component name="card" args="title: String, body: String">
<div class="card">
<h2>{title}</h2>
<p>{body}</p>
</div>
</component>
<app>
<card title="First" body="alpha"/>
<card title="Second" body="beta"/>
</app>
The runtime path is Dom::create_from_parsed_xml, introduced in The DOM - Loading XML and XHTML. It walks the parsed XML, resolves each tag against the registered component libraries, and produces the corresponding Dom.
Whether a component is hand-written Rust or XML-defined, the value is the same. It's a function from arguments and a RefAny to a Dom. The rest of this section is about registering those functions so the framework can look them up by name.
Why packs and not just functions
A plain Rust component (fn card(title: &str, body: &str) -> Dom) is visible to whoever can see the symbol. That's fine for compiled-in components: your own widgets, a third-party crate's widgets.
It isn't enough when you want more.
- You want to preview a component in the live debugger without recompiling.
- You want a typed data model the debugger can edit, populate, and pass back to the render function.
- You want code generation. The design-time tool emits the equivalent Rust, C, or Python source for a tree the user laid out interactively.
- You want to load components from XML by name.
<card .../>has to resolve to some function.
A ComponentDef (in core/src/xml.rs) carries everything the runtime and the design-time tools need:
pub struct ComponentDef {
pub id: ComponentId, // collection + name, e.g. shadcn:card
pub display_name: AzString,
pub description: AzString,
pub css: AzString, // ships with the component
pub source: ComponentSource, // where it came from (built-in, JSON, ...)
pub data_model: ComponentDataModel, // typed prop list - defaults double as preview values
pub render_fn: ComponentRenderFn, // (&def, &data, ®istry) -> StyledDom
pub compile_fn: ComponentCompileFn, // (&def, &target_lang, &data, indent) -> source
pub render_fn_source: OptionString,
pub compile_fn_source: OptionString,
}
A ComponentLibrary groups defs under a name, version, and description. It also carries exportable and modifiable flags. The live editor uses those flags to decide whether the user can edit a component in place.
Registering a pack
AppConfig::create() always pre-registers the "builtin" library. That library has 112 HTML element components (plus three control-flow builtins: if, for, map), so builtin:div, builtin:p, builtin:button, and the rest are available out of the box. Anything else is registered on top.
There are two registration shapes.
use azul::prelude::*;
let mut config = AppConfig::create();
// 1. Register a single component into a named library.
config.add_component(
AzString::from("mylib"),
my_register_card_fn, // extern "C" fn() -> ComponentDef
);
// 2. Register an entire pre-built library.
config.add_component_library(
AzString::from("shadcn"),
register_shadcn, // extern "C" fn() -> ComponentLibrary
);
Both work the same way. The registration function runs immediately at the call site. The returned ComponentDef or ComponentLibrary is moved into config.component_libraries. The library then becomes visible to the XML parser, to the layout callback (through CallbackInfo), and to the debug server.
Why the function-pointer indirection instead of a direct ComponentDef parameter? It's so the C and Python bindings can register libraries through their own callback shapes. The C example below shows it.
Built-in libraries use the same registration API
The 112 built-in HTML element components are themselves registered through add_component_library inside AppConfig::create(). The render functions are the same Dom::create_<tag>() constructors documented in DOM. Your own packs follow the same shape.
From C
The registration callback is a repr(C) function pointer, so a plain function pointer is enough on the C side.
extern AzComponentLibrary register_shadcn(void);
void main(void) {
AzAppConfig config = AzAppConfig_create();
AzAppConfig_addComponentLibrary(
&config,
AzString_fromCStr("shadcn"),
register_shadcn
);
/* ... */
}
From Python
The Python binding wraps the function pointer in a trampoline. You pass a Python callable. The binding stores it in the callback's ctx slot (OptionRefAny::Some(refany)) and dispatches through a generated trampoline.
from azul import *
def register_shadcn():
# Build a ComponentLibrary using the typed builders the binding exposes.
return ComponentLibrary.create("shadcn", "1.0.0", "shadcn-style components", [
# ... ComponentDefs ...
], exportable=True, modifiable=False)
config = AppConfig.create()
config.add_component_library("shadcn", register_shadcn)
Render: live preview
ComponentDef::render_fn has this signature:
fn(&ComponentDef, &ComponentDataModel, &ComponentMap) -> ResultStyledDomRenderDomError
The render function takes a modified data_model. The design-time tool overrides default values on each field, then hands the model back. It also takes the full ComponentMap so the function can recursively instantiate sub-components. A <card> containing an <h2> containing a <span> is one render call that recurses three levels through the same registry. The return is a fully cascaded StyledDom ready for layout.
That's what powers the live preview. The design-time tool reads the component's data model, lets the user edit each prop, calls the render function, and shows the result inline. No recompile. No trip through the disk.
Instantiation: from XML to DOM
When the XML parser encounters <card title="First" body="alpha"/>, it resolves card against the registered ComponentMap.
- Strip the namespace if present.
<shadcn:card .../>becomes("shadcn", "card"). A bare<card .../>falls back to"builtin"and is resolved like any built-in tag. - Look up the corresponding
ComponentDef. - Take the def's
data_modeland populate each field's default value from the XML attributes. Coercion is typed. - Call the def's
render_fnwith the populated data model and the component map. - Stamp every root node of the returned
StyledDomwith a component-origin record. The qualified component id is"shadcn:card"and the JSON-serialised data model is the populated one. That's what lets the debugger reconstruct the invocation later.
The ComponentMap is what Dom::create_from_parsed_xml consults under the hood. The AppConfig's component_libraries field carries the registered libraries, which are folded into a ComponentMap at app-create time.
Compile: code generation roundtrip
ComponentDef::compile_fn is the inverse of render_fn. Given the same data model, it emits the source that would call this component as a function in the target language.
fn(&ComponentDef, &CompileTarget, &ComponentDataModel, indent: usize)
-> ResultStringCompileError
That's what closes the round-trip for the design-time tool.
┌──────────────────┐ ┌────────────┐
user edits │ ComponentDataModel│ ─── render_fn ──────────▶│ StyledDom │
└──────────────────┘ └────────────┘
▲ │
│ │ inspector
└────────────────── compile_fn ◀─────────────┘ (data → source)
│
▼
fn card(title: &str, body: &str) -> Dom { … }
A node clicked in the inspector carries the component-origin record (the qualified component id and its data model JSON). The inspector calls compile_fn with that data model plus a CompileTarget (Rust, C, Cpp, or Python) and gets back source. From there the source can be pasted into the user's project, or handed to the codegen path that lives next to api.json. The round-trip is closed.
What the data model looks like
ComponentDataModel is a flat list of named fields. Each field has:
- a name, like
"title","body","on_click", or"children" - a
ComponentFieldType(Bool,F32,Callback,EnumRef,OptionType, and so on) - a
ComponentDefaultValue. That's the initial value the inspector shows, and it's the valuerender_fnreads when nothing has overridden it.
For non-trivial types like a struct of struct of enum, data_models and enum_models on the enclosing ComponentLibrary carry the type definitions. References between fields use the type's name. The inspector walks those references when it builds an editor for nested data.
That's what makes user-defined types editable in the inspector. A C callback like fn(RefAny, CallbackInfo) -> Update shows up as a Callback field. The inspector lets the user pick from a list of registered callbacks instead of asking them to write Rust into a text box.
Coming Up Next
- Built-in Widgets — Built-in widgets and how to write your own
- Styling with CSS — Stylesheets, selectors, and the cascade
- Layout — Overview of the layout solver