Skip to content

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', getterA) → slot = A
2. Cleanup — tokenA.unregister()                    → slot empty (A was live, removed)
3. Mount   — registerCondition('settings', getterB) → slot = B
                                                      (same closure, same value)
4. …user interacts…
5. Unmount — tokenB.unregister()                    → slot empty (B was live, removed)

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) single slot maintained by the runtime: a flat Map<name, fn>. A new registration overwrites the slot. Cleanup removes the entry only if it’s still the live one (stale tokens are no-ops). The “active” value is whatever is in the slot.

{
  triggerId: 'message-received',
  conditions: {
    settings: <getter from <SettingsPanel /> mount #2>,
    activeChannelId: <getter from <Chat /> mount #2>,
  },
  actions: {
    showToast: <handler from <NotificationLayer /> mount #2>,
  },
}

When the runtime reads conditions.settings at fire time, it picks the slot’s value. When the handler calls actions.showToast?.(), it picks the action slot. Last-write-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:

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. If this registration was already overwritten
    //     by a later one (stale token), unregister is a silent no-op so we
    //     don't wipe the live entry.
    return () => token.unregister();
  }, [runtime, trigger.id, name, stableGetter, scope]);
}

Three properties matter:

  1. Tokens are unique per registration. Every useCondition call returns its own token; cleanup compares function identity against the live slot before deleting.
  2. unregister is idempotent and stale-safe. Calling it twice is a no-op. If the registration was already overwritten by a later write, the cleanup won’t wipe the new one.
  3. The slot is the source of truth. A flat Map<name, fn> per trigger holds the current registration. A new write overwrites; the dispatcher reads the latest value directly.

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).

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-write-wins matters

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

StrictMode’s mount-cleanup-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 write wins”) and uses the most recent getter. When <SettingsPanelB /> unmounts, the slot is cleared — the previous <SettingsPanelA />’s getter is not automatically restored (v0.10 removed the stack-based fallback). If a “layered” override pattern matters in your app, lift the wiring up so a single component owns the slot. See Ownership for patterns.

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-write-wins on the slot, no duplicate registrations.

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.

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.

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 slots 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 slots 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 → cleanup → mountThe cleanup runs between mounts, the slot is empty, the second mount writes one registration. One live registration.
Two providers for the same conditionLast-write-wins: the second provider overwrites the slot; unmounting the second clears the slot (it does not fall back). 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 slots survive; inspector history persists.
Solid onCleanup / Vue onScopeDisposeSame slot semantics — last-write-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.