Analytics fan-out
The analytics anti-pattern: every button and every form imports a track(...) helper, which imports the Segment SDK, which imports Amplitude, which is conditional on a feature flag — and the moment you want to swap providers, half your codebase changes. Triggery flips it: components fire a generic ui:event, a single trigger routes it to the right destination, and swapping providers is a one-file change.
Scenario
Section titled “Scenario”- Every interesting UI action fires
ui:eventwith{ name, props }. - One trigger decides where the event goes — Segment, Amplitude, GA4, or all three — based on feature flags read from a condition.
- Three reactors own the SDKs. They never know what events exist, they only know how to forward an event to their own destination.
- Switching providers is a config flip, not a refactor.
File layout
Section titled “File layout”Directorysrc/
Directorytriggers/
- analytics.trigger.ts routing
Directoryfeatures/
Directoryanalytics/
- SegmentReactor.tsx
- AmplitudeReactor.tsx
- Ga4Reactor.tsx
- FlagsProvider.tsx provider for destinations
Directorydashboard/
- DashboardButton.tsx producer (just
fireUiEvent)
- DashboardButton.tsx producer (just
1. The trigger
Section titled “1. The trigger”import { createTrigger } from '@triggery/core';
export type UiEvent = {
name: string;
props?: Readonly<Record<string, unknown>>;
};
export type Destinations = {
segment: boolean;
amplitude: boolean;
ga4: boolean;
};
export const analyticsTrigger = createTrigger<{
events: {
'ui:event': UiEvent;
};
conditions: {
destinations: Destinations;
};
actions: {
sendToSegment: UiEvent;
sendToAmplitude: UiEvent;
sendToGa4: UiEvent;
};
}>({
id: 'analytics-fan-out',
events: ['ui:event'],
required: ['destinations'],
handler({ event, check, actions }) {
if (check.is('destinations', d => d.segment)) actions.sendToSegment?.(event.payload);
if (check.is('destinations', d => d.amplitude)) actions.sendToAmplitude?.(event.payload);
if (check.is('destinations', d => d.ga4)) actions.sendToGa4?.(event.payload);
},
});The trigger does nothing but route. Adding a fourth destination is one schema entry, one check.is, and one new reactor file — no producer changes.
2. Producers
Section titled “2. Producers”A button is now just a button. Compare with the old import { track } from './analytics' style — the producer has no idea analytics exist beyond firing a generic event.
import { useEvent } from '@triggery/react';
import { analyticsTrigger } from '../../triggers/analytics.trigger';
export function DashboardButton() {
const fireUiEvent = useEvent(analyticsTrigger, 'ui:event');
return (
<button
type="button"
onClick={() => {
fireUiEvent({ name: 'dashboard:cta-clicked', props: { variant: 'hero' } });
// …business logic of the actual click here
}}
>
Try it
</button>
);
}3. The flag provider
Section titled “3. The flag provider”A single condition exposes which destinations are on. You can wire this to LaunchDarkly, your own flag service, a localStorage toggle, anything — the trigger doesn’t care.
import { useCondition } from '@triggery/react';
import { analyticsTrigger, type Destinations } from '../../triggers/analytics.trigger';
export function AnalyticsFlagsProvider({
destinations,
children,
}: {
destinations: Destinations;
children: React.ReactNode;
}) {
useCondition(analyticsTrigger, 'destinations', () => destinations, [destinations]);
return <>{children}</>;
}4. The reactors
Section titled “4. The reactors”Three components — each one owns exactly one SDK. The runtime calls the reactor only when the trigger told it to.
import { useAction } from '@triggery/react';
import { analyticsTrigger } from '../../triggers/analytics.trigger';
import { segment } from '../../lib/analytics-segment';
export function SegmentReactor() {
useAction(analyticsTrigger, 'sendToSegment', ({ name, props }) => {
segment.track(name, props ?? {});
});
return null;
}import { useAction } from '@triggery/react';
import { amplitude } from '../../lib/analytics-amplitude';
import { analyticsTrigger } from '../../triggers/analytics.trigger';
export function AmplitudeReactor() {
useAction(analyticsTrigger, 'sendToAmplitude', ({ name, props }) => {
amplitude.track(name, props as Record<string, unknown>);
});
return null;
}import { useAction } from '@triggery/react';
import { analyticsTrigger } from '../../triggers/analytics.trigger';
declare global { interface Window { gtag?: (...args: unknown[]) => void } }
export function Ga4Reactor() {
useAction(analyticsTrigger, 'sendToGa4', ({ name, props }) => {
window.gtag?.('event', name, props ?? {});
});
return null;
}5. Wire it up
Section titled “5. Wire it up”import { useState } from 'react';
import { AnalyticsFlagsProvider } from './features/analytics/FlagsProvider';
import { AmplitudeReactor } from './features/analytics/AmplitudeReactor';
import { Ga4Reactor } from './features/analytics/Ga4Reactor';
import { SegmentReactor } from './features/analytics/SegmentReactor';
import { DashboardButton } from './features/dashboard/DashboardButton';
export function App() {
const [destinations] = useState({ segment: true, amplitude: false, ga4: true });
return (
<AnalyticsFlagsProvider destinations={destinations}>
<SegmentReactor />
<AmplitudeReactor />
<Ga4Reactor />
<DashboardButton />
</AnalyticsFlagsProvider>
);
}Scheduling
Section titled “Scheduling”Analytics calls are typically not on the critical path of a click — you’d like them off the main thread, but you don’t want them to delay rendering either. Triggery uses a microtask scheduler by default, which is the right answer here: the click handler returns synchronously, React re-renders, then the trigger runs.
export const analyticsTrigger = createTrigger<{ /* …schema… */ }>({
id: 'analytics-fan-out',
schedule: 'microtask', // default — explicit for clarity
// ...
});Test it
Section titled “Test it”import { createTestRuntime, mockAction, mockCondition } from '@triggery/testing';
import { describe, expect, it, vi } from 'vitest';
import { analyticsTrigger } from './analytics.trigger';
describe('analytics-fan-out', () => {
it('routes to enabled destinations only', async () => {
const rt = createTestRuntime({ triggers: [analyticsTrigger] });
const sendToSegment = vi.fn();
const sendToAmplitude = vi.fn();
const sendToGa4 = vi.fn();
mockCondition(rt, analyticsTrigger, 'destinations', {
segment: true, amplitude: false, ga4: true,
});
mockAction(rt, analyticsTrigger, 'sendToSegment', sendToSegment);
mockAction(rt, analyticsTrigger, 'sendToAmplitude', sendToAmplitude);
mockAction(rt, analyticsTrigger, 'sendToGa4', sendToGa4);
await rt.fire('ui:event', { name: 'cta-clicked', props: { variant: 'hero' } });
expect(sendToSegment).toHaveBeenCalledOnce();
expect(sendToGa4).toHaveBeenCalledOnce();
expect(sendToAmplitude).not.toHaveBeenCalled();
});
});