Skip to content
GitHubXDiscord

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 + onSelect through 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.
Open in StackBlitz Open example on GitHub
  • 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

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:

  1. Hover a row → matching node lights up.
  2. Hover a node → matching row lights up.
  3. Click either → selection pins.
  4. Both panes are mountable independently. A page that only shows the table should still work.
src/triggers/selection.trigger.ts
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.

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.

src/store.ts
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.

src/App.tsx
function StoreBridge() {
  useAction(selectionTrigger, 'setHovered',  setHoveredId);
  useAction(selectionTrigger, 'setSelected', setSelectedId);
  return null;
}

One mount. No competing reactors. Drop it anywhere under the runtime provider.

Each pane fires events on mouse-in / click and reads selection from useSelection(). They never talk to each other.

Diagram node (one pane)
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>
  );
}
Table row (the other pane)
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.

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 fire entity:hover with 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.
  • Multi-select — change the event payload from string | null to Array<string>. The store-write helper decides how to merge.
  • Keyboard navigation — fire entity:hover from a keydown handler 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.