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”Two events, two actions — the rule itself.
The tiny store
Section titled “The tiny store”useAction in v0.10 is additive — every component that subscribes to the same (trigger, name) runs on every emit. You could wire every Row/Node to call useAction directly. But fan-out to N rows means N renders per emit, plus the cleanup churn each time a row remounts. Instead, one reactor writes to a store and every row/node reads from it via useSyncExternalStore.
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”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.
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.