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:

ArrangementResult
Two consumers below one Notes providerBoth share its progress handle.
Two sibling Notes providersEach owns its own store; their descendants remain independent.
A Notes provider nested inside anotherThe inner provider shadows NotesStore for its subtree. The outer state is unchanged.
An ancestor provides a different store typeIt 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:

ErrorMeaning
Missing { type_name }No provider of the requested type exists here or above.
ExpiredScopeThe 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.