Signals: Client-Side Reactive State¶
ZeroZ Stack has one signal abstraction with three scopes: local to the client, local to the server, or shared across both. State lives in signals, derived values are computed, and rendering (or any side effect) happens in effects that re-run automatically when anything they read changes. No manual listener wiring, no "remember to update the label too" bugs.
The signal core (com.zeroz4j.signals) lives in the shared API module and knows nothing about the network. Server events can feed signals (see SERVER_EVENTS.md), but neither feature requires the other.
The primitives¶
| Type | Role |
|---|---|
ValueSignal<T> |
Mutable source state: get(), set(value), update(fn) |
Computed<T> |
Lazily evaluated derived state; recomputes when dependencies change |
Effect |
Side-effect runner (usually rendering); re-runs when dependencies change |
ObservableSignal<T> |
The interface custom signal implementations must implement to participate in tracking |
Disposable |
Returned by Effect.create; releases the subscription |
Automatic dependency tracking¶
Reading a signal with .get() inside a Computed or Effect registers it as a dependency — there is no subscribe call:
ValueSignal<List<Task>> tasks = new ValueSignal<>(new ArrayList<>());
ValueSignal<String> filter = new ValueSignal<>("all");
Computed<List<Task>> visible = new Computed<>(() ->
tasks.get().stream().filter(t -> matches(t, filter.get())).toList());
Computed<Integer> remaining = new Computed<>(() ->
(int) tasks.get().stream().filter(t -> !t.isDone()).count());
Disposable render = Effect.create(() -> renderList(visible.get()));
Disposable badge = Effect.create(() -> label.setText(remaining.get() + " open"));
Changing tasks or filter now updates exactly the parts of the UI that depend on them.
The immutability contract¶
ValueSignal.set() skips notification when the new value equals the old one. Never mutate a value in place and set it back — the signal cannot see the change:
// WRONG — same list reference, equality check swallows it, nothing re-renders:
tasks.get().add(task);
tasks.set(tasks.get());
// RIGHT — immutable update produces a new list:
tasks.update(current -> {
List<Task> next = new ArrayList<>(current);
next.add(task);
return next;
});
Shared signals: one declaration, both tiers¶
A signal created with Signals.shared(name, initialValue) is the same ValueSignal type, bound to a wire identity. Declare it once as a constant in your shared API module — the constant is the signal; there is no topic object, no subscribe call, no publish call:
// shared module
public final class JobSignals {
public static final ValueSignal<JobStatus> STATUS =
Signals.shared(JobStatus.idle());
}
// server — the whole propagation story:
JobSignals.STATUS.set(next);
// client — indistinguishable from a local signal:
Effect.create(() -> progressBar.setValue(JobSignals.STATUS.get().getPercent()));
Because the shared module compiles into both tiers, each tier holds its own instance of the constant bound to the same name; the runtime gives it its role. The server instance broadcasts on set() and retains the latest value; a client mirror receives the retained value the moment it subscribes — late joiners are always current, with no snapshot fetch and no merge logic. In a plain unit test with no transport installed, a shared signal behaves exactly like a local one.
Semantics and current limits, stated plainly:
- Server-authoritative by default — a client-side
set()on aSignals.shared(...)signal throwsIllegalStateException. Opt specific signals into client writes withSignals.sharedWritable(initialValue)orSignals.sharedWritable("name", initialValue, "role"...): the client applies the write optimistically and sends it up; the server — still authoritative — accepts it (role check plus the value's validation annotations) and broadcasts to everyone, or rejects it and answers with a corrective update that snaps the writer back to server truth. Last accepted write wins; there is no per-field merging — writes replace the whole value. - Latest-wins state, not events — consecutive equal values are deduplicated, and there is no history or replay of intermediate values. For discrete occurrences use server events.
- Serializable payloads — the value type must be wire-serializable (
@DataModelor aBinarySerializer-supported type). Treat shared values as immutable:set()a new instance, never mutate the current one. - Naming — the wire name defaults to the payload's class name (the same runtime identity the binary serializer already puts on the wire), giving one default signal per type. Need several signals of the same type, or a stable name across payload-class renames? Use
Signals.shared("explicit.name", initialValue).
Component binding¶
Fields support two-way binding to a signal:
ValueSignal<Boolean> darkTheme = new ValueSignal<>(true);
themeToggle.bindValue(darkTheme); // toggle ⇄ signal stay in sync both ways
Lifecycle¶
Effect.create returns a Disposable; Computed has a dispose() method. A view that creates effects or computeds must release them when it is permanently removed, or its upstream signals keep it alive:
private final List<Disposable> disposables = new ArrayList<>();
...
disposables.add(Effect.create(() -> ...));
...
public void dispose() {
disposables.forEach(Disposable::dispose);
disposables.clear();
myComputed.dispose();
}
Threading¶
Signals are not synchronized — treat them as UI-thread-only. Server push handlers are dispatched onto the platform UI scheduler, so reducing events into signals from a ServerEvents.on handler is safe.
Custom signals¶
Anything implementing ObservableSignal<T> (get + add/removeListener) participates in Effect/Computed tracking. The framework's own ServerEvents.LatestSignal — a signal holding the most recent payload of an event topic — is built exactly this way.
When to use signals¶
- Use them when state is rendered in more than one place, or when values derive from other values (counts, filters, validation) — the cases where manual
render()bookkeeping drifts. - Skip them when a view has a single render path and no derived state — a plain field plus a render method is simpler (see the
chat-eventsexample for that style).
The todo-signals example (zerozstack-examples/todo-signals) demonstrates the full model in isolation.