Diagram ⇄ table selection sync
The same entity is rendered twice in the UI — once as a node on a diagram, once as a row in a table. Hovering or clicking either pane should reflect in the other. Classic UI problem, ugly in every framework:
- Lifted state in a parent component couples diagram and table forever.
- Prop-drilling
selectedId+onSelectthrough every component in between — boilerplate, leaks the concern. - A shared store wired to every leaf grows into a god-object once you add three more synced panes.
Triggery splits the problem in two:
- the rule (“hover event → ‘this entity is now hovered’”) lives in one trigger file,
- the resulting state lives in a tiny store next to it,
- the two panes are pure consumers — they don’t know about each other, only about the store.
File layout
Section titled “File layout”- README.md narrative overview
- index.html Vite entry
Directorysrc/
- App.tsx producers + reactors live here
- main.tsx bootstrap
- store.ts tiny store — single reactor writes, both panes read
Directorytriggers/
- index.ts the rule — events, conditions, actions, handler
Scenario
Section titled “Scenario”A small data model — Orders, Invoices, Customers, Products, Shipments — rendered in two independent panes:
- An SVG diagram with nodes and edges.
- A table with one row per entity.
Required behaviour:
- Hover a row → matching node lights up.
- Hover a node → matching row lights up.
- Click either → selection pins.
- Both panes are mountable independently. A page that only shows the table should still work.
The trigger
Section titled “The trigger”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);
},
});Two events, two actions — the rule itself.
The tiny store
Section titled “The tiny store”useAction registrations are last-mount-wins — only the latest-mounted reactor for a given action actually runs. That’s correct for “I am THE side-effect”. It’s wrong for fanout-to-N rows. Instead, one reactor writes to a store and every row/node reads from it.
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);In a larger app this would be Zustand / Redux / Jotai / Signals. Here we keep it dependency-free.
Wire the trigger to the store — once
Section titled “Wire the trigger to the store — once”function StoreBridge() {
useAction(selectionTrigger, 'setHovered', setHoveredId);
useAction(selectionTrigger, 'setSelected', setSelectedId);
return null;
}One mount. No competing reactors. Drop it anywhere under the runtime provider.
The two panes consume the store
Section titled “The two panes consume the store”Each pane fires events on mouse-in / click and reads selection from useSelection(). They never talk to each other.
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>
);
}Two panes, no shared parent, no selectedId / onSelect prop-drilling. Removing the table breaks nothing — entity:hover events still fire, the store still updates, nobody reads them, life goes on.
What Triggery gives you here
Section titled “What Triggery gives you here”The store-based pattern alone could be done without Triggery. What Triggery adds:
- One file holds the rule. Today it’s “event → store write”. Tomorrow it might branch — fire analytics on
entity:select, ignore hover events during DND, suppress events from the table when the diagram is fullscreen. Each new branch is a line in the trigger handler, not a sprinkle across components. - The producer code doesn’t change when the rule changes. A
<Node>only knows how to fireentity:hoverwith its id. Whatever new behaviour the rule sprouts — debounce, scope-per-tab, redirect to a different store — is invisible to the producer. - Scoped variants come for free. If the same
<Diagram>+<Table>pair lives in two side-by-side dashboards, wrap each in<TriggerScope id="left">/<TriggerScope id="right">and the trigger reads only events from the matching scope. See Scopes. - The trigger is independently testable. Fire
entity:hover, assert the store mock was called with the right id. No DOM, no rendering, no providers — see Unit testing.
Variants
Section titled “Variants”- Multi-select — change the event payload from
string | nulltoArray<string>. The store-write helper decides how to merge. - Keyboard navigation — fire
entity:hoverfrom akeydownhandler on the diagram or the table; both panes update without knowing where the event came from. - Wire a third UI (chart, JSON inspector) — read from
useSelection()in that component. Existing panes don’t change.