Skip to content
GitHubXDiscord

StrictMode (React)

React’s <StrictMode> exists to make a class of bug impossible to ignore. In development, it intentionally mounts every component, immediately unmounts it, then mounts it again — so any state that wasn’t cleaned up on unmount becomes visible immediately, instead of after a navigation, a tab switch, or a hot reload. The same wrapper applies its rules to useEffect: every effect runs, cleans up, then runs again.

If your useEffect registers a subscription that isn’t cleaned up in the returned function, StrictMode shows you the duplicate registration right away. That’s the point. The flip side: every library whose hooks register something needs to survive being mounted-unmounted-mounted-again without leaking, double-firing, or dropping handlers.

Triggery does. This page is the explanation.

<StrictMode>
  <App />
</StrictMode>

In development, the React render lifecycle becomes:

  1. Mount the component.
  2. Run its effects.
  3. Run the cleanup function of every effect.
  4. Run the effects again.
  5. (User interaction continues from here.)

In production, StrictMode is a no-op — the tree mounts once. So if your code only works correctly when “effects run exactly once” is true, you’ll discover that bug in production, weeks later.

That’s why React’s docs explicitly recommend StrictMode for new apps and Triggery’s Getting started snippet wraps the root in it.

Triggery’s hooks register a token in useEffect and unregister it in the cleanup. Run the full StrictMode cycle and the trace looks like this:

1. Mount   — registerCondition('settings', getter)  → token A pushed
2. Cleanup — token A.unregister()                   → stack empty
3. Mount   — registerCondition('settings', getter)  → token B pushed
                                                      (same closure, same value)
4. …user interacts…
5. Unmount — token B.unregister()                   → stack empty

After step 3, exactly one registration is live. The first one (token A) is fully cleaned up before the second one (token B) takes its place. There’s no double-firing of the trigger; there’s no duplicate condition; the value the trigger sees is unambiguous.

The mechanism is a per-(trigger, name) stack maintained by the runtime, not a flat map. When a registration comes in, it’s pushed onto the stack. When it unregisters, it’s removed from wherever it is in the stack — most often the top, but the runtime walks the stack just in case. The “active” value is always the top of the stack.

What the runtime stack looks like
{
  triggerId: 'message-received',
  conditionStacks: {
    settings: [
      // Stack — top of array is the active registration
      <getter from <SettingsPanel /> mount #2>,
    ],
    activeChannelId: [
      <getter from <Chat /> mount #2>,
    ],
  },
  actionStacks: {
    showToast: [
      <handler from <NotificationLayer /> mount #2>,
    ],
  },
}

When the runtime reads conditions.settings at fire time, it picks the top of the stack. When the handler calls actions.showToast?.(), it picks the top of the action stack. Last-mount-wins is consistent, deterministic, and StrictMode-safe.

The key invariant: for every register* call there is exactly one unregister, and the runtime never confuses one mount’s cleanup with another mount’s registration even when their tokens are different.

Here’s the React hook again, annotated:

@triggery/react — useCondition
export function useCondition(trigger, name, getter, deps = []) {
  const runtime = useRuntime();
  const scope = useScope();
  const getterRef = useRef(getter);
  getterRef.current = getter;
  const stableGetter = useCallback(() => getterRef.current(), deps);

  useEffect(() => {
    // (A) on mount: register, get back a token whose .unregister() removes
    //     *this exact registration* (matched by identity, not by name).
    const token = runtime.registerCondition(trigger.id, name, stableGetter, { scope });

    // (B) on cleanup: unregister that exact token. The stack pops the
    //     matching entry — even if it's not at the top (e.g. two providers
    //     mounted in sequence).
    return () => token.unregister();
  }, [runtime, trigger.id, name, stableGetter, scope]);
}

Three properties matter:

  1. Tokens are unique per registration. Two useCondition calls register two distinct stack entries even if their getter happens to be the same function reference.
  2. unregister is idempotent. Calling it twice is a no-op. StrictMode doesn’t actually call cleanup twice, but if a parent component manually held the token and unregistered it, the React cleanup would still call its own — and the second call is silent.
  3. The active mirror always reflects the top of the stack. The runtime stores the “current value” on a flat Map<name, fn> for hot-path reads — but it’s just a mirror; the source of truth is the stack. When the top changes (because a registration was popped), the mirror is updated.

This is what makes the “mount → cleanup → mount” cycle behave as if only the second mount happened.

The most common “library is broken under StrictMode” bug is: a handler subscribed in mount #1 stays subscribed, and mount #2 adds another subscription. Now events fire two handlers.

In Triggery that can’t happen because:

  • The cleanup function returned from useEffect runs before the second mount.
  • The cleanup calls token.unregister(), which removes the exact stack entry that mount #1 added.
  • The stack is empty by the time mount #2 pushes its registration.

If your useAction handler logs each invocation, you’ll see exactly one log per fire, every time. The same holds for useEvent (which doesn’t even register — it just returns a stable callback), and for useInspectHistory (which subscribes to the inspector and unsubscribes in cleanup).

A test that proves it
import { createRuntime } from '@triggery/core';
import { TriggerRuntimeProvider, useAction } from '@triggery/react';
import { createTrigger } from '@triggery/core';
import { render } from '@testing-library/react';
import { StrictMode } from 'react';
import { expect, test, vi } from 'vitest';

const t = createTrigger<{ events: { 'ping': void }; actions: { onPing: void } }>({
  id: 'strict',
  events: ['ping'],
  handler: ({ actions }) => actions.onPing?.(),
});

function Reactor({ onPing }: { onPing: () => void }) {
  useAction(t, 'onPing', onPing);
  return null;
}

test('one fire == one action call, even under StrictMode', () => {
  const runtime = createRuntime();
  const onPing = vi.fn();
  render(
    <StrictMode>
      <TriggerRuntimeProvider runtime={runtime}>
        <Reactor onPing={onPing} />
      </TriggerRuntimeProvider>
    </StrictMode>,
  );

  runtime.fireSync('ping');
  expect(onPing).toHaveBeenCalledTimes(1);
});

In a library that doesn’t handle StrictMode, this test fails with onPing called twice. In Triggery, it passes.

Multiple live registrations: when last-mount-wins matters

Section titled “Multiple live registrations: when last-mount-wins matters”

StrictMode’s mount-unmount-mount cycle is one case. The other case is mounting two genuinely different components that both register the same condition or action:

<SettingsPanelA />  {/* useCondition('settings', () => valueA) */}
<SettingsPanelB />  {/* useCondition('settings', () => valueB) */}

The runtime warns once in DEV (“multiple condition registrations for ‘settings’ on trigger ‘message-received’ — last-mount-wins”) and uses the most-recently-pushed getter. When <SettingsPanelB /> unmounts, the stack pops back to <SettingsPanelA />’s getter automatically — no manual cleanup, no global state.

This is intentional: it’s the same mechanism that StrictMode relies on, generalized. The DEV warning helps catch accidental duplicate registrations (a copy-pasted component); the stack semantics make deliberate layered registrations (e.g. a feature-flag override) behave deterministically.

Solid’s owner-graph model means components mount once and reactivity is tracked through signals — there’s no equivalent of React’s “double-invoke effects” by default. However:

  • HMR re-runs setup functions; onCleanup callbacks fire, then setup re-runs.
  • Some Solid plugins (like solid-devtools) instrument the owner graph and can re-execute setup in development.

Triggery’s Solid bindings call runtime.registerCondition(...) directly in setup and onCleanup(() => token.unregister()) afterwards. The same register → cleanup → register cycle applies — last-mount-wins on the stack, no duplicate registrations.

@triggery/solid — useCondition
export function useCondition(trigger, name, getter) {
  const runtime = useRuntime();
  const scope = useScope();
  const token = runtime.registerCondition(trigger.id, name, getter, { scope });
  onCleanup(() => token.unregister());
}

If you write a Solid component that re-runs setup (HMR or otherwise), the cleanup runs first, then the new setup pushes a fresh registration. Same invariant.

Vue 3’s setup() runs once per component instance — there’s no double-invocation in dev mode. The Triggery bindings use onScopeDispose(() => token.unregister()) to tie the registration’s lifetime to the component (or any explicit effectScope). When the effect scope disposes, the token unregisters; if you re-mount the component, a new scope starts and a fresh registration is pushed.

@triggery/vue — useCondition
export function useCondition(trigger, name, getter) {
  const runtime = useRuntime();
  const scope = useScope();
  const token = runtime.registerCondition(trigger.id, name, getter, { scope });
  onScopeDispose(() => token.unregister());
}

Hot module reload in Vue swaps the component definition without disposing the active scope by default — so Triggery’s registrations survive HMR. That’s a useful property (inspector history persists across edits), and a property React doesn’t have.

Wrapping your test app in <StrictMode> is a free safety net. It costs effectively nothing — the second mount/cleanup pair runs in microseconds — and catches a real class of bug at the point of introduction.

src/test-utils/render-with-runtime.tsx
import { createRuntime } from '@triggery/core';
import { TriggerRuntimeProvider } from '@triggery/react';
import { render, type RenderOptions } from '@testing-library/react';
import { StrictMode, type ReactElement } from 'react';

export function renderWithRuntime(ui: ReactElement, options: RenderOptions = {}) {
  const runtime = createRuntime();
  return {
    runtime,
    ...render(ui, {
      ...options,
      wrapper: ({ children }) => (
        <StrictMode>
          <TriggerRuntimeProvider runtime={runtime}>{children}</TriggerRuntimeProvider>
        </StrictMode>
      ),
    }),
  };
}

If a test starts failing only when wrapped in StrictMode, the bug is in the cleanup path — and the library being tested is one of two: the component under test, or whatever the component subscribes to. Triggery itself is StrictMode-safe — if a test fails here, the component is leaking a non-Triggery subscription.

A related lifecycle question: what happens when @triggery/vite hot-replaces a *.trigger.ts file?

Auto-discovery re-runs createTrigger(config) on the new module. The runtime’s registerTrigger is idempotent by id — finding an existing trigger with the same id, it tears down the old registration (cancels in-flight handlers, clears timers, deindexes event names) and swaps in the new config. The inspector history is preserved (it’s keyed on runId, not on the trigger object identity).

The condition/action stacks are not cleared on a trigger replacement — they’re owned by the components, not the trigger. When the trigger’s id stays the same, the existing stacks continue to apply; the new handler runs against the same registered providers and reactors. This is what makes HMR feel snappy: edit a trigger, save, the next fire runs the new logic without re-mounting any component.

See Auto-discovery for the full HMR story.

ConcernTriggery’s behaviour
React StrictMode mount → unmount → mountThe cleanup runs between, the stack is empty, the second mount pushes one registration. One live registration.
Two providers for the same conditionLast-mount-wins on the stack. Unmounting the second pops back to the first. DEV warning once.
Async handlers in flight during cleanupsignal.aborted flips to true; the handler can short-circuit. The new mount starts fresh.
HMR on a trigger fileTrigger replaced atomically; existing condition/action stacks survive; inspector history persists.
Solid onCleanup / Vue onScopeDisposeSame stack semantics — last-mount-wins, no duplicates.

The principle is the same in all three bindings: register a token in the mount path, unregister it in the cleanup path, never assume effects run exactly once. That’s what makes the whole runtime safe by construction.