Skip to main content
On this pageOverview

Parent-Child Communication with OutMessage

Overview

Sometimes a child module needs to trigger a change in the parent's state. Child modules cannot directly update parent state—they only manage their own. The OutMessage pattern solves this—children emit semantic events that parents then handle.

Define OutMessage schemas alongside your child Message:

import { Schema as S } from 'effect'
import { m } from 'foldkit/message'

// MESSAGE

export const ClickedLogout = m('ClickedLogout')
export const Message = S.Union(ClickedLogout)
export type Message = typeof Message.Type

// OUT MESSAGE

export const RequestedLogout = m('RequestedLogout')
export const OutMessage = S.Union(RequestedLogout)
export type OutMessage = typeof OutMessage.Type

The child update function returns a 3-tuple: model, commands, and an optional OutMessage:

import { Match as M, Option } from 'effect'
import { Command } from 'foldkit/command'

export const update = (
  model: Model,
  message: Message,
): [
  Model,
  ReadonlyArray<Command<Message>>,
  Option.Option<OutMessage>,
] =>
  M.value(message).pipe(
    M.tagsExhaustive({
      ClickedLogout: () => [
        model,
        [],
        Option.some(RequestedLogout()),
      ],
    }),
  )

The parent handles the OutMessage with Option.match, taking action when present:

import { Match as M, Option } from 'effect'
import { evo } from 'foldkit/struct'

export const update = (
  model: Model,
  message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
  M.value(message).pipe(
    M.tagsExhaustive({
      GotSettingsMessage: ({ message }) => {
        const [nextSettings, commands, maybeOutMessage] =
          Settings.update(model.settings, message)

        return Option.match(maybeOutMessage, {
          onNone: () => [
            evo(model, { settings: () => nextSettings }),
            commands,
          ],
          onSome: outMessage =>
            M.value(outMessage).pipe(
              M.tagsExhaustive({
                RequestedLogout: () => [
                  LoggedOut({ email: '', password: '' }),
                  [...commands, clearSession()],
                ],
              }),
            ),
        })
      },
    }),
  )

Use Array.map with Effect.map to wrap child commands in parent message types:

import { Array, Effect } from 'effect'

const [nextSettings, commands, maybeOutMessage] = Settings.update(
  model.settings,
  message,
)

const mappedCommands = Array.map(commands, command =>
  Effect.map(command, message => GotSettingsMessage({ message })),
)

OutMessages are semantic events (like LoginSucceeded) while commands are side effects. This separation keeps child modules focused on their domain while parents handle cross-cutting concerns. See the Auth example for a complete implementation.