Чат-приложение подписано на socket.io. Каждое входящее сообщение должно разойтись веером: инвалидировать запрос разговора, дописать сообщение в кеш списка и увеличить счётчик непрочитанных — но только для каналов, которые пользователю реально нужны, и только когда он сейчас на это сообщение не смотрит. Сам сокет остаётся в одном файле. Логика маршрутизации — в одном триггере. Побочные эффекты — у тех компонентов, которым они принадлежат.
import { createTrigger } from '@triggery/core';type Message = { id: string; channelId: string; authorId: string; text: string; at: number;};export const messageTrigger = createTrigger<{ events: { 'message-received': { channelId: string; message: Message }; }; conditions: { activeChannelId: string | null; subscribedChannels: ReadonlySet<string>; currentUserId: string; }; actions: { appendToList: { channelId: string; message: Message }; invalidatePreview: string; // channelId incrementUnread: string; // channelId };}>({ id: 'message-received', events: ['message-received'], required: ['currentUserId'], handler({ event, conditions, actions, check }) { const { channelId, message } = event.payload; // Кеш списка обновляется всегда — любой потребитель list-запроса // должен увидеть новое сообщение, даже если канал отфильтрован где-то ещё. actions.appendToList?.({ channelId, message }); // Превью в сайдбаре — только для каналов, на которые пользователь подписан. if (check.is('subscribedChannels', set => set.has(channelId))) { actions.invalidatePreview?.(channelId); } // Счётчик непрочитанных — только если канал не активен и сообщение не от самого пользователя. const isActive = conditions.activeChannelId === channelId; const isOwn = message.authorId === conditions.currentUserId; if (!isActive && !isOwn) { actions.incrementUnread?.(channelId); } },});
Обработчик читается сверху вниз как продуктовая спецификация. Добавить четвёртый побочный эффект (push-уведомление, лог в аналитику и т. п.), не трогая сокет, провайдеры и существующие реакторы, — это просто новый useAction где-то ещё в дереве.
Один компонент владеет сокетом. Через @triggery/socket он пробрасывает входящие события message в триггер. Само соединение живёт в родителе — мост лишь регистрирует слушателя.
Условия читаются лениво — только когда срабатывает триггер. Перерендер <ActiveChannelProvider> при переключении канала ничего не инвалидирует; новый геттер просто вернёт новое значение при следующем вызове.