Postboy Help

Vue

Postboy is framework-agnostic — the messages, registrators, and the bus API are exactly the same as in any other environment. Vue simply answers two questions differently than Angular or React: how the bus instance is shared (no hierarchical class-based DI) and how subscriptions are tied to the component lifecycle (scopes instead of ngOnDestroy /effect cleanup).

The guide below uses Vue 3 with the composition API; a Vue 2 note is at the end. For the framework-agnostic model, see Concepts, Message roles, Events, Callbacks, and Executors.

Installation

npm install @artstesh/postboy

rxjs ^7 is the sole peer dependency — install it too if your project doesn't have it yet. Postboy major lines follow the RxJS generation of your project (1.x for ^6, 3.x for ^7); see Versions for details.

The bus instance

Two idiomatic options; the second one is recommended because it keeps components testable.

Option A: module singleton

The quickest start:

// src/postboy/bus.ts import { PostboyService } from '@artstesh/postboy'; export const postboy = new PostboyService();

Construct the service with no arguments — the optional constructor parameter exists for internal dependency injection, not for configuration.

// src/postboy/injection-key.ts import type { InjectionKey } from 'vue'; import type { PostboyService } from '@artstesh/postboy'; export const POSTBOY: InjectionKey<PostboyService> = Symbol('postboy');
// src/main.ts import { createApp } from 'vue'; import App from './App.vue'; import { postboy } from './postboy/bus'; import { POSTBOY } from './postboy/injection-key'; import { appRegistrator } from './postboy/app-registrator'; const app = createApp(App); app.provide(POSTBOY, postboy); appRegistrator.up(); // registrations before the first component subscribes app.mount('#app');

Components then receive the bus via inject(POSTBOY) — and tests can mount the same component with the recording mock from @artstesh/postboy-testing instead (see below), without module mocking.

Messages

Message classes are plain TypeScript, shared with any other part of the system:

// src/messages/index.ts import { PostboyCallbackMessage, PostboyExecutor, PostboyGenericMessage } from '@artstesh/postboy'; export class ListProductsQuery extends PostboyCallbackMessage<number[]> { static readonly ID = 'app.products.list'; } export class ParseProductIdExecutor extends PostboyExecutor<string> { static readonly ID = 'app.products.parse-id'; constructor(public productId: number) { super(); } } export class ItemsLoadedEvent extends PostboyGenericMessage { static readonly ID = 'app.products.loaded'; constructor(public ids: number[]) { super(); } }

Every class declares its own unique static readonly ID — that string is the routing key. See Message roles.

Application-level registrations

A registrator wires the global messages once, at startup:

// src/postboy/app-registrator.ts import { PostboyAbstractRegistrator } from '@artstesh/postboy'; import { postboy } from './bus'; import { ItemsLoadedEvent, ListProductsQuery } from '../messages'; export class AppRegistrator extends PostboyAbstractRegistrator { protected _up(): void { this.recordSubject(ItemsLoadedEvent); this.recordSubject(ListProductsQuery); } } export const appRegistrator = new AppRegistrator(postboy);

up() runs in main.ts before app.mount(...) — registrations must exist before components subscribe. The same ordering rule as in every other framework applies: define → register → subscribe → fire. See Registration and Lifecycle management.

Subscribing in components

The Vue-idiomatic way is a small composable that ties an RxJS subscription to the current scope — it is disposed automatically when the component unmounts:

// src/postboy/use-subscription.ts import { onScopeDispose } from 'vue'; import type { Observable } from 'rxjs'; export function useSubscription<T>(observable: Observable<T>, next: (value: T) => void): void { const subscription = observable.subscribe(next); onScopeDispose(() => subscription.unsubscribe()); }
<script setup lang="ts"> import { inject, ref } from 'vue'; import { POSTBOY } from '../postboy/injection-key'; import { useSubscription } from '../postboy/use-subscription'; import { ItemsLoadedEvent } from '../messages'; const postboy = inject(POSTBOY)!; const ids = ref<number[]>([]); useSubscription(postboy.sub(ItemsLoadedEvent), (event) => { ids.value = event.ids; }); </script> <template> <ul> <li v-for="id in ids" :key="id">{{ id }}</li> </ul> </template>

onScopeDispose works inside setup() and inside composables called from it — no manual onUnmounted bookkeeping per subscription. Assigning to ids.value updates Vue's reactivity automatically, so no change-detection calls are needed (unlike Angular's OnPush).

Component-owned registrations

When a component (a modal, a complex filter block) owns a handful of messages, skip the registrator class and use a namespace tied to the component's lifetime:

<script setup lang="ts"> import { inject, onUnmounted } from 'vue'; import { AddNamespace, EliminateNamespace } from '@artstesh/postboy'; import { POSTBOY } from '../postboy/injection-key'; import { ApplyFilterEvent } from '../messages'; const postboy = inject(POSTBOY)!; const namespace = 'product-filter'; const scope = postboy.exec(new AddNamespace(namespace)); scope.recordSubject(ApplyFilterEvent); onUnmounted(() => postboy.exec(new EliminateNamespace(namespace))); </script>

Eliminating the namespace disconnects everything recorded in it — the streams complete, and the subscriptions above end with them. See Namespaces for the full semantics and the infrastructure messages reference for the exact signatures.

Requests and commands

Callback messages and executors work the same as anywhere; in Vue they pair naturally with async handlers:

import { firstValueFrom } from 'rxjs'; const products = ref<string[]>([]); async function load(): Promise<void> { const ids = await firstValueFrom(postboy.fireCallback(new ListProductsQuery())); products.value = ids.map((id) => postboy.exec(new ParseProductIdExecutor(id))); }

fireCallback with an action dispatches immediately; without one it is lazy — it sends on the first subscription. finish(...) completes the result stream, so firstValueFrom resolves exactly once per query instance. exec is synchronous — never await it. See Callbacks and Executors for the full models.

Testing

With the provide/inject setup, @vue/test-utils mounts the component over the recording mock of @artstesh/postboy-testing:

import { mount } from '@vue/test-utils'; import { PostboyWorld } from '@artstesh/postboy-testing'; import { POSTBOY } from '../postboy/injection-key'; import ProductList from './ProductList.vue'; import { ListProductsQuery } from '../messages'; const world = new PostboyWorld(); const wrapper = mount(ProductList, { global: { provide: { [POSTBOY]: world.postboy }, }, }); // ...interact with the component... world.then.fired(ListProductsQuery).once(); world.dispose(); // afterEach — always dispose the world

If you chose the plain module singleton (option A), tests need vi.mock/jest.mock of the bus.ts module instead — the provide/inject variant avoids that entirely. See Testing for the toolkit itself.

Vue 2 (options API)

The library works the same; only the wiring differs:

  • share the bus via the module singleton (option A) — Vue 2 has no typed provide/inject;

  • subscribe in created()/mounted() and unsubscribe in beforeDestroy():

export default { data: () => ({ ids: [] }), created() { this._subscriptions = [ postboy.sub(ItemsLoadedEvent).subscribe((event) => { this.ids = event.ids; }), ]; }, beforeDestroy() { this._subscriptions.forEach((s) => s.unsubscribe()); }, };

Where to go next

07 September 2026