Доска задач — экран, на котором обычно ломается решение «рендерим на сервере». Карточка едет за курсором, колонка подсвечивается, счётчик меняется, и всё это должно происходить до того, как сервер вообще узнает о перемещении.
Я собрал доску на 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 и из консольных команд. Дублирования правил между клиентом и сервером нет вообще — и это главный выигрыш, который сложно оценить, пока не поработаешь с проектом, где такое дублирование есть.
Чего не хватает: обновления доски, когда карточку двигает коллега. Для этого над компонентом работает отдельный слой с потоком серверных событий, и стыковать их пришлось вручную.