Subscriptions

June 1, 2026 · View on GitHub

Subscriptions connect the MVU loop to external event sources: timers, WebSockets, browser events, server-sent events, and any other ongoing stream of data.

The Problem

View describes what the UI looks like. Transition describes how the model changes. But how does the application react to events that originate outside user interaction — a clock ticking, a WebSocket message arriving, a window resizing?

The Solution: Subscriptions

The Subscriptions function declares which external event sources the application wants to listen to, based on the current model:

public static Subscription Subscriptions(Model model)
    => model.IsRunning
        ? SubscriptionModule.Every(TimeSpan.FromSeconds(1), () => new Tick())
        : SubscriptionModule.None;

When the model changes, the runtime calls Subscriptions(model) again and diffs the result against the previous set. New subscriptions are started, removed subscriptions are stopped, and unchanged subscriptions continue running.

The Subscription Type Hierarchy

Subscriptions are an algebraic data type with three variants:

Subscription
├── None                              — no subscriptions (identity element)
├── Batch(IReadOnlyList<Subscription>) — multiple subscriptions (binary operation)
└── Source(SubscriptionKey, Start)     — a single subscription source

This forms a monoid:

PropertyValue
IdentitySubscription.None
Binary operationSubscription.Batch([...])

The monoid structure means subscriptions compose cleanly — you can combine any number of subscriptions without special-casing.

Creating Subscriptions

No Subscriptions

public static Subscription Subscriptions(Model model)
    => SubscriptionModule.None;

Periodic Timer

The built-in Every factory creates a timer subscription using PeriodicTimer for efficient, low-allocation periodic scheduling:

// Tick every second
SubscriptionModule.Every(TimeSpan.FromSeconds(1), () => new Tick())

// With custom key (for multiple timers at the same interval)
SubscriptionModule.Every("animation-timer", TimeSpan.FromMilliseconds(16), () => new AnimationFrame())

The key is auto-generated as "every:{interval.TotalMilliseconds}" for the simple overload. Use the keyed overload when you have multiple timers with the same interval.

Custom Subscription

For anything beyond timers, use Create with a custom start function:

SubscriptionModule.Create("websocket", async (dispatch, cancellationToken) =>
{
    using var ws = new ClientWebSocket();
    await ws.ConnectAsync(uri, cancellationToken);

    var buffer = new byte[4096];
    while (!cancellationToken.IsCancellationRequested)
    {
        var result = await ws.ReceiveAsync(buffer, cancellationToken);
        var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
        dispatch(new WebSocketMessage(json));
    }
});

The StartSubscription delegate signature:

public delegate Task StartSubscription(Dispatch dispatch, CancellationToken cancellationToken);
  • dispatch — Call this to feed messages into the MVU loop
  • cancellationToken — Cancelled when the subscription should stop (the runtime handles this)

Batching Subscriptions

Combine multiple subscriptions:

public static Subscription Subscriptions(Model model)
    => SubscriptionModule.Batch(
        SubscriptionModule.Every(TimeSpan.FromSeconds(1), () => new Tick()),
        SubscriptionModule.Create("resize", ListenForResize),
        model.IsConnected
            ? SubscriptionModule.Create("ws", ConnectWebSocket)
            : SubscriptionModule.None
    );

The Batch factory is smart:

InputReturns
0 subscriptionsNone
1 subscriptionThat subscription directly (no wrapper)
N subscriptionsBatch(subscriptions)

Subscription Keys and Lifecycle

Every subscription source has a SubscriptionKey that uniquely identifies it. The runtime's SubscriptionManager uses these keys to diff subscriptions:

Previous subscriptions: { "every:1000", "ws" }
New subscriptions:      { "every:1000", "resize" }

Result:
  Keep: "every:1000"    (same key → running task continues)
  Stop: "ws"            (removed → cancellation token triggered)
  Start: "resize"       (new → start function called)

This means:

  • Same key, same render → Subscription keeps running uninterrupted
  • Key disappears → Subscription is cancelled via CancellationToken
  • New key appears → Subscription is started

Key Design

Choose keys that reflect the subscription's identity:

// Good: descriptive, stable keys
SubscriptionModule.Create("chat-room:123", ConnectToRoom(123))
SubscriptionModule.Create("notifications", ListenForNotifications)

// Bad: keys that change every render (causes stop/restart)
SubscriptionModule.Create(Guid.NewGuid().ToString(), ...)  // ❌ New key each time!

Conditional Subscriptions

Since Subscriptions is a pure function of the model, subscriptions naturally start and stop based on model state:

public static Subscription Subscriptions(Model model)
    => model.CurrentPage switch
    {
        Page.Dashboard => SubscriptionModule.Batch(
            SubscriptionModule.Every(TimeSpan.FromSeconds(30), () => new RefreshData()),
            SubscriptionModule.Create("alerts", ListenForAlerts)),

        Page.Chat room => SubscriptionModule.Create(
            $"chat:{room.Id}",
            (dispatch, ct) => ConnectToChat(room.Id, dispatch, ct)),

        _ => SubscriptionModule.None
    };

When the user navigates from Dashboard to Chat:

  1. RefreshData timer is stopped (key removed)
  2. alerts listener is stopped (key removed)
  3. chat:{room.Id} connection is started (new key)

Platform Independence

Subscriptions work identically across all render modes:

Render ModeSubscription Host
InteractiveWasmRuns in browser WASM runtime
InteractiveServerRuns on server, messages sent over WebSocket
InteractiveAutoServer initially, then browser after WASM loads
StaticNot applicable (no interactivity)

The same Subscriptions function works everywhere because it's pure — it only depends on the model.

Testing Subscriptions

Test Subscription Selection

[Test]
public async Task RunningModel_HasTimerSubscription()
{
    var model = new Model(IsRunning: true);

    var sub = MyApp.Subscriptions(model);

    await Assert.That(sub).IsTypeOf<Subscription.Source>();
    await Assert.That(((Subscription.Source)sub).Key.Value).IsEqualTo("every:1000");
}

[Test]
public async Task StoppedModel_HasNoSubscriptions()
{
    var model = new Model(IsRunning: false);

    var sub = MyApp.Subscriptions(model);

    await Assert.That(sub).IsTypeOf<Subscription.None>();
}

Test with Runtime

[Test]
public async Task Timer_DispatchesTickMessages()
{
    var patches = new List<IReadOnlyList<Patch>>();
    using var runtime = await Runtime<ClockApp, Model, Unit>.Start(
        apply: p => patches.Add(p),
        interpreter: cmd => new ValueTask<Result<Message[], PipelineError>>(
            Result<Message[], PipelineError>.Ok([])));

    // Start the timer by dispatching a Start message
    await runtime.Dispatch(new Start());

    // Wait for a few ticks
    await Task.Delay(3500);

    // Model should have received tick messages
    await Assert.That(runtime.Model.TickCount).IsGreaterThanOrEqualTo(3);
}

Commands vs Subscriptions

AspectCommandsSubscriptions
TriggerOnce, from TransitionContinuous, based on model
LifetimeFire-and-forgetActive while key present
LifecycleCreated → Interpreted → DoneStarted → Running → Stopped
Use caseAPI calls, navigationTimers, events, sockets
Pure sideTransition returns commandSubscriptions returns set
Impure sideInterpreter executesStart function runs

Best Practices

1. Use Stable Keys

// ✅ Stable key based on data identity
SubscriptionModule.Create($"chat:{room.Id}", ...)

// ❌ Unstable key causes constant restart
SubscriptionModule.Create($"chat:{DateTime.Now}", ...)

2. Handle Cancellation Gracefully

SubscriptionModule.Create("stream", async (dispatch, ct) =>
{
    try
    {
        await foreach (var item in stream.ReadAllAsync(ct))
        {
            dispatch(new ItemReceived(item));
        }
    }
    catch (OperationCanceledException)
    {
        // Normal shutdown — subscription was removed from the model
    }
});

3. Keep Subscription Logic Simple

Subscriptions should dispatch messages, not make decisions:

// ✅ Simple: dispatch raw events
(dispatch, ct) => {
    ws.OnMessage += msg => dispatch(new WsMessage(msg));
}

// ❌ Complex: making decisions in the subscription
(dispatch, ct) => {
    ws.OnMessage += msg => {
        if (msg.Type == "error") dispatch(new ShowError(msg));
        else if (msg.Type == "data") dispatch(new UpdateData(msg));
    };
}

Let Transition handle the decision logic — that's where it's testable.

Summary

Subscriptions are the MVU answer to external event sources:

  • Declared as a function of the model (pure)
  • Diffed by key — automatic start/stop lifecycle
  • Platform-agnostic — same code for browser and server
  • Composable — monoid structure (None + Batch)

See Also