Live Components

A Twig component that re-renders on the server as the user types, how LiveProp and LiveAction work, and the cost per keystroke.

Syntax#[LiveProp(writable: true)] public string $query = '';

A Twig component is a PHP class plus a template, rendered as a tag: <twig:ArticleSearch />. The class's public properties and methods are available inside its template. A Live Component adds one capability: the browser can ask the server to render the component again with new values, and only the parts of the DOM that changed are updated.

How a render happens

Mark the class #[AsLiveComponent] and use DefaultActionTrait. Its template needs a single root element rendering {{ attributes }}, because that is where the component's state is written. A property marked #[LiveProp] is serialised into that element and sent back on the next request, which is how the server rebuilds the same state. With writable: true the browser may send a new value, which is what data-model="query" on an input does. A method marked #[LiveAction] can be called from the template by name. Each of these is an HTTP POST to an internal route, and each one constructs a new instance of your class.

The cost

One keystroke can mean one request, one framework boot and one query. data-model waits 150ms after the last keystroke by default; data-model="debounce(300)|query" widens that window. Anything not held in a LiveProp does not survive to the next render, and anything held in a LiveProp travels through the page in both directions, so keep props small. If a render runs a database query, so does every keystroke that reaches the server.

Example

Example · php
<?php

// src/Twig/Components/ArticleSearch.php

declare(strict_types=1);

namespace App\Twig\Components;

use App\Repository\ArticleRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

// Template is found by convention at
// templates/components/ArticleSearch.html.twig
#[AsLiveComponent]
final class ArticleSearch
{
    use DefaultActionTrait;

    // writable: true lets the browser send a new value for this property.
    // url: true also keeps it in the address bar, so the search survives
    // a refresh and can be shared as a link.
    #[LiveProp(writable: true, url: true)]
    public string $query = '';

    // No writable, so the browser can read this but never change it.
    #[LiveProp]
    public int $limit = 10;

    // The repository is injected on the constructor, type-hinted, not
    // fetched out of the container.
    public function __construct(private readonly ArticleRepository $articles)
    {
    }

    #[LiveAction]
    public function clear(): void
    {
        $this->query = '';
    }

    // Called once per render from the template as this.results, so this
    // query runs on every keystroke that survives the debounce.
    public function getResults(): array
    {
        return $this->articles->findMatching($this->query, $this->limit);
    }
}

When to use it

  • Filter a list of articles as the user types, with the filtering staying in SQL.
  • Build a multi-step wizard whose current step and collected data live on the server.
  • Show a recalculated order total each time a quantity changes.
  • Re-render a dashboard card when its date range select changes.

More examples

The component template

data-model names the LiveProp an input writes to, and modifiers in front of it change when that happens: debounce(300) waits, on(change) waits for the field to lose focus, norender updates the value without re-rendering. data-loading gives the user feedback for the round trip you have just introduced. Note that nothing here is escaped with |raw: the search term came from the user, and auto-escaping is what stops it becoming markup.

Example · twig
{# templates/components/ArticleSearch.html.twig #}

{# One root element, and {{ attributes }} on it: the component's state #}
{# and its Stimulus controller are written into this tag.              #}
<div {{ attributes }}>
    {# 300ms after the last keystroke, send `query` and re-render. #}
    <input type="search" data-model="debounce(300)|query" value="{{ query }}">

    {# Longhand: the two attributes a live action is made of. #}
    <button data-action="live#action" data-live-action-param="clear">Clear</button>

    {# Shorthand: live_action() renders exactly those two attributes. #}
    <button {{ live_action('clear') }}>Clear</button>

    {# Visible only while a request is in flight. #}
    <span data-loading="show">Searching</span>

    <ul>
        {# this.results calls getResults(), which did one query. #}
        {% for article in this.results %}
            <li>{{ article.title }}</li>
        {% endfor %}
    </ul>
</div>

An action with arguments

The template triggers this with {{ live_action('removeLine', {lineId: line.id}) }}. Treat the arguments as untrusted input, exactly as you would a query parameter: here the id is looked up inside the cart the component already holds, so a value belonging to someone else's cart finds nothing. Services are injected per action rather than on the constructor when only one action needs them.

Example · php
<?php

// src/Twig/Components/CartSummary.php

declare(strict_types=1);

namespace App\Twig\Components;

use App\Entity\Cart;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
final class CartSummary
{
    use DefaultActionTrait;

    // An entity LiveProp is stored as its id and loaded again each render.
    #[LiveProp]
    public Cart $cart;

    // #[LiveArg] marks a parameter whose value comes from the template.
    // Parameters without it are resolved as services, like controller
    // arguments are.
    #[LiveAction]
    public function removeLine(
        #[LiveArg] int $lineId,
        EntityManagerInterface $entityManager,
    ): void {
        $line = $this->cart->findLine($lineId);

        if (null !== $line) {
            $this->cart->removeLine($line);

            $entityManager->remove($line);
            $entityManager->flush(); // remove() schedules, flush() writes
        }
    }
}

Rendering the component from a page

These are three renders of one component: the tag syntax and the component() function are interchangeable, and the third adds deferred loading. Passing props from the page sets the initial LiveProp values. Deferred loading is worth it when a component is expensive and not the reason the user opened the page; the component template can define a placeholder macro to show in the meantime.

Example · twig
{# templates/article/index.html.twig #}

{# Shorthand: the HTML component syntax. A colon in front of the name #}
{# passes a Twig expression instead of a string.                      #}
<twig:ArticleSearch query="symfony" :limit="25" />

{# Longhand: the same render through the component() function. #}
{{ component('ArticleSearch', { query: 'symfony', limit: 25 }) }}

{# Send the page immediately and fetch this component in a second #}
{# request, so its query does not delay the first byte.           #}
{{ component('ArticleSearch', { query: 'symfony', loading: 'defer' }) }}

Discussion

  • Be the first to comment on this lesson.