Manage Side Effects with an Elm-inspired Redux Middleware

Managing side effects in Redux applications often requires complex patterns or additional libraries. While Redux excels at handling state updates through pure reducers, side effects like intervals, timers, or subscriptions need special handling. I’ll introduce a lightweight middleware solution that manages effects as derivations of your application state.

The Middleware

At its core, our effect middleware is built around four key types:

import { Action, Dispatch, Middleware } from "redux";

export type GetEffects<State, Effect> = (state: State) => Effect[];
export type Cleanup = () => void;
export type Handle<Effect, Msg extends Action> = (
  effect: Effect,
  dispatch: Dispatch<Msg>
) => Cleanup;
export type Identity<Effect> = (effect: Effect) => string;

The middleware itself maintains a map of running effects and reconciles them with desired effects after each state change:

import { Action, Dispatch, Middleware } from "redux";
import { Cleanup, GetEffects, Handle, Identity } from "./types";

export function effectMiddleware<State, Effect, Msg extends Action>({
  getEffects,
  identity,
  handle,
  loggingEnabled = false,
}: {
  getEffects: GetEffects<State, Effect>;
  identity: Identity<Effect>;
  handle: Handle<Effect, Msg>;
  loggingEnabled?: boolean;
}): Middleware<{}, State, Dispatch<Msg>> {
  const log = (...args: any[]) => {
    if (loggingEnabled) {
      console.log(...args);
    }
  };
  const runningEffects = new Map<string, Cleanup>();
  return (store) => (next) => (action) => {
    const state = next(action);
    const currentEffects = new Map(
      getEffects(store.getState()).map((effect) => [identity(effect), effect])
    );
    for (const [effectId, cancel] of runningEffects) {
      if (!currentEffects.has(effectId)) {
        cancel();
        runningEffects.delete(effectId);
      }
    }
    for (const [effectId, effect] of currentEffects) {
      if (!runningEffects.has(effectId)) {
        runningEffects.set(effectId, handle(effect, store.dispatch));
      }
    }
    log("running effects:");
    for (const [effectId] of runningEffects) {
      log(` - ${effectId}`);
    }
    return state;
  };
}

Usage Example: Auto-Counter

Let’s explore a contrived but interesting example: an auto-counter that can count up or down based on its state. First, we define our state, message, and effect types:

export type Zero = "stop" | "continue";
export type State = {
  direction: "up" | "down" | "paused";
  zero: Zero;
  min: number;
  max: number;
  count: number;
  interval: number;
};

export type Msg =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "goUp" }
  | { type: "goDown" }
  | { type: "pause" }
  | {
      type: "configure";
      config: Partial<{
        min: number;
        max: number;
        interval: number;
        zero: Zero;
      }>;
    };

export type Effect =
  | { type: "up"; interval: number }
  | { type: "down"; interval: number }
  | { type: "tooLow"; interval: number };

The core of our example lies in how we derive effects from state. We have two effect generators:

  1. Interval effects for counting:
const getIntervalEffects: GetEffects<State, Effect> = (state) => {
  if (state.direction === "up") {
    return [{ type: "up", interval: state.interval }];
  }
  if (state.direction === "down") {
    return [{ type: "down", interval: state.interval }];
  }
  return [];
};
  1. Warning effects when count is too low:
const getTooLowEffects: GetEffects<State, Effect> = (state) => {
  if (state.count < 5) {
    return [{ type: "tooLow", interval: 500 }];
  }
  return [];
};

These are combined into a single effect-getter:

export const getEffects: GetEffects<State, Effect> = (state) => {
  return getIntervalEffects(state).concat(getTooLowEffects(state));
};

Finally, we implement the effect handler:

export const handle: Handle<Effect, Msg> = (effect, dispatch) => {
  const id = setInterval(() => {
    switch (effect.type) {
      case "up":
        dispatch({ type: "increment" });
        break;
      case "down":
        dispatch({ type: "decrement" });
        break;
      case "tooLow":
        console.log("🔥 too low! 🔥");
        break;
    }
  }, effect.interval);
  return () => clearInterval(id);
};

To use the middleware, we simply add it to our store configuration:

const store = createStore(
  reducer,
  applyMiddleware(
    loggingMiddleware,
    effectMiddleware({ getEffects, identity, handle, loggingEnabled: true })
  )
);

Benefits

This approach to managing effects offers several advantages:

  1. Declarative: Effects are pure functions of state.
  2. Automatic cleanup: The middleware handles effect lifecycle.
  3. Type-safe: There’s full TypeScript support.
  4. Lightweight: There are no external dependencies.
  5. Testable: Effects are predictable and easy to test.

Managing Side Effects in Redux Applications

This effect middleware provides a simple but powerful way to manage side effects in Redux applications. By treating effects as derivations of state, we get predictable behavior and automatic cleanup while maintaining Redux’s simplicity.

Try it out in your own projects and let me know how it works for you!

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *