How to Use Subscriptions for Timers and External Events in the Native SDK Effect System

The Native SDK effect system uses Subscription objects returned from component functions to automatically manage timers and external event listeners, ensuring cleanup occurs when components unmount without manual teardown code.

The vercel-labs/native repository implements a lightweight reactive model where effect functions return Subscription handles to manage side effects. This pattern eliminates the need for manual cleanup code by delegating timer cancellation and event listener removal to the runtime according to the lifecycle defined in src/runtime/effects.zig.

Core Concepts of the Effect System

In src/runtime/effects.zig, the Subscription type serves as an opaque handle that wraps a cancel function. When a component function returns a subscription, the runtime places it into the component's effect list and invokes the cancel routine automatically during teardown.

The architecture follows three distinct lifecycle phases:

  • Start — The runtime invokes the subscription's initialization logic, registering timers or listeners with the platform
  • Update — Subsequent renders reuse existing subscription objects to prevent duplicate registrations
  • Dispose — The runtime iterates the subscription list and invokes each cancel function when the component unmounts

This deterministic lifecycle eliminates common bugs like zombie timers or leaked listeners.

Managing Timers Through Subscriptions

Timer subscriptions reside in src/runtime/clock.zig and provide thin wrappers around platform-specific timer facilities (such as setTimeout on web or dispatch_source on macOS). The SDK exposes two primary constructors: Timer.start for one-shot delays and Timer.interval for recurring execution.

One-Shot Timers with Timer.start

To schedule a delayed callback that automatically cleans up, return a Timer.start subscription from your component:

pub fn MyComponent() !void {
    const timer = Timer.start(2 * std.time.ns_per_s, fn () void {
        std.log.info("Timer fired!", .{});
    });

    return timer;
}

The runtime automatically cancels the underlying platform timer when MyComponent unmounts.

Recurring Timers with Timer.interval

For repeated execution, use Timer.interval which registers a repeating timer with the native platform:

pub fn RepeatingComponent() !void {
    const interval = Timer.interval(1 * std.time.ns_per_s, fn () void {
        std.log.info("Every second", .{});
    });

    return interval;
}

Both helpers ensure that the associated callbacks stop firing once the component is removed from the tree.

Subscribing to External Events

External event subscriptions follow the same pattern as timers. Platform-specific modules expose factory functions that return Subscription objects encapsulating OS listeners and their cancel routines.

Clipboard Events

The clipboard implementation, demonstrated in src/runtime/effects_clipboard_tests.zig (where the production code mirrors the test helper), provides the Clipboard.listen factory:

pub fn ClipboardWatcher() !void {
    const sub = Clipboard.listen(fn (event: Clipboard.Event) void {
        std.log.info("Clipboard changed: {s}", .{event.text});
    });

    return sub;
}

This automatically unsubscribes from platform clipboard notifications when the component teardown occurs.

Audio and Network Events

Similar patterns exist for hardware and connectivity monitoring. In src/runtime/effects_audio_tests.zig, the Audio.onDeviceChange factory returns subscriptions for audio hardware changes. Network status monitoring follows an identical pattern through Network.onChange or Network.onStatusChange.

All external event factories return Subscription objects that the runtime manages according to the lifecycle defined in src/runtime/effects.zig.

Subscription Lifecycle and Automatic Cleanup

The runtime management logic, validated in src/runtime/effects_tests.zig, governs how subscriptions interact with the component lifecycle.

When an effect function returns a subscription:

  1. The SDK places the subscription into the component's effect list
  2. The runtime invokes the subscription's start method (if present), registering timers or listeners with the underlying platform
  3. On subsequent renders, existing subscriptions are reused rather than recreated
  4. During unmount, the runtime iterates the subscription list and invokes each cancel function

This mechanism guarantees that resources like setTimeout handles on web or dispatch_source timers on macOS are properly released without explicit disposal code in user components.

Summary

  • Effect functions in the Native SDK return Subscription objects to manage side effects programmatically
  • Timer subscriptions created via Timer.start and Timer.interval in src/runtime/clock.zig handle both one-shot and recurring timers automatically
  • External event subscriptions from modules like Clipboard.listen and Audio.onDeviceChange encapsulate platform listeners with automatic cleanup
  • The runtime in src/runtime/effects.zig manages subscription lifecycle through Start, Update, and Dispose phases, preventing memory leaks and zombie timers
  • Unit tests in src/runtime/effects_tests.zig validate that all subscription types properly release resources during component teardown

Frequently Asked Questions

What happens if a component function doesn't return a subscription?

If a component function returns void or a non-Subscription type, the runtime creates no managed handles for that effect. While this works for pure render functions, any timers or listeners created without returning a subscription will leak resources when the component unmounts, as the runtime cannot track or cancel them.

How does the Native SDK prevent timer leaks?

The SDK prevents leaks through deterministic cleanup in src/runtime/effects.zig. When a component unmounts, the runtime iterates the component's subscription list and invokes each cancel function. For timers created via Timer.start or Timer.interval, these cancel functions stop the underlying platform timers immediately.

Can multiple subscriptions be combined in a single component?

While each effect function returns a single value, you can structure your component to manage multiple subscriptions by aggregating them or using a wrapper that returns a composite subscription. However, the standard pattern involves creating separate effect functions or returning a single subscription that manages multiple resources internally, as demonstrated in the test files.

What's the difference between Timer.start and Timer.interval?

Timer.start creates a one-shot subscription that fires a single callback after a specified duration, while Timer.interval creates a recurring subscription that repeatedly invokes the callback at the specified interval until the component unmounts. Both are defined in src/runtime/clock.zig and return Subscription objects that the runtime automatically cancels during teardown.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →