DEV Community

Cover image for Improve child to parent communication with xState 5
Georgi Todorov
Georgi Todorov

Posted on

Improve child to parent communication with xState 5

TL;DR

If you just want to see the code, it is here. And this is the PR with the latest changes that are discussed in the post.

Background

This post drifts a bit from the series, but it’s something I had been postponing for a while after switching from v4 to v5. I didn't find it that important at the time, but I've finally started deprecating the sendParent action.

Pros and cons

As stated in the xState documentation, the sendParent is no longer the preferred way to implement the child to parent communication in your machines.

There are couple of things that stopped me from doing this earlier with the adoption of v5.

I've had enough codebase to refactor and the still working sendParent didn’t feel like a priority.

Passing the parent as input and keeping it in the context felt like too much boilerplate.

At the time, I also had a mental barrier around how the parent is typed in the child machine. I expected to be able to reuse the already existing type of the parent machine, but this led to TypeScript errors beyond my knowledge and expertise. After a discussion in the XState Discord channel, this solution surfaced, though at the time, it felt hacky to me:

type ParentActor = ActorRef<Snapshot<unknown>, ParentEvents>;

However, I kept encountering bugs due to the poor TypeScript support of sendParent.

Eventually, this example was added to the XState documentation — and it finally gave me the confidence to switch:

type ChildEvent = {
  type: 'tellParentSomething';
  data?: string;
};
type ParentActor = ActorRef<Snapshot<unknown>, ChildEvent>;

const childMachine = setup({
  types: {
    context: {} as {
      parentRef: ParentActor;
    },
    input: {} as {
      parentRef: ParentActor;
    },
  },
}).createMachine({
  context: ({ input: { parentRef } }) => ({ parentRef }),
  entry: sendTo(({ context }) => context.parentRef, {
    type: 'tellParentSomething',
    data: 'Hi parent!',
  }),
});

export const parent = setup({
  actors: { child: childMachine },
}).createMachine({
  invoke: {
    src: 'child',
    input: ({ self }) => ({
      parentRef: self,
    }),
  },
  on: {
    tellParentSomething: {
      actions: log(({ event: { data } }) => `Child actor says "${data}"`),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Mapping this approach to my codebase looks like this:

// This is the parent

export type Events =
  | { type: "START_APP" }
  | { type: "SIGN_IN"; username: string }
  | { type: "SIGN_OUT" }
  | { type: "GET_USER" };

export const appMachine = setup({
  types: {
    events: {} as Events,
    context: {} as {
      refAuthenticated: AuthenticatedMachineActor | undefined;
    },
  },
  actors: {
    authenticatedMachine,
  },
  actions: {
    setRefAuthenticated: assign({
      refAuthenticated: ({ spawn, self }) => {
        return spawn("authenticatedMachine", {
          id: "authenticatedMachine",
          input: { parent: self },
        });
      },
    }),
    stopRefAuthenticated: assign({ refAuthenticated: undefined }),
  },
}).createMachine({
  id: "application",
  initial: "initializing",
  context: {
    refAuthenticated: undefined,
  },
  states: {
    // ...
    authenticated: {
      entry: ["setRefAuthenticated"],
      on: { SIGN_OUT: { target: "signingOut" } },
      exit: [stopChild("authenticatedMachine"), "stopRefAuthenticated"],
    },
    // ...
  },
});

export const AppContext = createActorContext(appMachine);
Enter fullscreen mode Exit fullscreen mode
// This is the child

import { Events as ParentEvents } from "../contexts/useApp";

type ParentActor = ActorRef<Snapshot<unknown>, ParentEvents>;

export type AuthenticatedMachineActor = ActorRefFrom<
  typeof authenticatedMachine
>;

export const authenticatedMachine = setup({
  types: {
    input: {} as { parent: ParentActor },
    context: {} as {
      refParent: ParentActor;
    },
    events: {} as
      // ...
      | { type: "SIGN_OUT" },
  },
  actions: {
    sendParentSignOut({ context }) {
      context.refParent.send({ type: "SIGN_OUT" });
    },
  },
}).createMachine({
  context: ({ input }) => {
    return { refParent: input.parent, refHome: undefined, refList: undefined };
  },
  id: "authenticatedNavigator",
  // ...
  on: {
    // ...
    SIGN_OUT: { actions: ["sendParentSignOut"] },
  },
  states: {
    // ...
  },
});
Enter fullscreen mode Exit fullscreen mode

One subtle difference from the documentation is that instead of using the sendTo action, I directly call context.refParent.send(...).

Also, in order to keep my codebase consistent, I try to always keep the the event types in the same file as the machine and export them as Events. Later, when needed in the child machine, I alias them as desired (ParentEvents, RootEvents, etc.)

Conclusion

I'm still not excited about passing and storing parent references in the context, but the improved typing significantly enhances the developer experience (DX).

Top comments (0)