---
title: "Reactivity | zenit Zig UI Docs"
description: "Keep facts in Signals, derive with Memos, and sync the outside world with Effects."
url: https://ui.surf/zenit/docs/guide/reactivity
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/reactivity.md
alternate_es: https://ui.surf/zenit/es/docs/guide/reactivity.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/reactivity.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/reactivity.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/reactivity.md
alternate_de: https://ui.surf/zenit/de/docs/guide/reactivity.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# Reactivity

Keep facts in Signals, derive with Memos, and sync the outside world with Effects.

## Three primitives

**Signal**

A writable source of truth. Read with `get()`, write with `set(v)`; reading records a dependency in the current computation.

**Memo**

Computed lazily from other reactive values; reuses its last result while inputs are unchanged and stays quiet when the value compares equal.

**Effect**

Runs a side effect when its dependencies change: updating a node, logging, syncing an external system.

## End-to-end data flow

`ui.textFmt` subscribes to the Signals / Memos you pass and re-formats just that one text node when they change. A Memo receives its dependencies through an explicit context struct.

`counter.zig`

```zig
const count = try scope.createSignal(u32, 0);

const doubled = try scope.createMemo(u32, .{ .count = count }, struct {
    fn compute(ctx: anytype) u32 {
        return ctx.count.get() * 2;
    }
}.compute);

try root.appendChild(cx.allocator, try ui.textFmt(
    cx,
    scope,
    "count = {d}, doubled = {d}",
    .{ count, doubled },
    .{ .color = cx.tokens.color.fg_primary },
));
```

DATA FLOW

One set travels the dependency edges (Signal to Memo to textFmt), and only the subscribed text node is marked render-dirty.

## Signals, not diffing

zenit keeps a real UI tree for layout, hit-testing and drawing, but updates don’t go “re-run components, build a candidate tree, diff the whole thing”. Memos and Effects that read a Signal register a dependency; `set` notifies exactly those subscribers, which mark the affected nodes layout- or render-dirty.

SIGNAL vs DIFF

The tree is still there; what disappears is rebuilding a candidate tree and hunting for differences on every change.

[Video](https://ui.surf/zenit/media/reactive-counter.mp4?v=635ee40b41)

The real Reactive Counter.app: each +1 updates count and doubled together up to 3 / 6, then Reset zeroes both subscribers.

## Updating from events

Component callbacks are `HandlerRef`s. Keep a small struct holding the Signal pointer with `cx.bindState`, then turn one of its methods into a callback with `cx.on`.

`handlers.zig`

```zig
const Bindings = struct {
    count: *ui.Signal(u32),

    fn increment(self: *Bindings) void {
        self.count.set(self.count.get() + 1);
    }
};

const bindings = try cx.bindState(Bindings, .{ .count = count });
const on_click = cx.on(Bindings, bindings, Bindings.increment);

const plus = try ui.widgets.Button(.{
    .label = "+1",
    .on_click = on_click,
}).mount(scope, cx);
```

## Reading and subscribing

| API | What it does |
| --- | --- |
| `signal.get()` | Read and register a dependency in the current Memo / Effect |
| `signal.peek()` | Read without subscribing. Handy for snapshots inside handlers |
| `signal.set(v)` | Write and notify subscribers |
| `signal.update(fn)` | Compute the next value from the previous one, then write |
| `scope.createEffect(ctx, fn)` | Run a side effect on change; disposed with its Scope |

`effects.zig`

```zig
// Effect: sync a value to something outside the reactive graph.
try scope.createEffect(.{ .count = count }, struct {
    fn run(ctx: anytype) void {
        std.log.info("count is now {d}", .{ctx.count.get()});
    }
}.run);

// peek(): read without subscribing (no dependency is recorded).
const snapshot = count.peek();

// update(): read-modify-write in one call.
count.update(struct {
    fn inc(v: u32) u32 {
        return v + 1;
    }
}.inc);
```

## Rules of thumb

-   **One fact, one Signal.** Don’t keep the same state in both a plain field and a Signal.
    
-   **Derivable values are Memos.** Don’t hand-sync derived fields in every handler.
    
-   **Effects only sync.** Never unconditionally write back to an Effect’s own dependency. That’s a loop.
    
-   **Lifetimes belong to a Scope.** When a page’s Scope is disposed, its Signals, Memos and Effects go with it.
    

> TIP
> 
> **Static UI needs no reactivity.** Mount text and structure that never changes directly. Reactive primitives solve changing data. They aren’t a wrapper every node needs.
