Skip to content
GitHubXDiscord

Auth reactions

Auth events are an architectural mess in every app: the access token expired while the user was halfway through editing a form, or a permission was revoked while they have a privileged screen open. Without a coordinator, every form and every screen has to subscribe to “auth events” and decide for itself. With Triggery, the trigger decides — features just register their state and their handlers.

Open in StackBlitz Open example on GitHub

Two events drive the app’s reaction to auth changes.

auth:session-expired — the access token is no longer valid.

  1. If the user has a dirty form → open a “Save changes before sign-in?” modal.
  2. If not → redirect to /login straight away.

auth:role-revoked — a previously granted role was removed.

  1. If the user is viewing a feature that requires the revoked role → close the feature.
  2. Always → show a toast explaining what happened.
  • Directorysrc/
    • Directorytriggers/
      • auth.trigger.ts the routing rule
    • Directoryfeatures/
      • Directoryauth/
        • AuthBridge.tsx producer (network errors → events)
        • AuthReactor.tsx reactor (redirect + toast + close-feature)
      • Directoryforms/
        • DirtyFormProvider.tsx provider (isDirty)
      • Directorypermissions/
        • PermissionProvider.tsx provider (roles, currentFeature)
src/triggers/auth.trigger.ts
import { createTrigger } from '@triggery/core';

export const authTrigger = createTrigger<{
  events: {
    'auth:session-expired': void;
    'auth:role-revoked':    { role: string };
  };
  conditions: {
    isDirty:         boolean;
    roles:           ReadonlySet<string>;
    currentFeature:  { id: string; requiredRole: string | null } | null;
  };
  actions: {
    openSaveBeforeLeavingModal: void;
    redirectToLogin:            void;
    closeFeature:               string;            // feature id
    showToast:                  { kind: 'error' | 'info'; body: string };
  };
}>({
  id: 'auth-reactions',
  events: ['auth:session-expired', 'auth:role-revoked'],
  required: ['roles'],
  handler({ event, conditions, actions, check }) {
    if (event.name === 'auth:session-expired') {
      // Dirty form? Give the user a chance to save first.
      if (check.is('isDirty', dirty => dirty)) {
        actions.openSaveBeforeLeavingModal?.();
        return;
      }

      // Nothing to lose — straight to login.
      actions.redirectToLogin?.();
      return;
    }

    if (event.name === 'auth:role-revoked') {
      const { role } = event.payload;

      actions.showToast?.({
        kind: 'info',
        body: `You no longer have the "${role}" role.`,
      });

      // If the user is *currently* inside a feature that required the revoked
      // role, close it. Other features are unaffected.
      const feature = conditions.currentFeature;
      if (feature && feature.requiredRole === role) {
        actions.closeFeature?.(feature.id);
      }
    }
  },
});

The rule reads top-to-bottom like a security policy document. A reviewer can sign off without touching React.

A small bridge listens to network responses and fires the right events. The rest of the app doesn’t need to know how auth state is sourced.

src/features/auth/AuthBridge.tsx
import { useEvent } from '@triggery/react';
import { useEffect } from 'react';
import { authTrigger } from '../../triggers/auth.trigger';
import { onAuthEvent } from '../../lib/http-client';

export function AuthBridge() {
  const fireExpired = useEvent(authTrigger, 'auth:session-expired');
  const fireRevoked = useEvent(authTrigger, 'auth:role-revoked');

  useEffect(() => {
    return onAuthEvent(e => {
      if (e.kind === 'expired')      fireExpired();
      else if (e.kind === 'revoked') fireRevoked({ role: e.role });
    });
  }, [fireExpired, fireRevoked]);

  return null;
}

onAuthEvent is your existing HTTP-client hook that surfaces 401s and role-change pushes — whatever your stack already has. The bridge is the only place those formats live.

src/features/forms/DirtyFormProvider.tsx
import { useCondition } from '@triggery/react';
import { authTrigger } from '../../triggers/auth.trigger';

export function DirtyFormProvider({
  isDirty,
  children,
}: {
  isDirty: boolean;
  children: React.ReactNode;
}) {
  useCondition(authTrigger, 'isDirty', () => isDirty, [isDirty]);
  return <>{children}</>;
}
src/features/permissions/PermissionProvider.tsx
import { useCondition } from '@triggery/react';
import { useMemo } from 'react';
import { authTrigger } from '../../triggers/auth.trigger';

export function PermissionProvider({
  roles,
  feature,
  children,
}: {
  roles: readonly string[];
  feature: { id: string; requiredRole: string | null } | null;
  children: React.ReactNode;
}) {
  const set = useMemo(() => new Set(roles), [roles]);

  useCondition(authTrigger, 'roles',          () => set,     [set]);
  useCondition(authTrigger, 'currentFeature', () => feature, [feature]);

  return <>{children}</>;
}

Conditions are pull-based. Even if the user types in a deeply nested form, the trigger doesn’t re-evaluate until something fires — the isDirty flag is just available if asked for.

One component owns the side effects. Modals, redirects, toasts — wired with three useAction calls.

src/features/auth/AuthReactor.tsx
import { useAction } from '@triggery/react';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { authTrigger } from '../../triggers/auth.trigger';
import { useModalEvent } from '../modal/useModalEvent';
import { useFeatureStack } from '../../stores/feature-stack';

export function AuthReactor() {
  const navigate    = useNavigate();
  const openModal   = useModalEvent();
  const closeFeature = useFeatureStack(s => s.close);

  useAction(authTrigger, 'openSaveBeforeLeavingModal', () => {
    openModal({
      kind: 'confirm',
      title: 'Save changes before signing in again?',
      body:  'Your session expired. We can save the form to a draft before you re-authenticate.',
      okEvent: 'forms:save-draft',
    });
  });

  useAction(authTrigger, 'redirectToLogin', () => {
    navigate('/login', { replace: true });
  });

  useAction(authTrigger, 'closeFeature', id => {
    closeFeature(id);
  });

  useAction(authTrigger, 'showToast', ({ kind, body }) => {
    if (kind === 'error') toast.error(body);
    else                  toast.info(body);
  });

  return null;
}

useModalEvent is a thin wrapper around useEvent(modalTrigger, 'modal:open') — see the Modal stack recipe.

src/App.tsx
import { AuthBridge } from './features/auth/AuthBridge';
import { AuthReactor } from './features/auth/AuthReactor';
import { DirtyFormProvider } from './features/forms/DirtyFormProvider';
import { PermissionProvider } from './features/permissions/PermissionProvider';

export function App({
  roles,
  currentFeature,
  isDirty,
}: {
  roles: readonly string[];
  currentFeature: { id: string; requiredRole: string | null } | null;
  isDirty: boolean;
}) {
  return (
    <PermissionProvider roles={roles} feature={currentFeature}>
      <DirtyFormProvider isDirty={isDirty}>
        <AuthBridge />
        <AuthReactor />
        {/* …routes… */}
      </DirtyFormProvider>
    </PermissionProvider>
  );
}
src/triggers/auth.trigger.test.ts
import { createTestRuntime, mockAction, mockCondition } from '@triggery/testing';
import { describe, expect, it, vi } from 'vitest';
import { authTrigger } from './auth.trigger';

describe('auth-reactions', () => {
  it('redirects immediately when the session expires and no form is dirty', async () => {
    const rt = createTestRuntime({ triggers: [authTrigger] });
    const redirectToLogin = vi.fn();
    const openSaveBeforeLeavingModal = vi.fn();
    mockCondition(rt, authTrigger, 'isDirty', false);
    mockCondition(rt, authTrigger, 'roles', new Set<string>());
    mockCondition(rt, authTrigger, 'currentFeature', null);
    mockAction(rt, authTrigger, 'redirectToLogin', redirectToLogin);
    mockAction(rt, authTrigger, 'openSaveBeforeLeavingModal', openSaveBeforeLeavingModal);

    await rt.fire('auth:session-expired');

    expect(openSaveBeforeLeavingModal).not.toHaveBeenCalled();
    expect(redirectToLogin).toHaveBeenCalledOnce();
  });

  it('opens the save-changes modal when a form is dirty', async () => {
    const rt = createTestRuntime({ triggers: [authTrigger] });
    const redirectToLogin = vi.fn();
    const openSaveBeforeLeavingModal = vi.fn();
    mockCondition(rt, authTrigger, 'isDirty', true);
    mockCondition(rt, authTrigger, 'roles', new Set<string>());
    mockCondition(rt, authTrigger, 'currentFeature', null);
    mockAction(rt, authTrigger, 'redirectToLogin', redirectToLogin);
    mockAction(rt, authTrigger, 'openSaveBeforeLeavingModal', openSaveBeforeLeavingModal);

    await rt.fire('auth:session-expired');

    expect(redirectToLogin).not.toHaveBeenCalled();
    expect(openSaveBeforeLeavingModal).toHaveBeenCalledOnce();
  });

  it('closes a feature when its required role is revoked', async () => {
    const rt = createTestRuntime({ triggers: [authTrigger] });
    const closeFeature = vi.fn();
    mockCondition(rt, authTrigger, 'isDirty', false);
    mockCondition(rt, authTrigger, 'roles', new Set(['user']));
    mockCondition(rt, authTrigger, 'currentFeature', { id: 'admin-panel', requiredRole: 'admin' });
    mockAction(rt, authTrigger, 'closeFeature', closeFeature);
    mockAction(rt, authTrigger, 'showToast', () => {});

    await rt.fire('auth:role-revoked', { role: 'admin' });

    expect(closeFeature).toHaveBeenCalledWith('admin-panel');
  });
});
Before (auth context with subscribers)With Triggery
Every form subscribes to auth eventsYes — they have toNo, only the trigger asks if anyone is dirty
Adding a “lock the feature on revoke” ruleTouch every screenOne case in the trigger
Testing “expired + dirty form → modal”Render the entire treeTwo mockCondition calls
Race between session-expired and role-revokedTwo listener chains can collideSingle sequential handler