Components and Composition
A panel becomes more useful when it can appear in several places, with a different title or progress value each time. A component gives that panel a name and an interface: the values it accepts, the actions it offers, and the content its caller can supply.
This chapter extracts the Field notes panel from Properties and Events and Rust into a small reusable card. It then builds on the same boundary to explain conditional views, lists, slots, and shared state. If you are new to Pax source, start with Templates.
Make a reusable component
Create src/note_card.rs and src/note_card.pax beside your application's
lib.rs and lib.pax:
src/
├── lib.rs Notes: application state and actions
├── lib.pax places cards and supplies their inputs
├── note_card.rs NoteCard: its public properties and defaults
└── note_card.pax the card's internal interface
In src/note_card.rs, declare the card's inputs:
#![allow(unused)] fn main() { use pax_kit::*; #[pax] #[file("note_card.pax")] #[custom(Default)] pub struct NoteCard { pub title: Property<String>, pub progress: Property<f64>, } impl Default for NoteCard { fn default() -> Self { Self { title: Property::new("Untitled note".to_string()), progress: Property::new(0.0), } } } }
The type has #[pax] and an associated template, like any root component.
A separate runnable! declaration selects the application root. Public properties form the card's
value interface; here progress uses a zero-to-one range. The custom default
provides useful values when a caller omits an input. Each new card instance
gets its own initial properties.
Give src/note_card.pax the display portion of the panel:
<Text x=24px y=24px width={100% - 48px} height=36px
text={self.title}
style={font_size: 24px, fill: rgb(32, 40, 48)}
/>
<Text x=24px y=72px width={100% - 48px} height=28px
text={"Progress: " + (self.progress * 100) + "%"}
style={font_size: 16px, fill: rgb(72, 80, 88)}
/>
<Group x=24px y=116px width={100% - 48px} height=12px>
<Rectangle width={(self.progress * 100)%} height=100%
fill=rgb(55, 120, 90) corner_radius=6
/>
<Rectangle width=100% height=100%
fill=rgb(220, 216, 206) corner_radius=6
/>
</Group>
<Rectangle width=100% height=100%
fill=rgb(246, 241, 230) corner_radius=12
/>
The card uses the layout area supplied by its caller. The percentage widths follow that area; the fixed offsets give this example its padding. Its background remains last so the text and bar render in front. For sizing and container choices, read Layout and Responsiveness.
Use the card
Here is a complete src/lib.rs for the two-card example:
#![allow(unused)] fn main() { use pax_kit::*; pub mod note_card; pub use note_card::NoteCard; pax_kit::runnable! { root: crate::Notes } #[pax] #[file("lib.pax")] #[custom(Default)] pub struct Notes { pub title: Property<String>, pub progress: Property<f64>, } impl Default for Notes { fn default() -> Self { Self { title: Property::new("Field notes".to_string()), progress: Property::new(0.25), } } } impl Notes { pub fn advance(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) { self.progress.set((self.progress.get() + 0.25).min(1.0)); } } }
pub mod includes the Rust module, and pub use makes NoteCard available
at the crate root. This is a straightforward pattern for a project's reusable
components. The .pax file is associated through #[file(...)]; it needs no
separate Rust module declaration.
Template paths are looked up from the package root, with a src/ fallback.
If you move the pair into a deeper directory, update that path explicitly,
for example #[file("src/cards/note_card.pax")], along with the Rust module
wiring. The path is not relative to the Rust file containing the attribute.
In src/lib.pax, place two cards and an application-level button:
<Button x=24px y=24px width=160px height=28px
label="Advance" @button_click=self.advance
/>
<NoteCard x=24px y=72px width=320px height=160px
title={self.title} progress={self.progress}
/>
<NoteCard x=24px y=248px width=320px height=160px
title="Sketchbook" progress=0.75
/>
The first card starts at 25 percent and follows the application's progress. The second displays 75 percent independently. Pressing Advance changes only the first card. Remove the second card's title and progress attributes to try its defaults: “Untitled note” and zero progress.
Restart the app after adding the Rust files and declarations. The default hot-reload lane updates template edits; see Developer Workflow for the application-logic reload options.
Declare a runnable UI
runnable! declares a Pax UI that can be launched as an application or embedded
in a host. It selects an ordinary component as the root, connects its optional
initializer, and generates the host-facing entry functions. The component can
also be used as a subtree in another application. The declaration itself runs
no initialization and mounts no UI; a host invokes those entry functions later.
The compiled UI remains a cartridge with a separate linkage boundary.
src/main.rs remains the native process entry and does not need to contain UI.
For a nested layout, put this in src/lib.rs:
#![allow(unused)] fn main() { pub mod ui; pax_kit::runnable! { root: crate::ui::App, init: crate::ui::init } }
Put the component and initializer in src/ui/mod.rs:
#![allow(unused)] fn main() { use pax_kit::*; #[pax] #[file("src/ui/app.pax")] pub struct App { pub title: Property<String> } pub fn init(_: &InitContext) -> AppResult<App> { Ok(App { title: Property::new("My application".into()) }) } }
src/ui/app.pax can contain <Text text={title} width=100% height=100%/>.
Template paths remain relative to the package root, with a src/ fallback;
they are not relative to the Rust module. Nested inline and file modules
are discovered statically. Use an ordinary Rust path or explicit use/pub use
for the selected type. A public re-export can expose a component from a private
module. Generated descriptors use an accessible qualified path and preserve one
type identity across imports in the cartridge.
When publishing a reusable component library, expose every component and value
type that its templates need to the consuming cartridge. For a transitive library,
an explicit pub use controls::Counter; in your library's public API lets code
generation reach that type through your library. Alternatively, the consuming
cartridge can depend directly on controls. Generated descriptors follow normal
Rust visibility; they cannot reach private, unexported component internals.
init: is optional and defaults to App::default(). When supplied, Rust checks
that the function accepts &InitContext and returns AppResult<App>. The compiler
does not run main or init to discover UI. Multiple runnable! declarations
share one catalog of component definitions. Repeated declarations of a root are
deduplicated; a root must have one unambiguous initializer. With several distinct
roots, choose a canonical path through InitOptions::entry, such as
Some("crate::ui::App".into()), or entry in the browser mount options.
Initializers run only for the selected mount, not when a runnable is used as a
child component. One active native mount is supported at a time.
Rust selects cfg and cfg_attr in each owning crate, including dependency
features, target conditions, tests, and custom build-script cfgs. Disabled
modules and templates are not loaded. Definitions produced by arbitrary Rust
macros are not source-discoverable; keep component and runnable declarations
in ordinary modules. Compilation caches parsed source and shares the catalog
across runnable declarations.
Keep slow work asynchronous after returning the initial root; see
application setup.
Migration: remove #[main] and #[init(path)] from the component, then add
pax_kit::runnable! { root: YourRoot, init: path } beside the module declarations.
The old #[main] plus optional #[init(path)] remains a compatibility shorthand
when no explicit declaration exists. Mixing the legacy shorthand with explicit declarations produces an error.
Library use does not initialize or mount the application; each cartridge keeps
its own platform entrypoints and immutable program definitions.
Use a separate UI package
In a Cargo workspace, a host package can own src/main.rs while a sibling UI
package owns the library, declaration, initializer, templates, and assets.
Both packages must be workspace members. Add this to the host Cargo.toml:
[dependencies]
my-ui = { path = "../my-ui", default-features = false }
[package.metadata.pax.cartridge]
package = "my-ui"
lib = "my_ui"
package is the Cargo package name. Optional lib checks the selected library
target name (including a custom [lib].name); Cargo permits one library target
per package. Without the table, Pax selects the host package's own library.
The UI library needs crate-type = ["rlib", "cdylib"] and its Pax dependencies.
The host needs the native launcher target
when building for Apple. The direct dependency lets Cargo schedule the UI
artifacts; metadata alone does not add a Cargo dependency. init!() selects
that library through its metadata helper, including renamed dependencies,
while preserving the separate native cartridge boundary.
Run from the host, or use pax-cli run --path path/to/host --target web. Pax reads
platform metadata and web public/ from the host and writes generated .pax/
output there. It compiles the selected UI library using Cargo's workspace target
directory, including a configured CARGO_TARGET_DIR. UI template paths and
assets/ resolve from the UI package. Assets from referenced component packages
are included too; give differing files distinct paths under assets/ to avoid
collisions. Identical shared files may use the same path.
Debug sessions watch the host, selected UI package, and referenced template and
asset directories. Template reload and dev inspection use their real source
paths. Rust changes still require rebuilding and restarting. Debug and release
use the same root selection and qualified component identities. The repository
fixture tests/src/cartridge-layouts exercises this layout, including a reused
legacy root exposed through a public re-export.
Inputs and state ownership
In lib.pax, {self.title} reads Notes.title. Inside note_card.pax, the
same expression reads that particular NoteCard.title. The attribute on the
invocation connects the two scopes. A child template can use its own fields
without knowing the containing application's type or variable names.
Values supplied by the caller take the place of the child's defaults for those inputs. Use defaults for fallback state, and avoid unconditionally writing caller-supplied inputs in a mount handler. Such a write can replace the displayed value even though the parent is meant to own it.
An ordinary binding such as progress={self.progress} derives the child's
input from the parent. It does not provide write-through to the parent.
When the parent owns progress, have application actions update that source.
The card's label and bar then follow its input. Properties
explains what happens if Rust writes directly to a computed field.
For a component designed to edit a shared value, bind: explicitly connects
the same property handle at both ends:
<NoteCard x=24px y=72px width=320px height=160px
title={self.title} progress=bind:progress
/>
The read-only card looks the same. If it gains an editing handler that calls
self.progress.set(...), that handler can now update Notes.progress too.
Both fields must have compatible property types; bind: takes a property
identifier, not an arbitrary formula. Use it when editing that value is part
of the component's intended interface. See
PAXEL for the binding syntax and
property handles for the Rust model.
For example, ExampleHost's source tabs bind to their own host's
selected_source property. Clicking a tab changes that drawer only; sibling
hosts keep independent selections. They do not need a local store for this
direct parent/child connection. Callers can deliberately bind multiple hosts
to the same selection property when synchronized selection is wanted.
State used only inside one card can stay on that card. State shared by several cards usually belongs in their common owner. For an action such as “advance this note,” a named event can let the owner decide how state should change; we will build that connection below.
Data-driven components
A component invocation can be part of a conditional branch or a repeated group. The template describes which instances should exist for the current state; Rust changes that state in response to actions.
Conditional content
For example, replace the first card with a completion message once progress reaches one:
<Group x=24px y=72px width=320px height=160px>
if self.progress < 1.0 {
<NoteCard width=100% height=100%
title={self.title} progress={self.progress}
/>
} else {
<Text width=100% height=100% text="Ready for the next idea."
style={font_size: 20px, fill: rgb(55, 120, 90)}
/>
}
</Group>
Conditions use PAXEL directly after if; the braces enclose template
content. An else if chain can choose among several states, and else is
optional. Pax uses the first true branch, or the final else when present.
Each branch can contain several elements.
Changing branches removes the old content and mounts the new content. Local
state belongs to those component instances: if a card is removed and later
created again, its local state starts again. Keep state that must survive
that change in Notes or another longer-lived owner. Exit transitions can
retain departing content temporarily, and rapid re-entry can reuse it; see
Animation and Motion for that lifecycle.
A structural if controls which elements exist. A conditional inside
@settings changes settings on
existing elements. Choose according to whether the interface needs different
content or different styling.
Ranges and collections
Use for to repeat template content. A range is useful for a fixed set of
decorative elements or positions:
for i in 0..3 {
<Text x=24px y={(24 + i * 32)px} width=240px height=28px
text={"Position " + i}
/>
}
This produces positions 0, 1, and 2; the upper bound is excluded. A loop
receives each value as its item. With for (item, i) in ..., the second
binding is its current zero-based index. Bindings are available inside that
loop's body; nested loops can give their own items and indices distinct names.
For application data, add a small record to lib.rs:
#![allow(unused)] fn main() { #[pax] pub struct NoteEntry { pub id: usize, pub title: String, } }
Add pub entries: Property<Vec<NoteEntry>> to Notes. In its existing
Default implementation, initialize that field with:
#![allow(unused)] fn main() { entries: Property::new(vec![ NoteEntry { id: 10, title: "Field notes".to_string() }, NoteEntry { id: 20, title: "Sketchbook".to_string() }, NoteEntry { id: 30, title: "References".to_string() }, ]), }
The record's plain fields describe one value in the collection. The outer
property publishes changes to the collection; use set or update as
described in Properties.
Keyed lists
Replace the two individual cards with a list:
<Group x=24px y=72px width=320px height=528px>
for (entry, i) in self.entries key entry.id {
<NoteCard y={(i * 176)px} width=100% height=160px
title={entry.title}
/>
}
</Group>
Here i determines position, while entry.id determines identity. If the
entries change from [10, 20, 30] to [30, 10, 20], Pax reuses the existing
component groups in their new order. Local state, such as a card's expanded
view or an in-progress edit, follows the entry with the same key. New keys
mount new groups; removed keys leave the active list and can play exit
transitions. A loop body may contain several elements, which share that
iteration's identity.
Choose a string or integer key that is stable for the item's lifetime and unique within that loop. The current index is generally a poor key for a list that can be reordered. Duplicate or unsupported key values cause a runtime warning and positional fallback, so check the data instead of relying on that fallback to preserve state.
An unkeyed loop reuses positions. It is useful for fixed grids and repeated decoration; for mutable application lists, use an item identifier. Keys govern reuse within their own loop, not identity across unrelated loops or branches. Layout remains a separate choice: this example uses explicit row positions; Stacker and Scroller can arrange or reveal a larger collection.
Explore Transition Grid to see a keyed list being inserted into, removed from, and reversed. Its child components animate while Stacker owns their placement. The tile counters are stored in the parent collection; the child's animated color is local component state.
Slots
Sometimes the reusable part is a frame around content its caller supplies. A card frame might reserve a header area while accepting text, controls, or another component in its body. Slots specify where that caller-provided content appears inside the frame's template.
Add a component declaration to lib.rs:
#![allow(unused)] fn main() { #[pax] #[file("note_frame.pax")] pub struct NoteFrame {} }
In src/note_frame.pax, use one explicit slot and a remainder slot:
<Group x=24px y=24px width={100% - 48px} height=32px>
slot(0)
</Group>
<Group x=24px y=72px width={100% - 48px} height={100% - 96px}>
slot()
</Group>
<Rectangle width=100% height=100%
fill=rgb(246, 241, 230) corner_radius=12
/>
Use it from lib.pax with content between its opening and closing tags:
<NoteFrame x=24px y=72px width=320px height=220px>
<Text width=100% height=100% text={self.title}
style={font_size: 24px, fill: rgb(32, 40, 48)}
/>
<Text width=100% height=32px text="Make room for the next idea."
style={font_size: 16px, fill: rgb(72, 80, 88)}
/>
<Button y=48px width=100% height=36px label="Advance"
@button_click=self.advance
/>
</NoteFrame>
slot(0) projects the first child into the header area. slot() projects all
children not consumed by an earlier active slot site in this component; here
the body Group receives the second Text and the Button. The caller positions
those body elements within the area the frame supplies. The slot syntax is
positional, with zero-based indices. These children remain authored in
Notes: their self.title and self.advance still refer to Notes, even
though the frame supplies their layout containers.
A component can forward its slots through another component. For example,
placing slot(0) inside a Scroller keeps that slot associated with the
component that authored it. The Scroller's own slots handle its received
content separately; they do not consume the outer component's children.
Slots project the existing child content. They do not make a new independent
copy at each insertion site. An explicit index that is out of range or already
consumed renders empty and produces a warning. An empty remainder slot is
valid and silent. Put fixed slot sites before the remainder site; an earlier
slot() can consume everything before a later explicit site gets a chance.
Consumption follows the active slot sites in template-tree order. Conditional
sites or a changing slot index can change which children reach the remainder.
Likewise, if and for in caller content can change the currently available
projected children. Wrap several elements in one Group when they should be
supplied together as one child. The
slot-projection-resolver example
explores these dynamic cases.
The frame's private background and layout structure stay inside its template.
Caller content and private implementation structure serve different roles;
container code should use NodeContext::received_children for its semantic
content rather than treating all runtime descendants as supplied children.
Custom container internals are beyond this chapter.
Component actions
A reusable component can name an action without prescribing the application's response. For example, an Advance control can ask its owner to advance a note. The owner may update progress, validate some data, or choose another note.
The complete custom-event example in Events
builds an AdvanceButton that dispatches advance to a handler on Notes.
Use that pattern when the component should express an intent and leave the
state change to its caller. Use an input for a value the component reads,
and an explicit bind: when editing the shared value is part of its interface.
Custom events carry a name only, so design their data interface alongside the component's properties or shared state. Keep the action's required or optional binding explicit. Events owns the sender/receiver code, context, delivery timing, and error behavior; this chapter's component boundary is what makes that connection reusable.
Shared state farther down the tree
Explicit inputs and actions keep a small component easy to reuse. For state needed throughout a larger subtree, a store lets descendants find a shared value without passing it through every intermediate component. The provider owns that registration for its mounted lifetime.
Provide and borrow a store
Define a type that names the store's purpose in lib.rs, alongside Notes.
Store, Property, and NodeContext are available through use pax_kit::*;:
#![allow(unused)] fn main() { pub struct NotesStore { pub progress: Property<f64>, } impl Store for NotesStore {} impl Notes { pub fn on_mount(&mut self, ctx: &NodeContext) { ctx.provide_store(NotesStore { progress: self.progress.clone(), }) .expect("Notes is mounting"); } } }
If Notes already has on_mount, add this registration to that method.
Cloning the property shares the owner's handle. The store is ordinary Rust
data; it does not need #[pax] or expose every field on Notes.
A descendant can borrow the store to obtain that handle. Add this component
to lib.rs:
#![allow(unused)] fn main() { #[pax] #[inlined( <Button label="Reset progress" @button_click=self.reset /> )] pub struct ResetProgress {} impl ResetProgress { pub fn reset(&mut self, ctx: &NodeContext, _event: Event<ButtonClick>) { let progress = ctx .with_store(|store: &mut NotesStore| store.progress.clone()) .expect("ResetProgress needs a NotesStore provider"); progress.set(0.0); } } }
Place two instances in the Notes template, wherever the layout has room:
<Group width=360px height=36px>
<ResetProgress width=172px height=36px />
<ResetProgress x=188px width=172px height=36px />
</Group>
Both find the same NotesStore registration and clone the same progress
handle. Either button resets the owner's progress, including the existing
label and bar bound to it. Intermediate components need no forwarding code.
Keep the borrowed-store closure short. It exclusively borrows that store
until it returns; cloning a property inside the closure lets later reads,
writes, and callbacks happen after the borrow ends. To display store state
in another component, connect the handle to one of that component's fields
using a computed property, as the
store-ownership example
does in CounterConsumer::mount. Stores are looked up from Rust, not directly
from PAXEL. Plain Rust fields in a store do not become reactive automatically.
Types, sharing, and shadowing
The concrete Rust type is the lookup key: the &mut NotesStore annotation
selects the provider. No string ID, child position, or traversal path is
needed. A store's contents can use your own enums, record IDs, or maps; they
are not tied to indices in the rendered tree.
Lookup starts at the context's node and walks its logical ancestors. The nearest provider of the requested type wins. It never searches siblings or children. That gives these composition rules:
| Arrangement | Result |
|---|---|
| Two consumers below one Notes provider | Both share its progress handle. |
| Two sibling Notes providers | Each owns its own store; their descendants remain independent. |
| A Notes provider nested inside another | The inner provider shadows NotesStore for its subtree. The outer state is unchanged. |
| An ancestor provides a different store type | It remains available through the inner provider. |
Shadowing makes a reusable subtree independent without making its consumers know where it is placed. For example, each editor panel can provide its own selection store, while all panels still find a shared application theme.
Use distinct structs or newtypes when two roles need separate stores with
the same payload. A Rust type alias does not create a different lookup key:
#![allow(unused)] fn main() { pub struct PrimaryProgress(pub Property<f64>); pub struct ComparisonProgress(pub Property<f64>); impl Store for PrimaryProgress {} impl Store for ComparisonProgress {} }
Providing the same type again on the same owner replaces its entry for
future lookups. Already cloned handles still refer to their original state;
replacement does not retarget consumers. Prefer publishing once at mount
and updating properties inside that stable store. If two providers should
intentionally share state, give them the same handle through bind: or Rust
before publishing it.
Context and projected children
Registration belongs to the exact node identified by ctx. Register in the
component's own mount handler to make that component the owner. In an inline
handler on a descendant Button, Rust self can be the containing component
while ctx identifies the Button; publishing there would make the Button
the owner. See binding scope.
A provider is visible to both its private template descendants and content
supplied by its caller. For example, in <Table><Row /></Table>, Row can
find Table's store while expressions and inline handlers on that invocation
retain the caller's scope. A private wrapper around Table's slot() does not become
a provider ancestor of that Row. Forwarding slots or moving their rendered
position also leaves provider ancestry unchanged. To expose state from a
private implementation component, the receiving component must publish the
intended interface itself.
Lifetime and lookup errors
Stores follow component identity. In a keyed list, reordering an item preserves its provider and state; an unkeyed list reuses positions. Final unmount clears the owner's providers and local state. A later mount starts fresh. Exit transitions retain providers until final unmount, and a return before the exit completes can reuse that instance. Unmount handlers can still access the departing scope before it is cleared.
A cloned property handle can keep its old state alive after unmount, but it
does not connect to a new instance. A saved NodeContext cannot access a
later mount's providers. For state that should outlive a view, own it in a
longer-lived component above that view or in application-managed persistence.
During template hot reload, a reused provider with stable property handles
keeps its registration. A changed property alias, such as changing a
bind: target, remounts the affected provider subtree so its consumers
receive the new handles. Recreated regions and a full application restart
start fresh stores. These development rules are not a persistence API;
see Hot reloading.
Both APIs return Result; StoreError
distinguishes these failures:
| Error | Meaning |
|---|---|
Missing { type_name } | No provider of the requested type exists here or above. |
ExpiredScope | The context is not mounted, its mount ended, or its provider ancestry expired. |
BorrowConflict { type_name } | The chosen provider is already borrowed by a with_store closure. |
provide_store can report expiration or a conflict when replacing an entry.
A conflict or expired scope does not fall back to a different outer provider.
The reset example uses expect because its provider is required; a component
that also works independently should handle Missing explicitly. Keep
expiration and conflicting borrows distinct from optional absence.
Try the store-ownership example
for shared private/projected consumers, sibling isolation, nested shadowing,
keyed reorder, and remove/remount controls. The
router-playground example
uses a store for mobile-menu state. Read
Property handles for sharing and thread
constraints, and the NodeContext reference
for the method signatures.
Application-wide dependencies are ordinary root fields created by the
typed initializer. Pass them explicitly
or provide a lexical store from a component that owns them. Remounting a child
does not repeat initialization. Local Rc handles stay on the UI thread; only
explicitly transferable clients and shared property values cross threads.
For an advanced alternative that implements an element directly through the Rust runtime, read Primitives. That chapter covers when to use a primitive, how to author one, and its rendering and lifecycle responsibilities.
Read more
You can now give a reusable view its own interface, choose the owner of its state, and assemble instances from data and caller-provided content. Continue with Layout and Responsiveness to make those components fit different spaces. Scrolling covers larger collections, Routing organizes screens, and Motion adds transitions as content enters, leaves, or changes position.