Become and stash
An actor’s onReceive is normally a single function that handles
every message kind. Two context APIs let it reshape that
handling on the fly:
context.become(handler)— temporarily replace the current receive handler with a different one. Restore withunbecome().context.stash()+unstashAll()— park messages an actor isn’t ready for, then re-deliver them when it is.
Together they’re the actor-model answer to “this actor has phases where it doesn’t accept normal traffic” — initialization, recovery from a checkpoint, draining before shutdown, awaiting an async config load.
become — swap the behavior
Section titled “become — swap the behavior”import { match } from 'ts-pattern';import { Actor, ActorSystem } from 'actor-ts';
type OnMessage = { readonly kind: 'on' };type OffMessage = { readonly kind: 'off' };type PressMessage = { readonly kind: 'press' };type Message = OnMessage | OffMessage | PressMessage;
class Switch extends Actor<Message> { override onReceive(message: Message): void { this.offState(message); }
private offState = (message: Message): void => { match(message) .with({ kind: 'press' }, () => this.onPressWhileOff()) .otherwise(() => {}); };
private onPressWhileOff(): void { this.log.info('turning on'); this.context.become(this.onState); }
private onState = (message: Message): void => { match(message) .with({ kind: 'press' }, () => this.onPressWhileOn()) .otherwise(() => {}); };
private onPressWhileOn(): void { this.log.info('turning off'); this.context.become(this.offState); }}
const system = ActorSystem.create('demo');const sw = system.spawnAnonymous(Switch);
sw.tell({ kind: 'press' }); // → "turning on"sw.tell({ kind: 'press' }); // → "turning off"sw.tell({ kind: 'press' }); // → "turning on"become(handler) swaps the function the framework calls on the
next message. No flag fields, no inline if (this.state === 'on')
ladder — the behavior is the state.
The signature:
become(behavior: Receive<TMessage>, discardOld?: boolean): void;unbecome(): void;discardOld defaults to true (replace). Pass false to push
the new behavior onto a stack instead — unbecome() then pops it
and restores the previous one.
Stacked behaviors
Section titled “Stacked behaviors”class Loader extends Actor<Command> { override onReceive(message: Command): void { this.idle(message); }
private idle = (message: Command): void => { match(message) .with({ kind: 'load' }, () => this.onLoad()) .otherwise(() => {}); };
private onLoad(): void { this.context.become(this.loading, /* discardOld */ false); this.startLoading(); }
private loading = (message: Command): void => { match(message) .with({ kind: 'done' }, () => this.onDone()) .otherwise(() => {}); };
private onDone(): void { this.context.unbecome(); // pops loading, idle returns }}Stacking is the right tool when you have a transient sub-behavior
(“currently loading”, “currently authenticating”) that returns to a
stable base behavior once it’s done. For toggles like the Switch
example above, discardOld = true (the default) is cleaner.
Compared to a plain state field
Section titled “Compared to a plain state field”Without become, the same Switch would look like this:
class Switch extends Actor<Message> { private isOn = false; override onReceive(message: Message): void { match(message) .with({ kind: 'press' }, () => this.onPress()) .otherwise(() => {}); }
private onPress(): void { this.isOn = !this.isOn; this.log.info(this.isOn ? 'turning on' : 'turning off'); }}Two states — the plain-field version is shorter. Where become
wins is at N states: with three or four phases, the inline
if/else chain on isOn grows into a state-machine ladder that
the type system can’t help you with. become makes each phase its
own function, with its own valid-message set, naturally narrowed.
For a more formalized version of the same idea, see the FSM pattern — explicit state + transition declarations on top of the same engine.
stash — park, then replay
Section titled “stash — park, then replay”import { Actor, ActorSystem } from 'actor-ts';import { match } from 'ts-pattern';
type ConfigureMessage = { readonly kind: 'configure'; readonly url: string };type RequestMessage = { readonly kind: 'request'; readonly payload: string };type Message = ConfigureMessage | RequestMessage;
class Worker extends Actor<Message> { private url?: string;
override onReceive(message: Message): void { match(message) .with({ kind: 'configure' }, (m) => this.onConfigure(m)) .with({ kind: 'request' }, (m) => this.onRequest(m)) .exhaustive(); }
private onConfigure(m: ConfigureMessage): void { this.url = m.url; // Now drain whatever piled up while we were unconfigured. this.context.unstashAll(); }
private onRequest(m: RequestMessage): void { if (!this.url) { // Not ready yet — park this message. this.context.stash(); return; } this.log.info(`POST ${this.url}: ${m.payload}`); }}The flow: messages arriving before configure are stashed. Once
configure runs, unstashAll() re-prepends them onto the mailbox
in the order they came in, and the actor processes them with the
URL now set.
The signatures:
stash(): void;unstashAll(): void;readonly stashSize: number;Four details that matter:
stash()must be called from inside a user-message handler. It parks the currently-handled message. Calling it frompreStart, a timer callback that isn’t an actor message, or outsideonReceivethrowsStashOutsideHandlerError.unstashAll()is FIFO. Messages come back in the order they were stashed. For aPriorityMailbox, they’re re-inserted through the priority function — so an unstashed message rejoins its priority tier, not the absolute front of the queue.- The stash has a capacity. The buffer is bounded to prevent
memory leaks from runaway stashing; overflow throws
StashOverflowError. The bound is a fixed 1024 messages, compiled in — there’s no configuration knob for it. - A bounded mailbox bounds the replay too. Mailboxes are
unbounded by default and none of this applies to them, but if you
set one with
withMailboxCapacity(...), the replay is subject to it:unstashAll()onto a full mailbox drops underdrop-head/drop-new(shedding the newest queued messages to make room for the older stashed ones) and throwsMailboxFullErrorunderreject, leaving the batch stashed. See Mailboxes.
stash + become — the canonical combo
Section titled “stash + become — the canonical combo”The two APIs compose for the “actor with an init phase” pattern:
class Worker extends Actor<Message> { private url?: string;
override preStart(): void { // Switch immediately into the "loading config" behavior. this.context.become(this.loading); void this.loadConfig(); }
private loading = (message: Message): void => { match(message) .with({ kind: 'configure' }, (m) => this.onConfigure(m)) .otherwise(() => this.onDuringLoad()); };
private onConfigure(message: ConfigureMessage): void { this.url = message.url; this.context.become(this.ready); this.context.unstashAll(); }
// Anything else arriving during load — stash it. private onDuringLoad(): void { this.context.stash(); }
private ready = (message: Message): void => { match(message) .with({ kind: 'request' }, (m) => this.onRequest(m)) .otherwise(() => {}); };
private onRequest(message: RequestMessage): void { this.log.info(`POST ${this.url}: ${message.payload}`); }
override onReceive(message: Message): void { // Initial behavior — the preStart switches into 'loading' before // any user message arrives. This is here to satisfy the signature. this.loading(message); }
private async loadConfig(): Promise<void> { const config = await fetchConfig(); this.context.self.tell({ kind: 'configure', url: config.url }); }}Two behaviors: loading (stashes everything except configure),
ready (handles requests). The become makes the transition
explicit; the stash + unstashAll makes sure no traffic is
dropped during the initialization window.
When to reach for which
Section titled “When to reach for which”| Situation | Tool |
|---|---|
| Two or more discrete phases with different valid-message sets | become |
| One phase has a transient sub-phase that should restore on done | become(..., discardOld=false) + unbecome() |
| Boolean flag — “ready”/“not ready” — with the same handler shape | A plain field (don’t reach for become) |
| Some messages must be deferred until later, in order | stash + unstashAll |
| Init phase that buffers all non-init traffic | become(loading) + stash in loading + unstashAll in transition |
Where to next
Section titled “Where to next”- Actor — the base class
whose
onReceiveyou swap withbecome. - Mailboxes —
unstashAllre-inserts into the mailbox; the mailbox decides the order. - FSM overview — a more formal
state-machine style on top of the same
becomeengine. - Typed behaviors — the typed
API expresses the same idea as functional
Behavior<T>values rather than mutating context.
The ActorContext API
reference covers become, stash, and the full surface.
