Skip to content
GitHubXDiscord

Onboarding flow

Onboarding flows are notorious for sprawling logic: “show step 2 only if step 1 is done AND the user is on this route AND they haven’t dismissed the tour AND they actually have access to the feature”. You either hard-code it in one giant useEffect chain, or you reach for a tour library that owns half your UI. Triggery’s third path: keep the rules in one trigger and let each step component announce itself to the trigger.

Open in StackBlitz Open example on GitHub

A two-step onboarding tour:

  1. Step 1 — “Try the search”: shown the first time the user lands on /dashboard, unless they’ve dismissed onboarding.
  2. Step 2 — “Save a filter”: shown the first time the user fires a feature:search-used event (after Step 1 has been completed).

State driving the rules:

  • <RouterProvider> exposes the current route.
  • <UserProvider> exposes { completedSteps, dismissed } from the user profile.
  • Features themselves announce usage via feature:* events.
  • Directorysrc/
    • Directorytriggers/
      • onboarding.trigger.ts what to show, when
    • Directoryfeatures/
      • Directoryrouter/
        • RouterProvider.tsx provider (route)
      • Directoryuser/
        • UserProvider.tsx provider (completedSteps, dismissed)
      • Directorydashboard/
        • DashboardSearch.tsx producer (fires feature:search-used)
      • Directoryonboarding/
        • OnboardingHost.tsx reactor (renders the active step)
src/triggers/onboarding.trigger.ts
import { createTrigger } from '@triggery/core';

export type StepId = 'try-search' | 'save-filter';

export const onboardingTrigger = createTrigger<{
  events: {
    'route-changed':      { to: string };
    'feature-discovered': { name: 'search-used' | 'filter-saved' };
  };
  conditions: {
    completedSteps: ReadonlySet<StepId>;
    dismissed:      boolean;
    route:          string;
  };
  actions: {
    showStep:     StepId;
    completeStep: StepId;
  };
}>({
  id: 'onboarding',
  events: ['route-changed', 'feature-discovered'],
  required: ['completedSteps', 'dismissed'],
  handler({ event, conditions, actions }) {
    // The user opted out — never bother them.
    if (conditions.dismissed) return;

    const done = conditions.completedSteps;

    if (event.name === 'route-changed') {
      // Step 1: first visit to the dashboard.
      if (event.payload.to === '/dashboard' && !done.has('try-search')) {
        actions.showStep?.('try-search');
      }
      return;
    }

    // Feature discovery — promote step 2 when search is used (and step 1 is done).
    if (event.name === 'feature-discovered') {
      const { name } = event.payload;

      if (name === 'search-used' && done.has('try-search') && !done.has('save-filter')) {
        actions.completeStep?.('try-search');
        actions.showStep?.('save-filter');
      }

      if (name === 'filter-saved' && done.has('save-filter') === false) {
        actions.completeStep?.('save-filter');
      }
    }
  },
});

The entire onboarding rule book is twenty-five lines, easy to read in one sitting, and impossible to disagree with the spec.

src/features/router/RouterProvider.tsx
import { useCondition, useEvent } from '@triggery/react';
import { useEffect } from 'react';
import { onboardingTrigger } from '../../triggers/onboarding.trigger';

export function RouterProvider({
  route,
  children,
}: {
  route: string;
  children: React.ReactNode;
}) {
  useCondition(onboardingTrigger, 'route', () => route, [route]);

  // Doubles as the producer for `route-changed`.
  const fireRouteChanged = useEvent(onboardingTrigger, 'route-changed');
  useEffect(() => {
    fireRouteChanged({ to: route });
  }, [fireRouteChanged, route]);

  return <>{children}</>;
}
src/features/user/UserProvider.tsx
import { useCondition } from '@triggery/react';
import { useMemo } from 'react';
import { onboardingTrigger, type StepId } from '../../triggers/onboarding.trigger';

export function UserProvider({
  completed,
  dismissed,
  children,
}: {
  completed: readonly StepId[];
  dismissed: boolean;
  children: React.ReactNode;
}) {
  const set = useMemo(() => new Set(completed), [completed]);

  useCondition(onboardingTrigger, 'completedSteps', () => set,       [set]);
  useCondition(onboardingTrigger, 'dismissed',      () => dismissed, [dismissed]);

  return <>{children}</>;
}

Any feature that wants to advance the tour fires a feature-discovered event. It doesn’t know which step it’s promoting — that’s the trigger’s job.

src/features/dashboard/DashboardSearch.tsx
import { useEvent } from '@triggery/react';
import { useState } from 'react';
import { onboardingTrigger } from '../../triggers/onboarding.trigger';

export function DashboardSearch() {
  const [q, setQ] = useState('');
  const fireDiscovered = useEvent(onboardingTrigger, 'feature-discovered');

  return (
    <form
      onSubmit={e => {
        e.preventDefault();
        fireDiscovered({ name: 'search-used' });
      }}
    >
      <input value={q} onChange={e => setQ(e.target.value)} placeholder="Search…" />
      <button type="submit">Search</button>
    </form>
  );
}

One component owns the visible coachmarks. It registers showStep / completeStep and writes the current step into a store.

src/features/onboarding/OnboardingHost.tsx
import { useAction } from '@triggery/react';
import { useState } from 'react';
import { onboardingTrigger, type StepId } from '../../triggers/onboarding.trigger';

const STEP_COPY: Record<StepId, { title: string; body: string }> = {
  'try-search':  { title: 'Try the search', body: 'Type anything in the box up top.' },
  'save-filter': { title: 'Save a filter',  body: 'Save the current view to come back to it.' },
};

export function OnboardingHost({ onComplete }: { onComplete: (step: StepId) => void }) {
  const [active, setActive] = useState<StepId | null>(null);

  useAction(onboardingTrigger, 'showStep', step => setActive(step));
  useAction(onboardingTrigger, 'completeStep', step => {
    onComplete(step);
    setActive(curr => (curr === step ? null : curr));
  });

  if (!active) return null;

  return (
    <aside role="dialog" className="coachmark">
      <h3>{STEP_COPY[active].title}</h3>
      <p>{STEP_COPY[active].body}</p>
      <button type="button" onClick={() => setActive(null)}>Got it</button>
    </aside>
  );
}

onComplete is the seam where you’d persist progress to your user profile API. The trigger doesn’t need to know about that — it only cares whether the completedSteps condition reflects the latest state on the next fire.

src/App.tsx
import { useState } from 'react';
import { OnboardingHost } from './features/onboarding/OnboardingHost';
import { RouterProvider } from './features/router/RouterProvider';
import { UserProvider } from './features/user/UserProvider';
import type { StepId } from './triggers/onboarding.trigger';

export function App() {
  const [route]      = useState('/dashboard');
  const [completed, setCompleted] = useState<readonly StepId[]>([]);
  const [dismissed]  = useState(false);

  return (
    <UserProvider completed={completed} dismissed={dismissed}>
      <RouterProvider route={route}>
        <OnboardingHost onComplete={step => setCompleted(c => [...c, step])} />
        {/* …the rest of the app… */}
      </RouterProvider>
    </UserProvider>
  );
}
src/triggers/onboarding.trigger.test.ts
import { createTestRuntime, mockAction, mockCondition } from '@triggery/testing';
import { describe, expect, it, vi } from 'vitest';
import { onboardingTrigger, type StepId } from './onboarding.trigger';

describe('onboarding', () => {
  it('shows step 1 on first dashboard visit', async () => {
    const rt = createTestRuntime({ triggers: [onboardingTrigger] });
    const showStep = vi.fn();
    mockCondition(rt, onboardingTrigger, 'completedSteps', new Set<StepId>());
    mockCondition(rt, onboardingTrigger, 'dismissed',      false);
    mockCondition(rt, onboardingTrigger, 'route',          '/dashboard');
    mockAction(rt, onboardingTrigger, 'showStep', showStep);

    await rt.fire('route-changed', { to: '/dashboard' });

    expect(showStep).toHaveBeenCalledWith('try-search');
  });

  it('never shows anything when the user dismissed onboarding', async () => {
    const rt = createTestRuntime({ triggers: [onboardingTrigger] });
    const showStep = vi.fn();
    mockCondition(rt, onboardingTrigger, 'completedSteps', new Set<StepId>());
    mockCondition(rt, onboardingTrigger, 'dismissed',      true);
    mockAction(rt, onboardingTrigger, 'showStep', showStep);

    await rt.fire('route-changed', { to: '/dashboard' });
    await rt.fire('feature-discovered', { name: 'search-used' });

    expect(showStep).not.toHaveBeenCalled();
  });
});