Skip to content
GitHubXDiscord

From RTK listenerMiddleware

listenerMiddleware from @reduxjs/toolkit is the closest thing in the Redux ecosystem to a trigger. The migration is mostly mechanical: one startListening({ actionCreator, effect }) registration becomes one createTrigger({ id, events, handler }). The codemod migrate-from-listener-middleware handles the actionCreator shape; other shapes need human review.

listenerMiddlewareTriggery
startListening({ actionCreator: foo, effect })createTrigger({ events: ['foo'], handler })
startListening({ matcher: isAnyOf(a, b) })events: ['a', 'b'] and branch on event.name
startListening({ predicate: (action, state) => … })Predicate → condition + handler guard
effect(action, api)api.getState()conditions.someName (registered via useReduxCondition)
api.dispatch(action)actions.someAction?.(payload)
api.cancelActiveListeners()concurrency: 'take-latest' (default) with signal
api.signalsignal in handler context
api.fork(task)Sub-handler; explicit take-every concurrency

Pattern 1 — actionCreator-based listener

Section titled “Pattern 1 — actionCreator-based listener”
Before
listenerMiddleware.startListening({
  actionCreator: chatSlice.actions.messageReceived,
  effect: (action, api) => {
    const settings = api.getState().settings;
    if (!settings.notifications) return;
    api.dispatch(uiSlice.actions.showToast({ title: action.payload.author }));
  },
});
After
export const messageTrigger = createTrigger<{
  events:     { 'chat/messageReceived': Message };
  conditions: { settings: Settings };
  actions:    { showToast: { title: string } };
}>({
  id: 'message-received',
  events: ['chat/messageReceived'],
  required: ['settings'],
  handler({ event, check, actions }) {
    if (!check.is('settings', s => s.notifications)) return;
    actions.showToast?.({ title: event.payload.author });
  },
});

State access (api.getState().settings) becomes a useReduxCondition registered by the settings feature; the toast dispatch becomes a useAction on the UI feature.

Before
import { isAnyOf } from '@reduxjs/toolkit';

listenerMiddleware.startListening({
  matcher: isAnyOf(chatSlice.actions.messageReceived, chatSlice.actions.mentionReceived),
  effect: (action, api) => { /* … */ },
});
After
createTrigger<{ /* … */ }>({
  events: ['chat/messageReceived', 'chat/mentionReceived'],
  handler({ event /* event.name is the discriminator */, actions }) {
    if (event.name === 'chat/mentionReceived') actions.playSound?.('mention');
    actions.showToast?.({ /* … */ });
  },
});

Listing both names in events: indexes the trigger under both event keys — the handler sees a discriminated union of payloads.

Before
listenerMiddleware.startListening({
  predicate: (action, currentState) =>
    action.type === 'chat/messageReceived' &&
    currentState.settings.notifications,
  effect: /* … */,
});
After
createTrigger<{
  events:     { 'chat/messageReceived': Message };
  conditions: { settings: Settings };
}>({
  events: ['chat/messageReceived'],
  required: ['settings'],
  handler({ check, actions }) {
    if (!check.is('settings', s => s.notifications)) return;
    actions.showToast?.({ /* … */ });
  },
});

The predicate’s “is this state right?” check turns into check.is inside the handler — same logic, lazily evaluated from a single source.

Pattern 4 — race against signal (async listeners)

Section titled “Pattern 4 — race against signal (async listeners)”
Before
effect: async (action, api) => {
  api.cancelActiveListeners();           // take-latest
  const res = await fetch('/details', { signal: api.signal });
  if (api.signal.aborted) return;
  api.dispatch(detailsLoaded(await res.json()));
}
After
{
  concurrency: 'take-latest',            // default
  async handler({ signal, actions }) {
    const res = await fetch('/details', { signal });
    if (signal.aborted) return;
    actions.storeDetails?.(await res.json());
  },
}

take-latest is the default; you only pass concurrency: to opt into something else.

@triggery/codemod ships migrate-from-listener-middleware for the actionCreator shape:

npx triggery-codemod migrate-from-listener-middleware src/store/middleware.ts --dry-run

For each startListening({ actionCreator, effect }) call the codemod generates one *.trigger.ts stub with the effect body dropped verbatim into the handler and a // TODO marker for the getState()/dispatch() conversion. Matcher / predicate shapes are detected but skipped — you’ll do those by hand from the patterns above.

You don’t have to remove Redux. The most common path is: keep the store, keep createSlice, and replace listenerMiddleware as a side-effect mechanism with triggers. The @triggery/redux adapter exposes selectors as conditions in 30 lines — see @triggery/redux.