Aller au contenu principal

Symfony UX & Live Components : des interfaces réactives sans SPA

Corentin Boutillier
10 min de lecture
2 vues

Pourquoi viser la réactivité… sans tomber dans la complexité des SPA

Les Single-Page Applications ont longtemps été la voie royale pour offrir une expérience fluide. Mais elles viennent avec un coût élevé : bundle JavaScript volumineux, duplication des modèles côté client/serveur, SEO plus complexe, accessibilité parfois négligée, et un cycle de build/maintenance lourd.

Avec Symfony UX, Stimulus et Live Components, vous obtenez des interfaces réactives et modernes tout en conservant vos forces côté serveur : rendu côté serveur (SSR), sécurité unifiée, validation back-end, et un budget JavaScript minimal. Résultat : des pages rapides, accessibles et simples à maintenir.

Symfony UX en bref

Symfony UX est un ensemble d’outils qui misent sur l’approche progressive enhancement :

  • Stimulus : un micro-framework JavaScript orienté contrôleurs et data-attributes, idéal pour des comportements ciblés.
  • Twig Components : des composants serveur réutilisables, avec un contrat clair (propriétés, template).
  • Live Components : des composants Twig “vivants” qui synchronisent l’état avec le serveur sans écrire de logique front complexe.

Idée clé : vous restez dans Twig et PHP. Le serveur rend le HTML, puis un contrôleur Stimulus applique des mises à jour ciblées du DOM (diff) lors des interactions. Pas de SPA, pas de re-écriture complète du front.

Live Components : comment ça marche

  • Le composant est une classe PHP annotée, couplée à un template Twig.
  • Les propriétés marquées comme “live” (LiveProp) sont synchronisées côté client.
  • Les actions (LiveAction) sont invoquées via des data-attributes (pas besoin d’API dédiée).
  • Lors d’une interaction, le serveur renvoie le HTML du composant ; le client applique un morphing du DOM (diff), ce qui minimise les reflows.

Avantages :

  • Aucune duplication de logique métier côté client.
  • Accessibilité native (vous rendez du HTML sémantique).
  • SEO-friendly (SSR par défaut).
  • Performances maîtrisées (diff partiel, pas de gros bundle).

Exemple 1 : recherche incrémentale avec debounce

Objectif : un champ de recherche qui filtre des produits au fur et à mesure, sans SPA.

Composant PHP

<?php
// src/Twig/Components/ProductSearchComponent.php
namespace App\Twig\Components;

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

#[AsLiveComponent('product_search')]
class ProductSearchComponent
{
    use DefaultActionTrait;

    public function __construct(private ProductRepository $repo) {}

    #[LiveProp(writable: true)]
    public ?string $q = '';

    public function getResults(): array
    {
        if (!$this->q) {
            return [];
        }

        // Pensez à limiter et indexer en base pour de vraies perfs
        return $this->repo->searchByQuery($this->q, limit: 10);
    }

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

Template Twig du composant

{# templates/components/product_search.html.twig #}
<div {{ init_live_component(this) }} aria-live="polite">
  <form class="stack" role="search" aria-label="Recherche produits">
    <label for="q" class="sr-only">Recherche</label>
    <input
      id="q"
      type="search"
      name="q"
      placeholder="Rechercher un produit…"
      data-model="q|debounce(300)"
      autocomplete="off"
    />

    <button
      type="button"
      data-action="live#action"
      data-live-action="reset"
      aria-label="Réinitialiser la recherche"
    >
      Réinitialiser
    </button>
  </form>

  <ul class="results" aria-live="polite">
    {% for product in this.results %}
      <li>
        <a href="{{ path('product_show', {id: product.id}) }}">{{ product.name }}</a>
        <small>— {{ product.price|number_format(2, ',', ' ') }} €</small>
      </li>
    {% else %}
      <li><em>Aucun résultat</em></li>
    {% endfor %}
  </ul>
</div>

Intégration dans une page

{# templates/catalog/index.html.twig #}
<section>
  <h2>Catalogue</h2>
  {{ component('product_search') }}
</section>

Points clés :

  • data-model="q|debounce(300)" limite les allers-retours réseau.
  • aria-live informe les lecteurs d’écran des mises à jour.
  • Pas de JS personnalisé : le contrôleur Stimulus “live” gère la synchro.

Exemple 2 : liste paginée et tri avec defer

Objectif : pagination et tri d’un tableau, sans recharger toute la page.

Composant PHP

<?php
// src/Twig/Components/OrdersTableComponent.php
namespace App\Twig\Components;

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

#[AsLiveComponent('orders_table')]
class OrdersTableComponent
{
    use DefaultActionTrait;

    public function __construct(private OrderRepository $repo) {}

    #[LiveProp(writable: true)]
    public int $page = 1;

    #[LiveProp(writable: true)]
    public string $sort = 'createdAt_desc';

    public function getPager(): \App\Util\Pager
    {
        return $this->repo->paginateAndSort($this->page, 20, $this->sort);
    }
}

Template Twig

{# templates/components/orders_table.html.twig #}
<div {{ init_live_component(this) }}>
  <div class="toolbar">
    <label for="sort" class="sr-only">Trier</label>
    <select id="sort" data-model="sort|defer">
      <option value="createdAt_desc">Plus récents</option>
      <option value="createdAt_asc">Plus anciens</option>
      <option value="total_desc">Montant décroissant</option>
      <option value="total_asc">Montant croissant</option>
    </select>
    <button data-action="live#update">Appliquer</button>
  </div>

  <table>
    <thead>
      <tr>
        <th>Commande</th>
        <th>Date</th>
        <th>Client</th>
        <th class="num">Total</th>
      </tr>
    </thead>
    <tbody>
      {% for order in this.pager.items %}
        <tr>
          <td>#{{ order.id }}</td>
          <td>{{ order.createdAt|date('d/m/Y H:i') }}</td>
          <td>{{ order.customerName }}</td>
          <td class="num">{{ order.total|number_format(2, ',', ' ') }} €</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>

  <nav class="pagination" aria-label="Pagination">
    {% for p in 1..this.pager.pages %}
      <a
        href="?page={{ p }}"
        data-model="page"
        data-value="{{ p }}"
        aria-current="{{ p == this.pager.current ? 'page' : 'false' }}"
        class="{{ p == this.pager.current ? 'is-active' : '' }}"
      >
        {{ p }}
      </a>
    {% endfor %}
  </nav>
</div>

Points clés :

  • Le sélecteur de tri est en “defer” : rien ne part au serveur tant qu’on n’appuie pas sur “Appliquer”.
  • La pagination garde un href pour un fallback sans JS, mais est aussi branchée au modèle live.

Exemple 3 : édition en ligne avec validation serveur

Composant PHP

<?php
// src/Twig/Components/InlineCustomerFormComponent.php
namespace App\Twig\Components;

use App\Entity\Customer;
use App\Form\CustomerType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('inline_customer_form')]
class InlineCustomerFormComponent
{
    use DefaultActionTrait;

    public ?Customer $customer = null;
    public \Symfony\Component\Form\FormView $formView;

    public function __construct(
        private FormFactoryInterface $forms,
        private RequestStack $requests
    ) {}

    public function mount(Customer $customer): void
    {
        $this->customer = $customer;
        $this->createFormView();
    }

    private function createFormView(): void
    {
        $form = $this->forms->create(CustomerType::class, $this->customer, [
            'method' => 'POST',
        ]);
        $form->handleRequest($this->requests->getCurrentRequest());
        $this->formView = $form->createView();
    }

    #[LiveAction]
    public function save(): void
    {
        // La validation Form/Constraint est côté serveur
        $this->createFormView();
        // Persistez si valide (ex : EntityManager)
        // $this->em->flush();
    }
}

Template Twig

{# templates/components/inline_customer_form.html.twig #}
<div {{ init_live_component(this) }}>
  {{ form_start(this.formView, { attr: { 'data-action': 'live#action', 'data-live-action': 'save' } }) }}
    {{ form_row(this.formView.name) }}
    {{ form_row(this.formView.email) }}
    <div class="actions">
      <button type="submit">Enregistrer</button>
    </div>
  {{ form_end(this.formView) }}
</div>

Points clés :

  • Vous bénéficiez de la validation Symfony Form/Validator sans dupliquer les règles côté client.
  • Retour d’erreurs immédiat via re-render partiel.

Progressive enhancement et accessibilité

  • Préservez des href et des form actions : vos composants restent utilisables sans JS. Le live vient en bonus.
  • Utilisez aria-live et gérez le focus après mise à jour (ex : remettre le focus sur un bouton ou premier élément pertinent).
  • Préférez les éléments sémantiques (button, nav, table, form). Le HTML sémantique + SSR est votre meilleur allié A11Y.
  • Annoncez les changements de contexte (ex : “X résultats trouvés”).

Rendu partiel, cache et perfs

  • Rendu granulaire : chaque composant ne re-render que sa zone, avec un diff DOM côté client. Limitez la taille du fragment pour des updates rapides.
  • Debounce/Defer : appliquez-les systématiquement aux champs “à frappe” et aux sélecteurs lourds.
  • Côté base de données :
    • Indexez les colonnes recherchées/sortables.
    • Paginer systématiquement.
    • Préférez des DTO/queries ciblées plutôt que de charger des graphes d’entités géants.
  • Cache HTTP initial : la page globale peut être mise en cache (CDN, ESI/SSI si nécessaire), tandis que les interactions live restent légères.
  • Charge utile : ne mettez en LiveProp que l’état nécessaire. Lisez le reste depuis des getters (calculés côté serveur) pour éviter la sérialisation d’objets massifs.

Quand utiliser Stimulus “pur” vs Live Components ?

  • Stimulus pur :
    • Animation/UX locale sans aller au serveur.
    • Petits comportements décoratifs (accordéons, onglets, auto-focus).
  • Live Components :
    • Interaction dépendante de données serveur (recherche, tri, formulaire, actions avec effets de validation/sécurité).
    • Besoin de mettre à jour partiellement le DOM sans écrire d’API JSON.

Les deux se combinent parfaitement : Stimulus pour l’UX côté client, Live Components pour les données et la logique.

Erreurs fréquentes et comment les éviter

  • Sur-solliciter le serveur : toujours mettre un debounce sur les inputs de recherche.
  • Tout mettre en LiveProp : stocker seulement l’état nécessaire, pas des entités entières.
  • Oublier l’accessibilité : aria-live, focus, labels explicites.
  • Ne pas prévoir de fallback : gardez les href/actions pour une expérience dégradée correcte.
  • Ignorer la sécurité serveur : les LiveAction passent par le back, profitez-en pour vérifier droits et CSRF.

Conclusion

Symfony UX et les Live Components permettent de livrer des interfaces modernes, dynamiques et accessibles sans la lourdeur d’une SPA. Vous capitalisez sur vos forces côté serveur (sécurité, validation, SEO, performances), tout en offrant une expérience utilisateur fluide grâce à des mises à jour partielles et des patterns éprouvés (debounce, defer, pagination, inline edit).

Vous voulez auditer votre front, migrer une SPA coûteuse ou accélérer vos écrans critiques avec Symfony UX ? Contactez-nous pour un atelier d’architecture ou un POC guidé. Nous vous aiderons à choisir les bons patterns, à optimiser les performances et à former votre équipe.

Partager cet article

Logo Vulcain Développement - Développeur Symfony expert vulcain.agency

Développeur Full-Stack freelance expert
Créateur d'applications web sur mesure

📧 vulcain.developpement@gmail.com
📍 Saint-Lô, France

🏗️ Développement Symfony

🔗 API Platform

🏢 Solutions Métier

Liens Rapides