A chat app subscribes to a socket.io connection. Every inbound message has to fan out: invalidate the conversation query, append the message to a list cache, and bump an unread badge — but only for channels the user actually cares about, and only when they aren’t already looking at the message. The socket itself stays in one file. Routing logic stays in one trigger. Side effects live with the components that own them.
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; // The list cache is always updated — every consumer of the list query // should see the new message even if the channel is filtered out elsewhere. actions.appendToList?.({ channelId, message }); // Sidebar preview — only for channels the user is subscribed to. if (check.is('subscribedChannels', set => set.has(channelId))) { actions.invalidatePreview?.(channelId); } // Unread badge — only if not actively viewing the channel and not the // user's own message. const isActive = conditions.activeChannelId === channelId; const isOwn = message.authorId === conditions.currentUserId; if (!isActive && !isOwn) { actions.incrementUnread?.(channelId); } },});
The handler reads top-to-bottom as a product spec. Adding a fourth side effect (push notification, log to analytics, etc.) doesn’t touch the socket, the providers, or any existing reactor — it adds one useAction somewhere else in the tree.
A single component owns the socket. It uses @triggery/socket to forward inbound message events into the trigger. The connection lives in a parent — the bridge just registers a listener.
Conditions are read lazily — only when the trigger fires. Re-rendering <ActiveChannelProvider> when the user switches channels does not invalidate anything; the new getter just returns the new value on next fire.
<ChatCacheReactor> doesn’t care about subscriptions or active channels. The trigger has already decided whether each action should run. The reactor just executes.