almdev Технический блог

Kanban на Live Components: перетаскивание без своего фронтенда
Сервер отвечает за рендер и бизнес-логику, клиент — за живое перетаскивание.

Доска задач на Symfony UX Live Components: что держать в состоянии компонента, как устроено перетаскивание, откат при отказе сервера, гонки между двумя пользователями и предел применимости.

Подкатегория: Интерактивность

Чтение
6 мин
Технологии / версии
Symfony UX · Live Components · Twig · Kanban
26 фев 2026 · 6 мин · 1 просмотр · Frontend
Механическая рука переносит карточку между колонками Kanban-доски
Интерактивность 02/2026

Доска задач — экран, на котором обычно ломается решение «рендерим на сервере». Карточка едет за курсором, колонка подсвечивается, счётчик меняется, и всё это должно происходить до того, как сервер вообще узнает о перемещении.

Я собрал доску на Live Components и полгода живу с этим решением. Работает, но не так, как обещает документация.

Что такое Live Component

Twig Component — это обычный компонент шаблона: класс с данными, шаблон, никакой интерактивности. Live Component — тот же компонент, который умеет перерисовывать себя по запросу к серверу, не перезагружая страницу.

Механика такая: состояние компонента сериализуется в атрибуты элемента, действие вызывает запрос, сервер пересобирает компонент с новым состоянием и возвращает HTML, а клиентская часть аккуратно вливает изменения в существующий DOM через сравнение деревьев.

Ключевое слово — «вливает». Это не замена innerHTML: фокус в поле ввода сохраняется, состояние прокрутки не сбрасывается, элементы не пересоздаются, если не изменились.

Что держать в состоянии

Это главное решение при проектировании компонента. У свойств есть два режима: часть состояния, которая едет на клиент и обратно, и вычисляемые данные, которые собираются заново при каждом рендере.

Ниже сокращённый фрагмент компонента: шаблон, обработка пустых результатов и вспомогательные методы опущены.

#[AsLiveComponent('board')]
final class BoardComponent
{
    use DefaultActionTrait;

    #[LiveProp(writable: true)]
    public string $projectId;

    #[LiveProp(writable: true)]
    public ?string $sprintId = null;

    #[LiveProp(writable: true)]
    public ?string $assigneeFilter = null;

    public function __construct(
        private readonly IssueRepositoryInterface $issues,
        private readonly ProjectRepositoryInterface $projects,
        private readonly WorkflowService $workflow,
        private readonly AuthorizationCheckerInterface $authorization,
        private readonly EntityManagerInterface $entityManager,
    ) {}

    /** @return array<string, list<Issue>> */
    public function getColumns(): array
    {
        $projectId = Uuid::fromString($this->projectId);
        $project = $this->projects->find($projectId);

        if (!$project || !$this->authorization->isGranted('PROJECT_VIEW', $project)) {
            throw new NotFoundHttpException();
        }

        return $this->issues->groupedByStatus(
            $projectId,
            $this->sprintId ? Uuid::fromString($this->sprintId) : null,
            $this->assigneeFilter,
        );
    }
}

В состоянии — три строки: проект, спринт, фильтр. Всё.

Сами задачи в состоянии не хранятся, они читаются из базы на каждый рендер. Соблазн положить их в свойство есть, но это было бы ошибкой по двум причинам. Во-первых, состояние уезжает в атрибут элемента и подписывается — четыреста задач превратятся в сотни килобайт разметки. Во-вторых, состояние на клиенте устаревает, и после первого же изменения из другой вкладки доска покажет неправду. Writable-свойства всё равно приходят от клиента, поэтому перед выборкой я заново загружаю проект и проверяю право на просмотр.

Правило простое: в состоянии живут параметры выборки, а не её результат.

Действие переноса

Перетаскивание — единственная часть, где нужен настоящий клиентский код. Корневой элемент доски помечен атрибутом data-board-root, чтобы контроллер колонки обращался именно к родительской доске, а не к ближайшему дочернему Live Component. Ниже сокращённый фрагмент Stimulus-контроллера: библиотека даёт события начала и конца, обработчик вызывает действие компонента:

import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
import Sortable from 'sortablejs';

export default class extends Controller {
    static values = { status: String };

    async connect() {
        this.component = await getComponent(
            this.element.closest('[data-board-root]'),
        );

        this.sortable = Sortable.create(this.element, {
            group: 'board',
            animation: 150,
            onEnd: (event) => {
                this.component.action('moveIssue', {
                    issueId: event.item.dataset.issueId,
                    toStatus: event.to.dataset.boardColumnStatusValue,
                    position: event.newIndex,
                });
            },
        });
    }

    disconnect() {
        this.sortable?.destroy();
    }
}

Серверная часть — обычный метод с атрибутом:

#[LiveAction]
public function moveIssue(
    #[LiveArg] string $issueId,
    #[LiveArg] string $toStatus,
    #[LiveArg] int $position,
): void {
    $issue = $this->issues->find(Uuid::fromString($issueId));

    if (!$issue) {
        throw new NotFoundHttpException();
    }

    if (!$this->authorization->isGranted('ISSUE_EDIT', $issue)) {
        throw new AccessDeniedHttpException();
    }

    $status = IssueStatus::tryFrom($toStatus);

    if (!$status || $position < 0) {
        throw new BadRequestHttpException('Некорректные параметры перемещения');
    }

    $error = $this->workflow->transition($issue, $status, $position);

    if ($error !== null) {
        throw new UnprocessableEntityHttpException($error);
    }

    $this->entityManager->flush();
}

Метод ничего не возвращает и ничего не рендерит. После его выполнения компонент пересобирается сам, getColumns() вызывается заново и отдаёт актуальную картину.

Проверка прав — внутри действия, а не только в шаблоне. Действие компонента — это точно такой же публичный HTTP-эндпоинт, как контроллер, и вызвать его можно напрямую.

Оптимистичное обновление и откат

Библиотека перетаскивания уже переставила карточку в DOM до всякого запроса. Это и есть оптимистичное обновление — бесплатное, потому что его сделал не наш код.

Проблема в откате. Если сервер отказал, компонент перерисуется из базы, и карточка вернётся на место. Пользователь увидит прыжок без объяснения.

Я решил это двумя вещами. Первая — карточка помечается классом ожидания, пока идёт запрос, и слегка гаснет. Вторая — стабильный hook response:error на объекте компонента убирает стандартное окно ошибки и показывает уведомление. Обработчик подключён один раз отдельным Stimulus-контроллером на корневом элементе доски, а не на каждой колонке:

async connect() {
    const component = await getComponent(this.element);

    component.on('response:error', (_backendResponse, controls) => {
        controls.displayError = false;
        notify('error', 'Не удалось переместить задачу');
    });
}

statusText содержит название HTTP-статуса, а не безопасный текст доменной ошибки. Если нужно показать причину вроде «нельзя закрыть задачу с открытыми подзадачами», действие должно вернуть согласованный структурированный ответ, а клиент — разобрать и экранировать его. В сокращённом примере я оставляю нейтральное сообщение.

Гонки

Два человека двигают одну карточку одновременно. Первый переносит её в «В работе», второй — в «Готово».

Оба запроса дойдут, второй перезапишет первый. Для доски задач это приемлемо: последнее действие побеждает. Второй пользователь сразу увидит итоговое состояние, а клиент первого получит его при следующем обновлении доски.

Что неприемлемо — потеря порядка внутри колонки. Позиция задаётся числом, и два одновременных вставления на позицию 3 дают двум задачам одинаковый номер. Дальше сортировка становится случайной.

Лечится это на уровне сервиса: позиции пересчитываются в транзакции с блокировкой строки проекта.

$this->connection->transactional(function () use ($issue, $status, $position) {
    $this->connection->executeStatement(
        'SELECT 1 FROM project WHERE id = ? FOR UPDATE',
        [$issue->getProject()->getId()->toRfc4122()],
    );

    $this->reorderColumn($issue, $status, $position);
});

Блокировка на проект, а не на задачу: переставляются несколько строк сразу. В PostgreSQL с нативной колонкой uuid параметр передаётся канонической строкой; DBAL приводит его к типу колонки. В моём замере блокировка держалась миллисекунды.

Сколько запросов на перетаскивание

Первая версия выполняла три работы: действие переноса, полную перерисовку компонента и отдельный запрос счётчиков. Рендер доски на 200 задач занимал 340 мс.

Что сократил.

Счётчики стали частью того же рендера — они и так считаются из тех же данных, отдельный запрос был просто недосмотром.

Появилось разделение колонок на отдельные дочерние компоненты. При переносе между статусами обновляются исходная и целевая колонки, а не вся доска; при перестановке внутри статуса — одна. Это дало основной выигрыш: рендер в моём замере упал с 340 до 60 мс.

Итог — один запрос на перетаскивание и 60–90 мс до готовой картинки. Ощущается мгновенным, потому что карточка визуально уже там.

Где предел

Компонент становится плохой идеей, когда взаимодействий между обращениями к серверу больше двух.

Конкретный пример из того же проекта: планирование спринта, где задачи перетаскивают пачками между бэклогом и спринтом, а сумма оценок пересчитывается на каждое движение. Здесь на каждое действие нужен мгновенный локальный пересчёт, а задержка полного цикла «запрос — ответ» уже заметна.

Второй предел — размер разметки. Компонент сериализует состояние в атрибуты, а перерисовка отправляет HTML целиком. На доске в 500 карточек ответ становится больше 300 килобайт, и сравнение деревьев начинает подтормаживать на слабых машинах.

Я ограничил доску двумя сотнями карточек с явным сообщением «уточните фильтр». Это честнее, чем медленный интерфейс.

Что получилось в сумме

Доска работает, отдельного фронтенда нет, вся бизнес-логика перехода статусов осталась в одном сервисе на сервере и переиспользуется из API и из консольных команд. Дублирования правил между клиентом и сервером нет вообще — и это главный выигрыш, который сложно оценить, пока не поработаешь с проектом, где такое дублирование есть.

Чего не хватает: обновления доски, когда карточку двигает коллега. Для этого над компонентом работает отдельный слой с потоком серверных событий, и стыковать их пришлось вручную.

#symfony-ux #live-components #twig #kanban