Skip to content

Compose Components

A view is built by composing components — mounting them inside one another and arranging them with layout.

Overview

Components compose by nesting: a component mounts others inside its build(), so a UI is a tree of components within components. Data moves between them in two directions — values pass down to a child through #[Prop], events come back up through an emit, and a two-way #[Model] combines the two. A layer (a modal) fits the same model: it's just a component drawn above the rest, composed and wired like any other.

Component composition

Composition is building a view from smaller components, then deciding where their state lives.

Compose components together

Composing is mounting one component inside another's build(). A mounted component returns a node, so it drops into a container's children like any other.

Here a small Field renders a label and a value:

php
use Phui\Attributes\Prop;

final class Field extends Component
{
    #[Prop]
    private string $label = '';

    #[Prop]
    private string $value = '';

    public function build(): Node
    {
        $label = Text::make($this->label.':')->colour('#6c7086');
        $value = Text::make($this->value)->bold();

        return Container::make([$label, $value])->axis('horizontal')->gap(1);
    }
}

A parent nests it with Field::mount(), feeding each one its data through the #[Prop] properties:

php
Container::make([
    Field::mount()->props(label: 'Status', value: 'Online'),
    Field::mount()->props(label: 'Region', value: 'eu-west'),
])->axis('vertical')->gap(1)->border('rounded');

Decide where state lives

That view hardcodes its two fields. Real data lives in state — and where you hold it decides how much of the UI rebuilds when it changes.

A component rebuilds on two triggers, and only these: its own #[State] changing, or a #[Prop] it was passed changing. So a state change re-renders the component that holds it and any child whose props derive from it — and leaves the rest of the tree alone.

That makes state placement a performance choice. Hold the fields in the parent and every Field rebuilds whenever any single value changes:

php
use Phui\Attributes\State;

#[State]
public array $fields = [
    'Status' => 'Online',
    'Region' => 'eu-west',
];

public function build(): Node
{
    $rows = [];

    foreach ($this->fields as $label => $value) {
        $rows[] = Field::mount()->props(label: $label, value: $value);
    }

    return Container::make($rows)
      ->axis('vertical')
      ->gap(1)
      ->padding(1)
      ->border('rounded');
}

That is the right call when the fields change together. But if one field updated on its own — a value ticking live — holding that state inside the Field itself would rebuild just that row, not all of them.

Aim to rebuild as little as possible: push state down into the component that actually changes, so its neighbours stay put. The counterweight is not to fragment the UI into stateful components everywhere — when several parts always change together, lift the state to their common parent and let them rebuild as one.

State is compared by value each rebuild to decide whether to re-render. If a state property instead holds objects you mutate in place, that check can miss the change — mark it #[State(isDeep: true)] so nested contents are compared. Deep comparison walks the whole structure on every rebuild, though, so it carries real overhead — avoid it unless you need it, and prefer replacing objects over mutating them in place.

Managing data flow

Data moves between a parent and its children in three shapes — down as props, up as emits, and both ways as a model.

Pass data down with props

A component is reusable when it takes its data as input rather than hardcoding it. The #[Prop] properties on Field are exactly that — values supplied by whoever mounts it, passed with props() and named to match:

php
Field::mount()->props(label: 'Status', value: 'Online');

A prop change re-renders the child, so the parent stays in control of what each one shows.

Send events up with emits

Props flow down, but a child often needs to tell its parent something. It does so by emitting a named event; the parent handles it with on() where it mounted the child. Whatever you pass as emit()'s second argument travels with the event, reaching the handler on the EmitEvent's ->data.

A modal is the clearest case — it needs to ask its parent to close it. Here a details dialog emits close when dismissed:

php
use Phui\Bindings\Binding;
use Phui\Events\Keyboard\Keypress;

final class Details extends Component
{
    protected bool $focusable = true;

    #[Prop]
    private string $text = '';

    public function bindings(): array
    {
        return [
            Binding::keypress(
                Keypress::ESCAPE,
                fn () => $this->emit('close'),
            ),
        ];
    }

    public function build(): Node
    {
        return Container::make([Text::make($this->text)])
          ->padding(1)
          ->border('rounded');
    }
}

Bind a value both ways with a model

Sometimes a value is owned by both sides: the parent sets it, and the child also changes it and must report every change back — a selected row, a slider's value. Wired by hand that means a prop coming in, a local copy the child can change, and an emit on every write. A #[Model] collapses all three into one declaration.

A model behaves as prop and state at once: the parent's value wins on rebuild, the child's own writes persist otherwise, and each write emits the configured event automatically. The child declares it and writes to it freely:

php
use Phui\Attributes\Model;

#[Model(emit: 'change')]
public int $value = 0;

private function step(int $by): void
{
    $this->value += $by;
}

The parent supplies the value and handles the emit:

php
use Phui\Events\EmitEvent;

Stepper::mount()
    ->props(value: $this->count)
    ->on('change', fn (EmitEvent $e) => $this->count = $e->data);

Now the parent owns the value: it can clamp, reject, or redirect what comes back before the child sees it again — and you write neither the local copy nor the emit.

The prop is optional. Leave it off and the model is state that announces itself: the child runs on its own value, and the parent listens only if it cares.

php
Stepper::mount()->on('change', fn (EmitEvent $e) => $this->log($e->data));

What you cannot do is supply the value and ignore the emit — the child's writes would take effect locally and the two would drift apart. Phui throws on that combination rather than let it happen.

Managing layers

A layer draws a component above the rest of the UI, with its own focus.

Open and close a layer

The parent owns whether the dialog is open. Mounting a component with ::modal() instead of ::mount() composes it exactly the same way — it returns a node in the children array — but draws it in a layer above the rest, with its own focus.

A modal fills its own background so the UI beneath does not show through, and captures input while open. Call transparent() to let the layer below show through, or interactable(false) for an overlay that ignores input — see ModalNode.

Gate the modal on a state boolean with Node::when() to open it, and handle its close emit to shut it:

php
protected bool $focusable = true;

#[State]
public bool $showDetails = false;

public function bindings(): array
{
    return [
        Binding::keypress(Keypress::d, fn () => $this->showDetails = true),
    ];
}

public function build(): Node
{
    $rows = [];

    foreach ($this->fields as $label => $value) {
        $rows[] = Field::mount()->props(label: $label, value: $value);
    }

    $details = Node::when($this->showDetails, Details::modal()
        ->props(text: 'eu-west · 3 nodes · 12d uptime')
        ->on('close', fn () => $this->showDetails = false));

    return Container::make([...$rows, $details])
      ->axis('vertical')
      ->gap(1)
      ->padding(1)
      ->border('rounded');
}

That closes the loop: the parent opens the layer by setting its own state, passes data down into it with props, and the child sends close up with an emit. Props down, events up — the two directions every composed UI is built from.

Route input through a layer

Input reaches the topmost layer that actually painted the cell under the cursor. A modal fills its background by default, so it paints every cell and catches every click — nothing leaks to the UI beneath.

interactable(false) changes that: it makes the whole layer inert, passing all input straight through whatever it draws to the live layer below — what you want for a purely visual layer like a toast.

Released under the MIT License.