When Not to Reach for UX

Where server-rendered interactivity stops paying off, and what an API-first split with a client-side frontend actually costs.

Symfony UX works by keeping state on the server. That is both its strength and its boundary. Any interaction that needs the server's opinion costs a round trip, and round trips carry latency you do not control.

Where it fits

Forms, filters, search, tables, wizards, admin screens: anywhere the server already owns the data and the user expects to wait a moment for it. For behaviour that is purely local, write a plain Stimulus controller instead and make no request at all. A character counter, a disclosure toggle and a confirmation dialog need no server.

Where it does not

Drag-and-drop canvases, rich text editors, anything redrawing while a pointer moves, offline use, optimistic updates, and multi-step client state that must survive a bad connection. A Live Component re-rendering on pointer move is a request on pointer move. If the interaction has its own model in the browser, something has to own that model, and a Twig template cannot.

The API-first split

The honest alternative is to stop rendering HTML for that part of the product. Symfony answers JSON; the frontend is a separate application with its own tooling and its own router. Symfony's own guidance points this way for React, Vue or Svelte front ends. The cost is real: two things to deploy, serialisation to maintain, authorisation rules expressed on both sides, and client-side routing. Pay it when the interaction demands it.

A mixed codebase is normal. Twig and Turbo for most routes, one client-rendered area where it earns its place.

Example

Example · php
<?php

// src/Controller/Api/PlanController.php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Repository\PlanRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

// The route prefix and the name prefix both apply to every action below,
// so this class answers GET /api/plans as route api_plan_index.
#[Route('/api/plans', name: 'api_plan_')]
final class PlanController extends AbstractController
{
    public function __construct(private readonly PlanRepository $plans)
    {
    }

    #[Route('', name: 'index', methods: ['GET'])]
    public function index(Request $request): JsonResponse
    {
        // query is the query string. request would be the POST body.
        $term = $request->query->getString('q');

        // One query returns the summary rows as arrays, so nothing below
        // touches a lazy relation and no further queries are issued.
        $rows = $this->plans->findSummaries($term, 20);

        return $this->json($rows);
    }
}

When to use it

  • Decide whether a drag-and-drop scheduler should be a Live Component or a client-side app.
  • Serve an existing React admin from the same Symfony project, with Symfony as a JSON API.
  • Replace a Live Component firing a request per pointer move with a Stimulus controller.
  • Route every URL under /editor to one shell template so refreshes do not 404.

More examples

Local behaviour needs no request

Wired up with data-action="input->character-count#update", this runs on every keystroke and sends nothing. The same feature as a Live Component would be a POST per keystroke to compute a subtraction. The question to ask of any interaction is whether the server knows something the browser does not; when the answer is no, Stimulus alone is the cheaper and faster choice. Note that the server still enforces the length limit on submit, because this counter is a hint, not a constraint.

Example · javascript
// assets/controllers/character_count_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['input', 'count'];
    static values = { max: Number };

    connect() {
        this.update();
    }

    update() {
        const remaining = this.maxValue - this.inputTarget.value.length;

        this.countTarget.textContent = `${remaining} characters left`;
        this.countTarget.hidden = remaining === this.maxValue;
    }
}

Handing server data to a client-rendered area

This is the middle path: the page is still a normal Twig route, and one element is a client-owned island booted by a Stimulus controller. The plan is passed as a value, not fetched in a second request, and the save URL is generated by the router rather than hardcoded in JavaScript. Auto-escaping handles the JSON inside the attribute, so no |raw is needed or wanted. Use this when exactly one screen needs a client-side model and the rest of the application does not.

Example · twig
{# templates/editor/show.html.twig #}

{# The server renders the data once. From here the browser owns it: #}
{# dragging a wall redraws locally and sends nothing.               #}
<div data-controller="floorplan-editor"
     data-floorplan-editor-plan-value="{{ plan|json_encode }}"
     data-floorplan-editor-save-url-value="{{ path('api_plan_update', {id: planId}) }}">
</div>

One shell route for a client-side router

When a section of the application is client-rendered, its URLs still have to resolve on the server, otherwise a refresh or a shared link returns 404. GET /editor/plans/7 returns 200 with the same shell page every time, and the client-side router reads the path and decides what to show. Keep this route narrow: a catch-all at the root would swallow every other URL in the project, including the JSON API the shell then calls.

Example · php
<?php

// src/Controller/EditorShellController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class EditorShellController extends AbstractController
{
    // '.*' as a requirement lets the placeholder match slashes, which it
    // does not by default, so /editor/plans/7/walls reaches this action.
    // The default makes /editor itself match as well.
    #[Route(
        '/editor/{path}',
        name: 'app_editor_shell',
        requirements: ['path' => '.*'],
        defaults: ['path' => ''],
        methods: ['GET'],
    )]
    public function shell(): Response
    {
        return $this->render('editor/shell.html.twig');
    }
}

Discussion

  • Be the first to comment on this lesson.