XState sync actors with Effect Reactivity

Syncing actors with XState requires too much manual wiring of refs and props. So I found a better solution: using the Reactivity module of Effect. This is how it works.

Author Sandro Maglione

Sandro Maglione

Contact me

Like everything in XState, syncing actors is done with events.

But sending events requires knowing the destination 🤔

If an actor inserts some data, another actor may become stale. Syncing actors all inside XState requires passing refs around and imperatively sending events up (sendParent) and down (sendTo).

Storing and managing refs everywhere is bad, error prone, and does not scale.

But I found another solution: the Reactivity module of effect.

Here is how it works 👇

Read full example

Passing refs around, and why not

Imagine a TODO app with three actors:

  • createTodo inserts a new todo
  • todoList reads the todo list
  • todoStats reads derived stats

When createTodo adds a new TODO, the other two should react and reload 👀

There needs to be a link between each actor, so XState can send "reload" events.

The ref-based version starts by lifting all three actors into one parent machine.

Ref.tsx
const refTodoScreenMachine = setup({
  types: {
    children: {} as {
      createTodo: "createTodo";
      todoList: "todoList";
      todoStats: "todoStats";
    },
  },
  actors: {
    createTodo: refCreateTodoMachine,
    todoList: refTodoListMachine,
    todoStats: refTodoStatsMachine,
  },
}).createMachine({
  /// 👇 Actors are lifted into a parent so the parent can address each child.
  invoke: [
    { id: "createTodo", src: "createTodo" },
    { id: "todoList", src: "todoList" },
    { id: "todoStats", src: "todoStats" },
  ],
});

export default function Ref() {
  // 👇 Parent owns the refs of all the actors
  const [snapshot] = useMachine(refTodoScreenMachine);

  const createTodoActor = snapshot.children.createTodo;
  const todoListActor = snapshot.children.todoList;
  const todoStatsActor = snapshot.children.todoStats;

  return (
    <section>
      {createTodoActor && <RefCreateTodo actor={createTodoActor} />}
      {todoStatsActor && <RefTodoStats actor={todoStatsActor} />}
      {todoListActor && <RefTodoList actor={todoListActor} />}
    </section>
  );
}

The parent creates the children, React renders the child actors, and each child owns its own local state.

The hidden cost appears after createTodo succeeds 😬

The create actor has changed todo data, so the list and the stats are both stale. createTodo reports the change to the parent:

ref-machines.ts
export const refCreateTodoMachine = setup({
  actors: {
    createTodo: fromPromise(({ input }: { input: { title: string } }) =>
      Runtime.runPromise(
        Effect.gen(function* () {
          const todoApi = yield* TodoApi;
          yield* todoApi.create(input.title);
        })
      )
    ),
  },
}).createMachine({
  states: {
    // ...
    Creating: {
      invoke: {
        src: "createTodo",
        input: ({ context }) => ({ title: context.title }),
        onDone: {
          target: "Idle",
          /// 👇 The child reports up, so refresh coordination moves to the parent.
          actions: sendParent({ type: "todo.created" }),
        },
      },
    },
  },
});

The parent receives todo.created and decides which children must refresh.

ref-machines.ts
export const refTodoScreenMachine = setup({
  actors: {
    createTodo: refCreateTodoMachine,
    todoList: refTodoListMachine,
    todoStats: refTodoStatsMachine,
  },
}).createMachine({
  invoke: [
    { id: "createTodo", src: "createTodo" },
    { id: "todoList", src: "todoList" },
    { id: "todoStats", src: "todoStats" },
  ],
  on: {
    "todo.created": {
      /// 👇 The parent knows which siblings own todo-derived state.
      actions: [
        sendTo("todoList", { type: "todos.refresh" }),
        sendTo("todoStats", { type: "stats.refresh" }),
      ],
    },
  },
});

The list and stats actors react with their own local events:

ref-machines.ts
export const refTodoListMachine = setup({
  actors: {
    fetchTodos: fromPromise(() =>
      Runtime.runPromise(TodoApi.use((api) => api.list))
    ),
  },
}).createMachine({
  states: {
    Ready: {
      on: {
        "todos.refresh": {
          target: "Loading",
        },
      },
    },
    // ...
  },
});

todos.refresh is local to the list actor. stats.refresh is local to the stats actor. But the parent has to know both names, both actor ids, and the fact that both are affected by todo.created.

The parent is no longer only a structural parent. It has become the synchronization layer ⚡️

Why this is not ideal

The first version has one writer and two readers, with three pieces of manual wiring:

  • The writer sends todo.created up
  • The parent pipes that into todos.refresh
  • The parent pipes that into stats.refresh

For every new actor using that data, a new ref. Imagine also adding new children of children.

This becomes "events drilling" in XState, like "prop drilling" in React 🤯

This is the part that scales poorly: the actor tree starts carrying the data dependency graph.

Move cross sync into Effect

Instead of manually wiring all actors, createTodo can send a "global" invalidation event, and all interested actors can listen and react to it.

But not inside XState, but using effect 💡

Let's start by defining "invalidation topics" keys:

app-sync.ts
export type SyncTopic = "todos";

const syncKey = (topic: SyncTopic) => [`app-sync:${topic}`] as const;

The effect service starts with one operation: invalidate a topic.

This uses the Reactivity module of effect ⚡️

app-sync.ts
export class AppSync extends Context.Service<AppSync>()("AppSync", {
  make: Effect.gen(function* () {
    const reactivity = yield* Reactivity.Reactivity;

    return {
      /// 👇 Writers publish what changed, not who should refresh.
      invalidate: (topic: SyncTopic) => reactivity.invalidate(syncKey(topic)),
    };
  }),
}) {
  static readonly layer = Layer.effect(this)(this.make).pipe(
    Layer.provide(Reactivity.layer),
  );
}

Now the create actor can publish to the sync layer after writing the todo.

sync-machines.ts
export const syncCreateTodoMachine = setup({
  actors: {
    createTodo: fromPromise(({ input }: { input: { title: string } }) =>
      Runtime.runPromise(
        Effect.gen(function* () {
          const todoApi = yield* TodoApi;
          const appSync = yield* AppSync;

          yield* todoApi.create(input.title);

          /// 👇 The writer publishes what changed, not who should refresh.
          yield* appSync.invalidate("todos");
        })
      )
    ),
  },
}).createMachine({
  // ...
});

There is no parent event here. The create actor decides exactly when invalidation happens. It does not know (nor care) which other actors should react.

Listen inside each reader actor

Invalidation is only half of the sync layer.

Readers need a way to subscribe to the same topic and translate it into their own local XState events.

AppSync.listen keeps that translation outside the parent actor.

app-sync.ts
export class AppSync extends Context.Service<AppSync>()("AppSync", {
  make: Effect.gen(function* () {
    const reactivity = yield* Reactivity.Reactivity;

    return {
      invalidate: (topic: SyncTopic) => reactivity.invalidate(syncKey(topic)),
      listen: (topic: SyncTopic, onInvalidate: () => void) =>
        reactivity.stream(syncKey(topic), Effect.void).pipe(
          /// 👇 The stream emits once when it starts; readers skip that seed.
          Stream.drop(1),
          Stream.runForEach(() => Effect.sync(onInvalidate)),
        ),
    };
  }),
}) {
  static readonly layer = Layer.effect(this)(this.make).pipe(
    Layer.provide(Reactivity.layer),
  );
}

The todo list listens to todos in a fromCallback actor, which reports todos.refresh to the main machine:

sync-machines.ts
export const syncTodoListMachine = setup({
  actors: {
    listenTodoInvalidations: fromCallback<TodoListEvent>(({ sendBack }) => {
      /// 👇 The list translates the shared topic into its own local event.
      const cancel = Runtime.runCallback(
        Effect.gen(function* () {
          const appSync = yield* AppSync;

          yield* appSync.listen("todos", () =>
            sendBack({ type: "todos.refresh" })
          );
        })
      );

      return () => {
        cancel();
      };
    }),
  },
}).createMachine({
  /// 👇 Listening is part of the list actor, so the actor can stay local.
  invoke: {
    src: "listenTodoInvalidations",
  },
  states: {
    Ready: {
      on: {
        "todos.refresh": {
          target: "Loading",
        },
      },
    },
  },
});

The stats actor listens to the same topic, but keeps a different local event:

sync-machines.ts
export const syncTodoStatsMachine = setup({
  actors: {
    listenTodoInvalidations: fromCallback<TodoStatsEvent>(({ sendBack }) => {
      /// 👇 Stats listens to the same topic but keeps a different event name.
      const cancel = Runtime.runCallback(
        Effect.gen(function* () {
          const appSync = yield* AppSync;

          yield* appSync.listen("todos", () =>
            sendBack({ type: "stats.refresh" })
          );
        })
      );

      return () => {
        cancel();
      };
    }),
  },
}).createMachine({
  /// 👇 No parent has to know that stats depends on todos.
  invoke: {
    src: "listenTodoInvalidations",
  },
  states: {
    Ready: {
      on: {
        "stats.refresh": {
          target: "Loading",
        },
      },
    },
  },
});

AppSync does not know about todos.refresh or stats.refresh.

Each actor still owns its own event names and loading state. The only shared concept is the domain topic: todos.

The shared runtime matters

There is one important Effect requirement: all actors must use the same runtime (ManagedRuntime).

A ManagedRuntime holds a shared Reactivity layer, necessary for having the same write/read source of events 🏗️

runtime.ts
import { Layer, ManagedRuntime } from "effect";
import { AppSync } from "./app-sync";
import { TodoApi } from "./todo-api";

/// 👇 Every actor uses this runtime, so reads and invalidations share reactivity.
export const Runtime = ManagedRuntime.make(
  Layer.mergeAll(TodoApi.layer, AppSync.layer)
);

The create actor uses Runtime.runPromise to write a todo and invalidate todos.

The list and stats actors use Runtime.runCallback to keep their listeners alive.

Because they use the same runtime, they are connected through the same AppSync/Reactivity layer.

Actors can stay local again

Once Effect owns cross-actor sync, the React tree no longer needs a parent coordination machine.

The sync version renders each actor where it is used:

Sync.tsx
export default function Sync() {
  return (
    <section>
      <SyncCreateTodo />
      <SyncTodoStats />
      <SyncTodoList />
    </section>
  );
}

function SyncCreateTodo() {
  const actor = useActorRef(syncCreateTodoMachine);

  return <CreateTodoForm actor={actor} />;
}

function SyncTodoList() {
  const actor = useActorRef(syncTodoListMachine);

  return <TodoListView actor={actor} />;
}

function SyncTodoStats() {
  const actor = useActorRef(syncTodoStatsMachine);

  return <TodoStatsView actor={actor} />;
}

The actor tree is back to describing the UI structure, not the sync graph.

Extra: listen to multiple topics

The TODO example only needs one topic.

If a reader depends on more than one domain topic, AppSync can grow without changing the writer side:

app-sync.ts
export type SyncTopic = "todos" | "projects";
export type SyncTopics = SyncTopic | readonly SyncTopic[];

const syncKey = (topic: SyncTopic) => [`app-sync:${topic}`] as const;

export class AppSync extends Context.Service<AppSync>()("AppSync", {
  make: Effect.gen(function* () {
    const reactivity = yield* Reactivity.Reactivity;

    return {
      invalidate: (topic: SyncTopic) => reactivity.invalidate(syncKey(topic)),
      listen: (topics: SyncTopics, onInvalidate: (topic: SyncTopic) => void) =>
        Effect.forEach(
          typeof topics === "string" ? [topics] : topics,
          (topic) =>
            reactivity.stream(syncKey(topic), Effect.void).pipe(
              Stream.drop(1),
              Stream.runForEach(() => Effect.sync(() => onInvalidate(topic)))
            ),
          { concurrency: "unbounded", discard: true }
        ),
    };
  }),
}) {}

A reader can then subscribe to every topic that affects its own state.

project-summary-machine.ts
const projectSummaryMachine = setup({
  actors: {
    listenSummaryInvalidations: fromCallback<ProjectSummaryEvent>(
      ({ sendBack }) => {
        const cancel = Runtime.runCallback(
          Effect.gen(function* () {
            const appSync = yield* AppSync;

            yield* appSync.listen(["todos", "projects"], () =>
              sendBack({ type: "projectSummary.refresh" })
            );
          })
        );

        return () => {
          cancel();
        };
      }
    ),
  },
}).createMachine({
  // The actor still owns projectSummary.refresh locally.
});

The final code for this is surprisingly short (thanks to effect).

Read full example

With a single AppSync service I was able to shed many layers of dependencies between XState actors, and avoid any props and pollution of React components.

I am using this everywhere now 🪄