Синхронизация выделения: диаграмма ⇄ таблица
Одна и та же сущность рисуется в интерфейсе дважды — узлом в диаграмме и строкой в таблице. При наведении или клике в одной панели нужно подсветить совпадение в другой. Классическая UI-задача, и в любом фреймворке она решается через одно из трёх:
- Поднять состояние в общий родитель — диаграмма и таблица навсегда связаны через него.
- Пробрасывать
selectedId+onSelectпропсами через все компоненты между ними — бойлерплейт, утечка ответственности. - Общий стор, подключённый к каждому листу — со временем превращается в god-object, когда добавляются ещё три синхронизируемые панели.
Triggery делит задачу пополам:
- правило («событие ховера → “эта сущность теперь под мышкой”») живёт в одном файле триггера,
- результирующее состояние живёт в крошечном сторе рядом,
- две панели — чистые потребители, они не знают друг о друге, только о сторе.
Файловая раскладка
Заголовок раздела «Файловая раскладка»- README.md обзорное описание
- index.html точка входа Vite
Директорияsrc/
- App.tsx продьюсеры + реакторы
- main.tsx точка входа
- store.ts крошечный стор — реактор пишет, обе панели читают
Директорияtriggers/
- index.ts правило — события, условия, действия, обработчик
Сценарий
Заголовок раздела «Сценарий»Маленькая доменная модель — Order, Invoice, Customer, Product, Shipment — рисуется в двух независимых панелях:
- Диаграмма на SVG с узлами и связями.
- Таблица с одной строкой на сущность.
Требуемое поведение:
- Навёл на строку — соответствующий узел подсвечивается.
- Навёл на узел — соответствующая строка подсвечивается.
- Кликнул в любой панели — выделение фиксируется.
- Панели монтируются независимо. Если на странице только таблица — всё работает.
Триггер
Заголовок раздела «Триггер»import { createTrigger } from '@triggery/core';
export const selectionTrigger = createTrigger<{
events: {
'entity:hover': string | null;
'entity:select': string | null;
};
actions: {
setHovered: string | null;
setSelected: string | null;
};
}>({
id: 'entity-selection-sync',
events: ['entity:hover', 'entity:select'],
handler({ event, actions }) {
if (event.name === 'entity:hover') actions.setHovered?.(event.payload);
else actions.setSelected?.(event.payload);
},
});Два события, два действия — всё правило целиком.
Крошечный стор
Заголовок раздела «Крошечный стор»Регистрации useAction работают по принципу last-mount-wins — у каждого действия активен только самый свежезамонтированный обработчик. Это правильно для «я — побочный эффект этого правила». Но не годится для fan-out на N строк сразу. Поэтому: один реактор пишет в стор, каждая строка/узел читает из стора.
import { useSyncExternalStore } from 'react';
type State = { hoveredId: string | null; selectedId: string | null };
let state: State = { hoveredId: null, selectedId: null };
const listeners = new Set<() => void>();
const subscribe = (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
const getSnapshot = () => state;
export const setHoveredId = (id: string | null) => {
if (state.hoveredId === id) return;
state = { ...state, hoveredId: id }; for (const l of listeners) l();
};
export const setSelectedId = (id: string | null) => {
if (state.selectedId === id) return;
state = { ...state, selectedId: id }; for (const l of listeners) l();
};
export const useSelection = () => useSyncExternalStore(subscribe, getSnapshot, getSnapshot);В большом приложении это был бы Zustand / Redux / Jotai / Signals. Здесь оставляем без сторонних зависимостей.
Связываем триггер со стором — один раз
Заголовок раздела «Связываем триггер со стором — один раз»function StoreBridge() {
useAction(selectionTrigger, 'setHovered', setHoveredId);
useAction(selectionTrigger, 'setSelected', setSelectedId);
return null;
}Один монтаж. Конкурирующих реакторов нет. Положи это где угодно под провайдером рантайма.
Обе панели потребляют стор
Заголовок раздела «Обе панели потребляют стор»Каждая панель отправляет события на mouse-in / click и читает выделение через useSelection(). Друг с другом не разговаривают.
function Node({ entity }: { entity: Entity }) {
const hover = useEvent(selectionTrigger, 'entity:hover');
const select = useEvent(selectionTrigger, 'entity:select');
const { hoveredId, selectedId } = useSelection();
const hovered = hoveredId === entity.id;
const selected = selectedId === entity.id;
return (
<g
onMouseEnter={() => hover(entity.id)}
onMouseLeave={() => hover(null)}
onClick={() => select(entity.id)}
>
<rect fill={selected ? '#af37c5' : hovered ? '#e6dffa' : '#fff'} … />
</g>
);
}function Row({ entity }: { entity: Entity }) {
const hover = useEvent(selectionTrigger, 'entity:hover');
const select = useEvent(selectionTrigger, 'entity:select');
const { hoveredId, selectedId } = useSelection();
return (
<tr
onMouseEnter={() => hover(entity.id)}
onMouseLeave={() => hover(null)}
onClick={() => select(entity.id)}
style={{ background: selectedId === entity.id ? '#af37c5'
: hoveredId === entity.id ? '#e6dffa' : 'transparent' }}
>
<td>{entity.id}</td><td>{entity.label}</td>
</tr>
);
}Две панели, без общего родителя, без проброса selectedId / onSelect пропсами. Удалить таблицу ничего не сломает: событие entity:hover всё равно отправляется, стор всё равно обновляется, никто не читает — жизнь продолжается.
Что здесь даёт именно Triggery
Заголовок раздела «Что здесь даёт именно Triggery»Сам стор-паттерн можно было бы сделать и без Triggery. Что добавляет Triggery:
- Один файл хранит правило. Сегодня это «событие → запись в стор». Завтра логика может ветвиться — отправить аналитику на
entity:select, игнорировать ховер при включённом DND, заглушить события из таблицы пока диаграмма в полноэкране. Каждое новое условие — строка в обработчике, а не россыпь по компонентам. - Код продьюсера не меняется при изменении правила.
<Node>умеет только одно — отправитьentity:hoverсо своим id. Любые новые поведения (debounce, скоупы по вкладкам, перенаправление в другой стор) для продьюсера невидимы. - Скоупированные варианты бесплатно. Если та же пара диаграмма + таблица живёт в двух соседних дашбордах, оборачиваем каждый в
<TriggerScope id="left">/<TriggerScope id="right">, и триггер читает события только из своего скоупа. См. Области видимости. - Триггер тестируется независимо. Отправил
entity:hover, проверил, что mock стора вызван с нужным id. Без DOM, без рендера, без провайдеров — см. Модульные тесты.
Варианты
Заголовок раздела «Варианты»- Мультивыделение — меняем тип payload на
Array<string>. Хелпер записи в стор сам решает, как объединять значения. - Навигация с клавиатуры — отправляем
entity:hoverизkeydown-обработчика на диаграмме или таблице; обе панели обновляются, не зная откуда событие пришло. - Третий UI (график, JSON-инспектор) — читаем
useSelection()в этом компоненте. Существующие панели не трогаем.