Skip to content
GitHubXDiscord

migrateFromListenerMiddleware

Stable · since 0.1.0

Walks a file that uses Redux Toolkit’s createListenerMiddleware / startListening and generates one *.trigger.ts per startListening({ actionCreator, effect }) registration. The source file is left untouched — adopters review the generated triggers, wire them into their components via useEvent, then delete the middleware registration when ready.

Provided as both a programmatic API and a CLI command (triggery-codemod migrate-from-listener-middleware).

import { migrateFromListenerMiddleware } from '@triggery/codemod';
function migrateFromListenerMiddleware(
  options: MigrateFromListenerMiddlewareOptions,
): MigrateFromListenerMiddlewareResult;

interface MigrateFromListenerMiddlewareOptions {
  readonly file: string;
  readonly outDir?: string;
  readonly dryRun?: boolean;
  readonly project?: import('ts-morph').Project;
}

interface MigrateFromListenerMiddlewareResult {
  readonly file: string;
  readonly migrated: readonly MigratedListener[];
}

interface MigratedListener {
  readonly eventName: string;
  readonly triggerFilePath: string;
  readonly triggerFileContent: string;
}
ParamTypeRequiredDescription
filestringyesPath to the file declaring the middleware.
outDirstringnoDirectory for the generated trigger files. Defaults to the source file’s directory.
dryRunbooleannoPlan the changes and return them without writing to disk.
projectts-morph.ProjectnoPre-existing project — reuse across batches.
FieldDescription
fileThe source file that was processed.
migratedOne entry per startListening call the codemod handled — name, output path, generated source.

The codemod recognises the canonical RTK pattern:

startListening({
  actionCreator: someAction,
  effect: (action, listenerApi) => { /* … */ },
});

Other shapes — matcher, predicate, or type — are detected but not transformed in V1. The source file isn’t rewritten, so re-runs are idempotent.

triggery-codemod migrate-from-listener-middleware [--out-dir <path>] [--dry-run] <file>
FlagRequiredDescription
--out-dirnoOverride the default output directory.
--dry-runnoPrint planned changes without writing.
import { migrateFromListenerMiddleware } from '@triggery/codemod';

const result = migrateFromListenerMiddleware({
  file: 'src/store/listenerMiddleware.ts',
  outDir: 'src/triggers',
});

for (const m of result.migrated) {
  console.log(m.eventName, '→', m.triggerFilePath);
}
triggery-codemod migrate-from-listener-middleware --out-dir src/triggers src/store/listenerMiddleware.ts

Output:

Migrated 3 listener(s) from src/store/listenerMiddleware.ts:
  • user-logged-in → src/triggers/user-logged-in.trigger.ts
  • product-added  → src/triggers/product-added.trigger.ts
  • cart-checked-out → src/triggers/cart-checked-out.trigger.ts

For each startListening({ actionCreator: userLoggedIn, effect: (action, api) => {...} }), the codemod writes:

import { createTrigger } from '@triggery/core';

/**
 * Auto-migrated from a Redux Toolkit listenerMiddleware `startListening`
 * registration. Review the generated handler …
 */
export const userLoggedInTrigger = createTrigger<{
  events: { 'user-logged-in': unknown };
  conditions: Record<string, never>;
  actions: Record<string, never>;
}>({
  id: 'user-logged-in',
  events: ['user-logged-in'],
  required: [],
  async handler({ event, conditions, actions, check }) {
    // TODO: original RTK effect body — refactor dispatch/getState into actions/conditions.
    const action = event.payload;
    /* … original effect body … */
  },
});

The event name is derived from the actionCreator symbol’s text. The codemod doesn’t try to resolve .type on the action creator — that would require type info — so name the events by hand if your action creator slugs don’t match the slice’s type string.

The generated triggers are starters. After running the codemod:

  1. Replace listenerApi.dispatch(x) with a Triggery action — add actions.<name> to the schema, register it via useAction in the appropriate component.
  2. Replace listenerApi.getState() reads with typed conditions — register via useCondition.
  3. Wire the new event firing: components that used to dispatch the listened-for action now call useEvent(trigger, 'name') instead.
  4. Once every consumer is wired, delete the startListening block.