docs/guide/reactivity
핵심 개념 · Data flow

반응성

사실은 Signal에 담고, 파생 값은 Memo로 표현하며, 외부 세계와의 동기화는 Effect로 합니다.

10분 분량

세 가지 프리미티브

01
Signal

쓰기 가능한 사실의 원천입니다. get()으로 읽고 set(v)로 씁니다. 읽으면 현재 계산에 의존성이 기록됩니다.

02
Memo

다른 반응형 값에서 지연 계산됩니다. 입력이 그대로면 이전 결과를 재사용하고, 값이 같으면 하위로 알리지 않습니다.

03
Effect

의존성이 바뀌면 부수 효과를 실행합니다. 노드 갱신, 로그 기록, 외부 시스템 동기화 등입니다.

전체 데이터 흐름

ui.textFmt는 전달한 Signal / Memo를 구독하고, 이들이 바뀌면 그 텍스트 노드 하나만 다시 포맷합니다. Memo의 의존성은 명시적인 컨텍스트 구조체로 전달합니다.

counter.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
한 번의 set이 의존성 간선을 따라 전파되고(Signal → Memo → textFmt), 구독한 텍스트 노드만 render dirty로 표시됩니다.

diff가 아닌 Signal

zenit은 레이아웃, 히트 테스트, 그리기를 위해 실제 UI 트리를 유지하지만, 업데이트는 “컴포넌트 재실행 → 후보 트리 생성 → 전체 diff” 경로를 거치지 않습니다. Signal을 읽는 Memo / Effect는 의존성을 등록하고, set은 정확히 그 구독자에게만 알려 영향받는 노드를 layout 또는 render dirty로 표시합니다.

SIGNAL vs DIFF
트리는 여전히 있습니다. 사라지는 것은 변경마다 후보 트리를 다시 만들고 차이를 찾는 단계입니다.
실제 Reactive Counter.app: +1마다 count와 doubled가 함께 3 / 6까지 갱신되고, Reset이 두 구독 결과를 0으로 되돌립니다.

이벤트에서 Signal 갱신

컴포넌트 콜백은 HandlerRef입니다. cx.bindState로 Signal 포인터를 가진 작은 구조체를 보관하고, cx.on으로 그 메서드를 콜백으로 바꿉니다.

handlers.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);

읽기와 구독

API역할
signal.get()읽고 현재 Memo / Effect에 의존성을 등록
signal.peek()구독 없이 읽기. 핸들러 안에서 스냅샷을 얻을 때 유용
signal.set(v)쓰고 구독자에게 알림
signal.update(fn)이전 값으로 다음 값을 계산한 뒤 쓰기
scope.createEffect(ctx, fn)변경 시 부수 효과 실행, Scope와 함께 해제
effects.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);

경험 법칙

  • ✓

    사실 하나에 Signal 하나. 같은 상태를 일반 필드와 Signal 양쪽에 두지 마십시오.

  • ✓

    계산 가능한 값은 Memo로. 파생 필드를 핸들러마다 수동으로 동기화하지 마십시오.

  • ✓

    Effect는 동기화만. Effect 자신의 의존성에 무조건 다시 쓰지 마십시오. 루프가 됩니다.

  • ✓

    수명은 Scope에 속합니다. 페이지의 Scope가 해제되면 그 안의 Signal, Memo, Effect도 함께 해제됩니다.

zenit · 이중 라이선스오픈 소스 프로젝트는 GPL-3.0-only로 무료 사용할 수 있으며, 비공개 소스나 상용 제품에는 상용 라이선스가 필요합니다.작성자 연락처: zongyi.xzy#gmail.com (#을 @로 바꾸세요)zenit 5f9add5+wip 2026-09-30