Anti-patterns¶
Named failure modes, so they can be pointed at in a code review. Each is something ZeroZ Stack applications actually do. Most now fail loudly rather than silently, but the mistake is still worth avoiding — see Troubleshooting for the symptom-first version.
Event-as-state¶
Broadcasting state through a EventTopic instead of a shared signal.
// WRONG — a client that connects after this publish sees no progress at all.
events.publish(JobEvents.PROGRESS_CHANGED, new JobStatus(42));
Events have no retained value and no replay. It works while you test with a browser that was already open, and fails for every user who arrives later or reloads.
Fix: declare a shared signal. The server set()s it, and late joiners receive the retained value
on subscribe.
Signal-as-log¶
Pushing a growing collection through a shared signal instead of publishing events.
// WRONG — resends the entire history on every message, and `equals` deduplication
// makes the behaviour hard to predict as the list grows.
CHAT_HISTORY.update(list -> append(list, msg));
Fix: publish an event per occurrence and let each client reduce it into its own local signal. One message on the wire per message in the chat.
In-place mutation¶
Mutating a value and setting it back. ValueSignal.set compares the new value with the old using
equals and does nothing when they match — and a mutated list equals itself.
// WRONG — same list reference; the equality check swallows it, nothing re-renders.
tasks.get().add(task);
tasks.set(tasks.get());
// RIGHT — a new instance.
tasks.update(current -> {
List<Task> next = new ArrayList<>(current);
next.add(task);
return next;
});
The LiveSync variant is the same mistake through a different door: setters are the tracking boundary,
so profile.getTags().add("x") reports nothing. Reassign through the setter, or call
LiveMutationTracker.touch(profile).
Operation-as-edit¶
Modelling an operation as a @ClientWritable field flip.
// WRONG — "approve" has no name, no security annotation, no validation point,
// no audit trail, and no way to tell the caller it was refused.
invoice.setApproved(true);
An operation deserves a method. State edits sync, operations call.
@RmiService
public interface InvoiceService {
@RolesAllowed("approver") // on the INTERFACE method — the implementation is not scanned
void approve(String invoiceId);
}
Unbounded broadcast¶
Putting per-user, per-tenant or otherwise sensitive data into a server event or a shared signal.
Both reach every connected session, with no principal check and no tenant filter. Shared signals are worse: their registry is static, so there is one value per name for the entire JVM, across all tenants.
// WRONG — every session receives every user's balance.
public static final ValueSignal<Money> BALANCE = Signals.shared(Money.zero());
Fix: scope it. Events and LiveSync both take a scope:
events.publishToUser(AccountEvents.BALANCE_CHANGED, balance, principalName);
syncEngine.notifyChanged(balance, Scope.USER, principalName);
A shared signal cannot be scoped, so per-user state is never a shared signal.
This is a security bug, not an efficiency one. Treat it as such in review.
Polling a live object¶
Copying a @LiveSync object into a signal on a timer, because you expect it not to notify anyone.
// WRONG — a live object is already a reactive dependency.
Window.setInterval(() -> messages.set(new ArrayList<>(state.getMessages())), 500);
// RIGHT — read the getter inside an Effect; an inbound sync re-runs it.
Effect.create(() -> messages.set(new ArrayList<>(state.getMessages())));
Reading a live object's getter inside an Effect or Computed subscribes to it. See
LiveSync objects are reactive.
Sync-before-send¶
Calling syncEngine.notifyChanged(obj) on an object the server has never sent to a client. It has no
ObjectMapper handle, so the call throws IllegalStateException.
Fix: return the object from an RMI method at least once — that is what registers the handle — then
notifyChanged works for the lifetime of the connection.
Leaked effect¶
Creating an Effect in a view that is removed without disposing it. The upstream signal holds a
reference to the effect, which holds your view, which holds its components.
private final List<Disposable> disposables = new ArrayList<>();
void build() {
disposables.add(Effect.create(() -> label.setText(count.get() + " open")));
}
public void dispose() {
disposables.forEach(Disposable::dispose);
disposables.clear();
myComputed.dispose(); // Computed has its own dispose()
}
The same applies to ServerEvents.on, which also returns a Disposable.
Snapshot race¶
Fetching initial state and then subscribing. Anything that happens during the round trip is lost.
// WRONG
List<ChatMessage> history = chatService.getHistory();
ServerEvents.on(ChatEvents.MESSAGE_POSTED, this::accept);
// RIGHT — subscribe, fetch, then reconcile by value equality
ServerEvents.on(ChatEvents.MESSAGE_POSTED, this::accept);
merge(chatService.getHistory());
Polling over RMI¶
Calling an RMI method on a timer to discover whether something changed. The connection is already persistent and bidirectional; the server can simply tell you.
Fix: a shared signal for current values, an event for occurrences.
There is no legitimate reason to poll: RMI answers, events announce, and shared signals and live objects both notify the reactive system.
Wrapping handlers in your own thread¶
// WRONG — redundant. The listener body is already running on a suspendable thread.
sendButton.addClickListener(e ->
new Thread(() -> chatService.sendMessage(text)).start());
Component.addDomEventListener already wraps every DOM listener via Component.threaded(...), so your
handler body runs on a TeaVM green thread — which is exactly why a suspending RMI call works directly
inside it. Adding another thread buys nothing, and updates made from an extra thread may not repaint
until the next UI event.
For delayed work use Window.setTimeout rather than a thread plus a sleep.
Trusting client validation¶
Validation annotations generate client-side field feedback and server-side enforcement, from the same declaration. The client half is a courtesy to the user; it runs in code the user controls.
Never treat a client-side check as a boundary. The server re-validates RMI arguments, LiveSync
mutations and shared-signal writes independently — keep it that way, and put @RolesAllowed on
anything that matters.
Unconsumed push¶
Broadcasting to a topic nothing listens for. It costs a serialization pass and a frame per session, and it reads as working code.
If you publish a topic, subscribe to it somewhere — or delete the publish.