From useEffect
useEffect is React’s general-purpose escape hatch — and most apps end up using it for jobs it wasn’t designed for: orchestrating side effects across feature boundaries. This page is the field guide for moving those useEffects into Triggery, one shape at a time.
Mental model mapping
Section titled “Mental model mapping”useEffect shape | Triggery counterpart |
|---|---|
| Effect body | Handler body |
useEffect(fn, [deps]) re-running on dep change | A useCondition getter — pulled lazily, no re-run |
return () => cleanup() | signal.addEventListener('abort', …) for async handlers; sync handlers don’t need cleanup |
setTimeout inside the effect | actions.debounce(ms).foo(p) — runtime owns the timer |
| Subscribing to an external source | useDomEvent / useSocketIoEvent / useWebSocketEvent from adapters |
if (cond) doThing() inside the effect | check.is('cond', …) inside the handler |
| Calling a Zustand/Redux/Jotai action | actions.someAction?.(payload) |
Pattern 1 — useEffect with cleanup (subscribe / unsubscribe)
Section titled “Pattern 1 — useEffect with cleanup (subscribe / unsubscribe)”useEffect(() => {
const handler = (msg: Message) => onMessage(msg);
socket.on('new-message', handler);
return () => socket.off('new-message', handler);
}, [onMessage]);const fireMessage = useEvent(messageTrigger, 'new-message');
useSocketIoEvent(socket, 'new-message', fireMessage);The @triggery/socket adapter handles on / off and gives fireMessage stable identity. Closure-captured callbacks disappear.
Pattern 2 — useEffect chasing dependencies
Section titled “Pattern 2 — useEffect chasing dependencies”useEffect(() => {
if (!user || !settings.notifications) return;
if (channelId === activeChannelId) return;
showToast({ title: msg.author, body: msg.text });
}, [user, settings, channelId, activeChannelId, msg]);The dep array balloons; missing one entry is a silent bug. With Triggery the rule lives in a single handler and the providers don’t re-render anyone:
handler({ event, conditions, actions, check }) {
if (event.payload.channelId === conditions.activeChannelId) return;
if (!check.is('settings', s => s.notifications)) return;
actions.showToast?.({ title: event.payload.author, body: event.payload.text });
}Every value the handler reads comes from a useCondition somewhere else — none of those components re-render when the event fires.
Pattern 3 — useEffect calling a store action
Section titled “Pattern 3 — useEffect calling a store action”const increment = useBadgeStore(s => s.increment);
useEffect(() => {
if (msg && msg.authorId !== currentUserId) increment(msg.channelId);
}, [msg, currentUserId, increment]);// trigger handler:
if (event.payload.authorId !== conditions.currentUserId) {
actions.incrementBadge?.(event.payload.channelId);
}
// reactor (in the badge feature):
useAction(messageTrigger, 'incrementBadge', channelId => increment(channelId));The store doesn’t leak into the trigger file — the reactor that owns the badge state owns the dispatch.
Pattern 4 — useEffect listening to a socket
Section titled “Pattern 4 — useEffect listening to a socket”// Chat.tsx
useEffect(() => {
socket.on('new-message', msg => {
setMessages(m => [...m, msg]);
});
return () => socket.off('new-message');
}, []);
// NotificationLayer.tsx — second listener for the same event
useEffect(() => {
socket.on('new-message', msg => {
if (settings.notifications) toast(msg.author);
});
return () => socket.off('new-message');
}, [settings]);Two listeners, two cleanups, two dep arrays, one event. Replace with one fireMessage and many useAction reactors — see the notification pipeline recipe for the full version.
Codemod
Section titled “Codemod”@triggery/codemod ships extract-trigger, which pulls the first useEffect(() => { … }, []) out of a file into a sibling *.trigger.ts stub and rewrites the component to call useEvent(...). It is intentionally mechanical — schema typing, condition extraction and cleanup logic stay your job.
npx triggery-codemod extract-trigger --name new-message src/Chat.tsx --dry-runFor anything more involved (effects with conditional deps, multiple effects sharing state, custom hooks) the codemod doesn’t help — but the patterns above tell you what to do by hand.
When to keep useEffect
Section titled “When to keep useEffect”Triggery is not a replacement for every useEffect. Keep it when:
- The side effect is purely local to one component and never grows (“toggle a class on click”).
- You’re subscribing to a browser API that only the component cares about (e.g. a
ResizeObserverwhose result is rendered by the same component). - You’re synchronising with refs (
ref.current?.focus()after a state change).
Anything that crosses a feature boundary is what Triggery is for.