Files
michaelschiemer/docs/livecomponents/livecomponent-nested-components.md
Michael Schiemer 36ef2a1e2c
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
fix: Gitea Traefik routing and connection pool optimization
- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
2025-11-09 14:46:15 +01:00

20 KiB

LiveComponents Nested Components System

Comprehensive guide for building nested component hierarchies with parent-child relationships, event bubbling, and state synchronization.

Overview

The Nested Components System enables complex UI compositions through parent-child component relationships. Parents can manage global state while children handle localized behavior, with events bubbling up for coordination.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    Parent Component                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │           Global State Management                    │   │
│  │  • Manages list of items                            │   │
│  │  • Provides data to children                        │   │
│  │  • Handles child events                             │   │
│  └─────────────────────────────────────────────────────┘   │
│                           │                                  │
│              ┌────────────┴────────────┐                   │
│              │                         │                   │
│    ┌─────────▼────────┐    ┌──────────▼────────┐         │
│    │  Child Component  │    │  Child Component  │         │
│    │  • Local State    │    │  • Local State    │         │
│    │  • Dispatch Events│    │  • Dispatch Events│         │
│    └──────────────────┘    └───────────────────┘         │
└─────────────────────────────────────────────────────────────┘

Core Concepts

1. Component Hierarchy

ComponentHierarchy Value Object - Represents parent-child relationships:

use App\Framework\LiveComponents\ValueObjects\ComponentHierarchy;
use App\Framework\LiveComponents\ValueObjects\ComponentId;

// Root component (no parent)
$rootHierarchy = ComponentHierarchy::root();
// depth=0, path=[]

// First-level child
$childHierarchy = ComponentHierarchy::fromParent(
    parentId: ComponentId::fromString('parent:main'),
    childId: ComponentId::fromString('child:1')
);
// depth=1, path=['parent:main', 'child:1']

// Add another level
$grandchildHierarchy = $childHierarchy->withChild(
    ComponentId::fromString('grandchild:1')
);
// depth=2, path=['parent:main', 'child:1', 'grandchild:1']

Hierarchy Queries:

$hierarchy->isRoot();  // true if no parent
$hierarchy->isChild(); // true if has parent
$hierarchy->getLevel(); // nesting depth (0, 1, 2, ...)
$hierarchy->isDescendantOf($componentId); // check ancestry

2. NestedComponentManager

Server-Side Hierarchy Management:

use App\Framework\LiveComponents\NestedComponentManager;

$manager = new NestedComponentManager();

// Register root component
$parentId = ComponentId::fromString('todo-list:main');
$manager->registerHierarchy($parentId, ComponentHierarchy::root());

// Register child
$childId = ComponentId::fromString('todo-item:1');
$childHierarchy = ComponentHierarchy::fromParent($parentId, $childId);
$manager->registerHierarchy($childId, $childHierarchy);

// Query hierarchy
$manager->hasChildren($parentId); // true
$manager->getChildIds($parentId); // [ComponentId('todo-item:1')]
$manager->getParentId($childId);  // ComponentId('todo-list:main')
$manager->isRoot($parentId);      // true
$manager->getDepth($childId);     // 1

// Get all ancestors/descendants
$ancestors = $manager->getAncestors($childId); // [parentId]
$descendants = $manager->getDescendants($parentId); // [childId]

// Statistics
$stats = $manager->getStats();
// ['total_components' => 2, 'root_components' => 1, ...]

3. SupportsNesting Interface

Parent components must implement this interface:

use App\Framework\LiveComponents\Contracts\SupportsNesting;

interface SupportsNesting
{
    /**
     * Get list of child component IDs
     */
    public function getChildComponents(): array;

    /**
     * Handle event from child component
     *
     * @return bool Return false to stop event bubbling, true to continue
     */
    public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool;

    /**
     * Validate child component compatibility
     */
    public function canHaveChild(ComponentId $childId): bool;
}

4. Event Bubbling

Events flow from child to parent:

┌─────────────────────────┐
│   Child Component        │
│   • User clicks button   │
│   • Dispatches event     │
└───────────┬─────────────┘
            │ Event Bubbles Up
            ▼
┌─────────────────────────┐
│   Parent Component       │
│   • Receives event       │
│   • Updates state        │
│   • Re-renders children  │
└─────────────────────────┘

Event Dispatcher:

use App\Framework\LiveComponents\NestedComponentEventDispatcher;

$dispatcher = new NestedComponentEventDispatcher();

// Child dispatches event
$dispatcher->dispatch(
    componentId: ComponentId::fromString('todo-item:1'),
    eventName: 'todo-completed',
    payload: [
        'todo_id' => '1',
        'completed' => true
    ]
);

// Check dispatched events
$dispatcher->hasEvents(); // true
$dispatcher->count(); // 1
$events = $dispatcher->getEvents();

Implementation Guide

Creating a Parent Component

1. Implement SupportsNesting:

use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\SupportsNesting;
use App\Framework\LiveComponents\Attributes\LiveComponent;

#[LiveComponent('todo-list')]
final readonly class TodoListComponent implements LiveComponentContract, SupportsNesting
{
    private ComponentId $id;
    private TodoListState $state;

    public function __construct(
        ComponentId $id,
        ?ComponentData $initialData = null,
        array $todos = []
    ) {
        $this->id = $id;
        $this->state = $initialData
            ? TodoListState::fromComponentData($initialData)
            : new TodoListState(todos: $todos);
    }

    // LiveComponentContract methods
    public function getId(): ComponentId { return $this->id; }
    public function getData(): ComponentData { return $this->state->toComponentData(); }
    public function getRenderData(): ComponentRenderData { /* ... */ }

    // SupportsNesting methods

    public function getChildComponents(): array
    {
        // Return array of child component IDs
        $childIds = [];
        foreach ($this->state->todos as $todo) {
            $childIds[] = "todo-item:{$todo['id']}";
        }
        return $childIds;
    }

    public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool
    {
        // Handle events from children
        match ($eventName) {
            'todo-completed' => $this->handleTodoCompleted($payload),
            'todo-deleted' => $this->handleTodoDeleted($payload),
            default => null
        };

        return true; // Continue bubbling
    }

    public function canHaveChild(ComponentId $childId): bool
    {
        // Only accept TodoItem components
        return str_starts_with($childId->name, 'todo-item');
    }

    private function handleTodoCompleted(array $payload): void
    {
        $todoId = $payload['todo_id'];
        $completed = $payload['completed'];

        // Log or trigger side effects
        error_log("Todo {$todoId} marked as " . ($completed ? 'completed' : 'active'));

        // Note: State updates happen through Actions, not event handlers
        // Event handlers are for logging, analytics, side effects
    }
}

2. Create Parent State:

final readonly class TodoListState
{
    public function __construct(
        public array $todos = [],
        public string $filter = 'all'
    ) {}

    public static function fromComponentData(ComponentData $data): self
    {
        $array = $data->toArray();
        return new self(
            todos: $array['todos'] ?? [],
            filter: $array['filter'] ?? 'all'
        );
    }

    public function toComponentData(): ComponentData
    {
        return ComponentData::fromArray([
            'todos' => $this->todos,
            'filter' => $this->filter
        ]);
    }

    public function withTodoAdded(array $todo): self
    {
        return new self(
            todos: [...$this->todos, $todo],
            filter: $this->filter
        );
    }

    // More transformation methods...
}

3. Create Parent Template:

<!-- todo-list.view.php -->
<div class="todo-list">
    <!-- Parent UI -->
    <h2>My Todos ({total_count})</h2>

    <!-- Child Components -->
    <for items="todos" as="todo">
        <div
            data-live-component="todo-item:{todo.id}"
            data-parent-component="{component_id}"
            data-nesting-depth="1"
        >
            <!-- TodoItemComponent renders here -->
        </div>
    </for>
</div>

Creating a Child Component

1. Implement Component with Event Dispatcher:

use App\Framework\LiveComponents\NestedComponentEventDispatcher;

#[LiveComponent('todo-item')]
final readonly class TodoItemComponent implements LiveComponentContract
{
    private ComponentId $id;
    private TodoItemState $state;

    public function __construct(
        ComponentId $id,
        private NestedComponentEventDispatcher $eventDispatcher,
        ?ComponentData $initialData = null,
        ?array $todoData = null
    ) {
        $this->id = $id;
        $this->state = $initialData
            ? TodoItemState::fromComponentData($initialData)
            : TodoItemState::fromTodoArray($todoData ?? []);
    }

    #[Action]
    public function toggle(): ComponentData
    {
        $newState = $this->state->withToggled();

        // Dispatch event to parent
        $this->eventDispatcher->dispatch(
            componentId: $this->id,
            eventName: 'todo-completed',
            payload: [
                'todo_id' => $this->state->id,
                'completed' => $newState->completed
            ]
        );

        return $newState->toComponentData();
    }

    #[Action]
    public function delete(): ComponentData
    {
        // Dispatch delete event to parent
        $this->eventDispatcher->dispatch(
            componentId: $this->id,
            eventName: 'todo-deleted',
            payload: ['todo_id' => $this->state->id]
        );

        return $this->state->toComponentData();
    }
}

2. Create Child State:

final readonly class TodoItemState
{
    public function __construct(
        public string $id,
        public string $title,
        public bool $completed = false,
        public int $createdAt = 0
    ) {}

    public static function fromTodoArray(array $todo): self
    {
        return new self(
            id: $todo['id'] ?? '',
            title: $todo['title'] ?? '',
            completed: $todo['completed'] ?? false,
            createdAt: $todo['created_at'] ?? time()
        );
    }

    public function withToggled(): self
    {
        return new self(
            id: $this->id,
            title: $this->title,
            completed: !$this->completed,
            createdAt: $this->createdAt
        );
    }
}

3. Create Child Template:

<!-- todo-item.view.php -->
<div class="todo-item {completed|then:todo-item--completed}">
    <button data-livecomponent-action="toggle">
        <if condition="completed"></if>
    </button>

    <div class="todo-item__title">{title}</div>

    <button data-livecomponent-action="delete"></button>
</div>

Client-Side Integration

Automatic Initialization:

// NestedComponentHandler automatically initializes with LiveComponentManager
import { LiveComponentManager } from './livecomponent/index.js';

// Scans DOM for nested components
const nestedHandler = liveComponentManager.nestedHandler;

// Get hierarchy info
const parentId = nestedHandler.getParentId('todo-item:1'); // 'todo-list:main'
const childIds = nestedHandler.getChildIds('todo-list:main'); // ['todo-item:1', ...]

// Event bubbling
nestedHandler.bubbleEvent('todo-item:1', 'todo-completed', {
    todo_id: '1',
    completed: true
});

// Statistics
const stats = nestedHandler.getStats();
// { total_components: 5, root_components: 1, max_nesting_depth: 2, ... }

Best Practices

1. State Management

Parent owns the data:

// Parent manages list
private TodoListState $state; // Contains all todos

// Child manages display state only
private TodoItemState $state; // id, title, completed, isEditing

Don't duplicate state:

// Bad: Both parent and child store todo data
// This leads to synchronization issues

2. Event Handling

Use event handlers for side effects:

public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool
{
    // ✅ Logging
    error_log("Child event: {$eventName}");

    // ✅ Analytics
    $this->analytics->track($eventName, $payload);

    // ✅ External system updates
    $this->cache->invalidate($payload['todo_id']);

    return true; // Continue bubbling
}

Don't modify state in event handlers:

// ❌ Bad: Event handlers shouldn't modify component state
// State changes happen through Actions that return new ComponentData

3. Child Compatibility

Validate child types:

public function canHaveChild(ComponentId $childId): bool
{
    // Only accept specific component types
    return str_starts_with($childId->name, 'todo-item');
}

4. Circular Dependencies

Framework automatically prevents:

// This will throw InvalidArgumentException:
$manager->registerHierarchy($componentId, $hierarchy);
// "Circular dependency detected: Component cannot be its own ancestor"

Performance Considerations

Hierarchy Depth

  • Recommended: Max 3-4 levels deep
  • Reason: Each level adds overhead for event bubbling
  • Alternative: Flatten hierarchy when possible

Event Bubbling

  • Cost: O(depth) for each event
  • Optimization: Stop bubbling early when not needed
  • Pattern: Return false from onChildEvent() to stop
public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool
{
    if ($eventName === 'internal-event') {
        // Handle locally, don't bubble further
        return false;
    }

    // Let other events bubble
    return true;
}

State Synchronization

  • Pattern: Parent as single source of truth
  • Benefit: Avoids synchronization bugs
  • Trade-off: More re-renders, but simpler logic

Testing

Unit Tests

describe('NestedComponentManager', function () {
    it('tracks parent-child relationships', function () {
        $manager = new NestedComponentManager();

        $parentId = ComponentId::fromString('parent:1');
        $childId = ComponentId::fromString('child:1');

        $manager->registerHierarchy($parentId, ComponentHierarchy::root());
        $manager->registerHierarchy(
            $childId,
            ComponentHierarchy::fromParent($parentId, $childId)
        );

        expect($manager->hasChildren($parentId))->toBeTrue();
        expect($manager->getParentId($childId))->toEqual($parentId);
    });

    it('prevents circular dependencies', function () {
        $manager = new NestedComponentManager();
        $id = ComponentId::fromString('self:1');

        expect(fn() => $manager->registerHierarchy(
            $id,
            ComponentHierarchy::fromParent($id, $id)
        ))->toThrow(InvalidArgumentException::class);
    });
});

Integration Tests

describe('TodoList with nested TodoItems', function () {
    it('handles child events', function () {
        $todoList = new TodoListComponent(
            id: ComponentId::fromString('todo-list:test'),
            todos: [
                ['id' => '1', 'title' => 'Test', 'completed' => false]
            ]
        );

        $childId = ComponentId::fromString('todo-item:1');

        // Simulate child event
        $result = $todoList->onChildEvent(
            $childId,
            'todo-completed',
            ['todo_id' => '1', 'completed' => true]
        );

        expect($result)->toBeTrue(); // Event bubbled successfully
    });
});

Troubleshooting

Problem: Children not rendering

Cause: Missing data-parent-component attribute

Solution:

<!-- ✅ Correct -->
<div
    data-live-component="child:1"
    data-parent-component="parent:main"
    data-nesting-depth="1"
>
</div>

Problem: Events not bubbling

Cause: Wrong ComponentId or event name

Solution:

// ✅ Use exact component ID
$this->eventDispatcher->dispatch(
    componentId: $this->id,  // ✅ Correct: use component's own ID
    eventName: 'todo-completed',
    payload: [...]
);

Problem: Circular dependency error

Cause: Component trying to be its own ancestor

Solution:

// ❌ Wrong: Same component as parent and child
$hierarchy = ComponentHierarchy::fromParent($sameId, $sameId);

// ✅ Correct: Different components
$hierarchy = ComponentHierarchy::fromParent($parentId, $childId);

Advanced Patterns

Multi-Level Nesting

// Grandparent → Parent → Child
$grandparent = ComponentHierarchy::root();

$parent = ComponentHierarchy::fromParent(
    ComponentId::fromString('grandparent:1'),
    ComponentId::fromString('parent:1')
);

$child = $parent->withChild(
    ComponentId::fromString('child:1')
);
// depth=2, path=['grandparent:1', 'parent:1', 'child:1']

Conditional Children

public function getChildComponents(): array
{
    // Only show children if filter matches
    $filteredTodos = $this->state->getFilteredTodos();

    return array_map(
        fn($todo) => "todo-item:{$todo['id']}",
        $filteredTodos
    );
}

Dynamic Child Addition

#[Action]
public function addTodo(string $title): ComponentData
{
    $newTodo = [
        'id' => uniqid('todo_', true),
        'title' => $title,
        'completed' => false
    ];

    // State includes new todo
    $newState = $this->state->withTodoAdded($newTodo);

    // Framework automatically creates child component
    // based on getChildComponents() result

    return $newState->toComponentData();
}

Summary

Nested Components enable:

  • Complex UI compositions
  • Parent-child communication via events
  • Hierarchical state management
  • Reusable component patterns
  • Type-safe relationships

Key Classes:

  • ComponentHierarchy - Relationship value object
  • NestedComponentManager - Server-side hierarchy
  • NestedComponentHandler - Client-side hierarchy
  • NestedComponentEventDispatcher - Event bubbling
  • SupportsNesting - Parent component interface

Next Steps:

  • Implement Slot System for flexible composition
  • Add SSE integration for real-time updates
  • Explore advanced caching strategies