-
Notifications
You must be signed in to change notification settings - Fork 28
signals patterns reference
Compiled from the official
vaadin/signals-casesrepository (all 20+ use cases) and the Vaadin 25.2 documentation. Use this document when reviewing signal code for correctness, performance, and idiomatic style.
- Taxonomy: Which Signal Type to Use
- get() vs peek() — The Most Misused Decision
- Effect Scoping and Lifecycle
- Split Effects — One Concern Per Effect
- Binding Methods — Prefer Over Manual Effects
- Computed Signals and map()
- Two-Way Mapping: updater() and modifier()
- bindChildren() for Dynamic Lists
- peek() to Avoid Circular Dependencies
- Service-to-Signal Pattern
- Event Bridge Pattern
- Computed Signal Chains
- Session-Scoped Signals
- Async Operations and Signals
- Debounce with Manual Scheduling
- Binder + Signal Integration
- Element.bindAttribute() for Non-Component DOM
- Signal.not() and Boolean Derivations
- Custom Equality Checkers
- Memory and Lifecycle: Standalone vs Component Effects
- Anti-Patterns Index
| Scenario | Signal Type |
|---|---|
| Single-user local UI state (toggle, form field, local filter) | ValueSignal<T> |
| Dynamic list of items, single-user | ListSignal<T> |
| Cross-user shared state (real-time collaboration, dashboards) |
SharedValueSignal<T> / SharedListSignal<T>
|
| Derived/computed value from one source | signal.map(fn) |
| Derived value from multiple sources | Signal.computed(() -> ...) |
| Read-only exposure of a writable signal | signal.asReadonly() |
| Negating a boolean signal | Signal.not(signal) |
Rule: ValueSignal / ListSignal cannot participate in transactions. If you need transactional atomicity (all-or-nothing updates), use shared signals.
Rule: Declare signals as final class fields, grouped at the top of the class. This makes the reactive state explicit.
// Good — state is visible at a glance
public class MyView extends VerticalLayout {
private final ValueSignal<String> querySignal = new ValueSignal<>("");
private final ValueSignal<Boolean> loadingSignal = new ValueSignal<>(false);
private final Signal<Boolean> canSearch = querySignal.map(s -> !s.isBlank());
public MyView() { ... }
}This is the most important distinction and the source of most bugs.
| Method | Registers dependency? | Where to use |
|---|---|---|
get() |
YES | Inside Signal.effect(), Signal.computed(), bindText() lambdas, any reactive context |
peek() |
NO | Click listeners, onAttach(), initialization code, service callbacks, debounce handlers, anywhere outside a reactive context |
Hard rule: get() outside a reactive context throws IllegalStateException. peek() inside a reactive context silently breaks reactivity.
// CORRECT: get() in reactive context
Signal.effect(this, () -> {
String value = nameSignal.get(); // dependency registered
label.setText(value);
});
// CORRECT: peek() in event listener (outside reactive context)
button.addClickListener(e -> {
String current = nameSignal.peek(); // no dependency, just a read
doSomethingWith(current);
});
// CORRECT: peek() in service callback (background thread, not reactive context)
private void onDataUpdate(DashboardData data) {
currentUsersSignal.set(data.currentUsers());
// peek() used to read current window size when computing a new value
if (timelineCategoriesSignal.peek().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.peek().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}Use peek() for addToCart-style logic where you scan the list for an existing item without wanting to subscribe to every item change:
// UC06: Adding to cart — peek() to search without creating subscriptions
private void addToCart(Product product, ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.peek().stream()
.filter(signal -> signal.peek().product().id().equals(product.id()))
.findFirst()
.ifPresentOrElse(
existing -> existing.set(existing.peek().withQuantity(existing.peek().quantity() + 1)),
() -> cartItemsSignal.insertLast(new CartItem(product, 1)));
}Signal.effect(component, () -> { ... });- Active while
componentis attached to the DOM. - Automatically paused when component is detached; resumed on re-attach.
- No manual cleanup needed.
Registration cleanup = Signal.unboundEffect(() -> { ... });
// Must call cleanup.remove() when done — memory leak risk otherwiseUse only when the effect is not tied to any UI component lifetime (e.g., service-level background tracking). Always store the Registration and remove it explicitly.
Signal.effect(this, ctx -> {
String value = priceSignal.get();
span.setText("$" + value);
if (!ctx.isInitialRun() && ctx.isBackgroundChange()) {
span.getElement().executeJs("this.classList.add('highlight')");
}
});ctx.isInitialRun() — true on first execution.
ctx.isBackgroundChange() — true when triggered by a different user session or background thread.
Registration effectRegistration = Signal.effect(chart, () -> { ... });
// Later:
effectRegistration.remove();Useful when you need to detach an effect before the component is detached.
Rule: Each effect has exactly one responsibility. Never bundle multiple independent concerns into one large effect.
Why it matters: When a signal changes, only the effects that depend on it re-run. A fat effect re-runs on any of its many dependencies, doing unnecessary work.
// WRONG: One effect doing multiple things — ALL re-runs when ANY dependency changes
Signal.effect(container, () -> {
nameLabel.setText(userSignal.get().name());
ageLabel.setText(String.valueOf(userSignal.get().age()));
statusIndicator.setVisible(userSignal.get().isActive());
// ... more updates
});
// CORRECT: Separate bindings per concern — each updates only when its dependency changes
nameLabel.bindText(userSignal.map(User::name));
ageLabel.bindText(userSignal.map(u -> String.valueOf(u.age())));
statusIndicator.bindVisible(userSignal.map(User::isActive));Real-world split from UC14 (dashboard with charts):
// Each series gets its own effect — only that series rerenders when its signal changes
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Separate effect for x-axis categories
Signal.effect(chart, () -> xAxis.setCategories(
timelineCategoriesSignal.get().stream().map(Signal::get).toArray(String[]::new)));
// Separate "coordination" effect that only triggers chart.drawChart()
Signal.effect(chart, () -> {
berlinTimelineSignal.get(); // register dependency
londonTimelineSignal.get(); // register dependency
newYorkTimelineSignal.get(); // register dependency
chart.drawChart();
});This approach: data effects update series independently; a single coordination effect fires drawChart() only after all series have had a chance to update.
Rule: Use binding methods for standard component properties. Use Signal.effect() only for custom logic that binding methods cannot express.
| Method | Target |
|---|---|
bindText(signal) |
Text content (also: new Span(signal), new Paragraph(signal)) |
bindVisible(signal) |
Visibility |
bindEnabled(signal) |
Enabled/disabled state |
bindValue(readSignal, writeFn) |
Two-way form field binding |
bindReadOnly(signal) |
Read-only state |
bindRequiredIndicatorVisible(signal) |
Required indicator |
bindClassName(name, booleanSignal) |
CSS class toggle |
bindClassNames(listSignal) |
Full CSS class list |
bindThemeName(name, booleanSignal) |
Theme variant toggle |
bindThemeVariant(variant, booleanSignal) |
Button/component theme variant |
bindHelperText(signal) |
Helper text |
bindPlaceholder(signal) |
Placeholder text |
bindWidth(signal) / bindHeight(signal)
|
Size |
getStyle().bind(property, signal) |
Inline CSS property |
getThemeList().bind(name, booleanSignal) |
Theme class toggle |
getElement().getClassList().bind(...) |
Element-level class |
When a binding depends on multiple signals, pass a lambda instead of a signal directly:
// Depends on multiple signals — use lambda
section.bindVisible(() -> needsVisaSignal.get() && visaTypeSignal.get() == VisaType.H1B);
button.bindEnabled(() -> binder.validationStatusSignal().get().isOk()
&& submissionStateSignal.get() != SubmissionState.SUBMITTING);The framework tracks which signals the lambda reads during execution and creates the appropriate dependencies dynamically.
ValueSignal<Integer> age = new ValueSignal<>(0);
Signal<String> category = age.map(a -> a < 18 ? "Child" : (a < 65 ? "Adult" : "Senior"));
Signal<Boolean> adult = age.map(a -> a >= 18);- Read-only.
- Lazy: only recalculates when the source changes.
- Cached: multiple reads return the cached value between changes.
Signal<Double> total = Signal.computed(() -> {
double subtotal = price.get() * quantity.get();
return subtotal * (1 + taxRate.get());
});- Use when combining two or more source signals.
- Dependencies tracked dynamically — if a branch is not taken, signals in that branch are not dependencies.
// Lambda: recalculates on every read AND on every dependency change
span.bindText(() -> firstName.get() + " " + lastName.get());
// Signal.computed(): caches the result, only recalculates on dependency change
span.bindText(Signal.computed(() -> firstName.get() + " " + lastName.get()));For cheap string concat: lambda is fine. For expensive computations that might be read multiple times: use Signal.computed() explicitly.
Computed signals can depend on other computed signals. The framework traces the full dependency graph automatically:
Signal<BigDecimal> subtotalSignal = Signal.computed(() ->
cartItemsSignal.get().stream().map(ValueSignal::get)
.map(item -> item.product().price().multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add));
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
DiscountCode discount = validateDiscountCode(discountCodeSignal.get());
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage()) // depends on subtotalSignal
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
Signal<BigDecimal> taxSignal = Signal.computed(() ->
subtotalSignal.get().subtract(discountSignal.get()) // depends on both
.multiply(new BigDecimal("0.08")).setScale(2, RoundingMode.HALF_UP));
Signal<BigDecimal> totalSignal = Signal.computed(() ->
subtotalSignal.get().subtract(discountSignal.get())
.add(shippingSignal.get()).add(taxSignal.get())
.setScale(2, RoundingMode.HALF_UP));When quantity changes in a cart item: subtotal → discount → tax → total all recalculate automatically. No manual wiring.
Dependencies are tracked based on what was actually read in the last execution:
Signal.effect(component, () -> {
if (showDetails.get()) {
component.setText(details.get()); // showDetails + details are dependencies
} else {
component.setText(summary.get()); // only showDetails + summary are dependencies
}
});When showDetails is false, changes to details do NOT trigger re-execution. This is a performance optimization built into the tracking system.
For binding a form field directly to a property of a complex record or bean.
record Todo(String text, boolean done) {
Todo withDone(boolean done) { return new Todo(this.text, done); }
}
ValueSignal<Todo> todoSignal = new ValueSignal<>(new Todo("Write docs", false));
Checkbox checkbox = new Checkbox();
checkbox.bindValue(
todoSignal.map(Todo::done), // read: Todo -> Boolean
todoSignal.updater(Todo::withDone) // write: (Todo, Boolean) -> Todo
);updater(merger) receives the current parent value and the new child value, returns a new parent. The signal framework calls set() with the returned value automatically.
TextField nameField = new TextField("Name");
nameField.bindValue(
userSignal.map(User::getName),
userSignal.modifier(User::setName) // mutates in place, signal handles change notification
);Prefer records over beans. Use modifier() only when working with existing mutable bean classes.
When the property lives inside a nested object:
Signal<Address> addressSignal = personSignal.map(Person::address);
cityField.bindValue(
addressSignal.map(Address::city),
personSignal.updater((person, city) ->
person.withAddress(person.address().withCity(city)))
);The updater always operates on the top-level signal (personSignal). The read side uses the intermediate addressSignal for clarity.
Use Vaadin Binder instead when you need:
- Field-level validation messages
- Cross-field validation
-
isValid()checks -
validationStatusSignal()for reactive valid/invalid state
Use map()+updater() for simple field-to-record binding without validation.
Rule: Use bindChildren() for dynamic component lists, never manually add/remove children in a loop.
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
VerticalLayout list = new VerticalLayout();
list.bindChildren(cartItemsSignal, itemSignal -> createCartItemRow(itemSignal));What bindChildren() guarantees:
- Factory function runs once per item (not on every list change)
- Add: new component created, appended
- Remove: component detached
- Reorder: components moved in DOM, not recreated
- Value update: existing component's bindings update, not recreated
Performance implication: If you use setItems() on a Grid or similar data component (which re-renders the entire list), that is NOT bindChildren(). For containers where you want individual components per item, bindChildren() is the correct API.
When to use Signal.effect() with setItems() instead:
// For Grid, ComboBox, VirtualList, etc. that manage their own rendering:
Signal.effect(grid, () -> grid.setItems(
itemsSignal.get().stream().map(ValueSignal::get).toList()));bindChildren() = custom component per item in a layout container.
Signal.effect() + setItems() = data component (Grid, VirtualList) that renders its own rows.
UC15 trick — peek() inside bindChildren() factory when items are immutable:
// Items in this list are never updated after creation — peek() is correct
resultsContainer.bindChildren(searchResultsSignal,
productSignal -> createProductCard(productSignal.peek()));If items are never mutated after insertion, peek() in the factory saves subscription overhead. Only use this when you know items are immutable.
The canonical example: a "change tracker" that stores both previous and current value.
record Change(double previous, double current) {}
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue()));
// Effect that tracks previous value — MUST use peek() on changeSignal
Signal.unboundEffect(() -> {
double current = signal.get(); // creates dependency on 'signal'
double previous = changeSignal.peek().current(); // NO dependency on changeSignal
changeSignal.set(new Change(previous, current));
});If changeSignal.get() were used instead of peek(), the effect would depend on changeSignal. Since the effect also writes to changeSignal, it would create an infinite re-run loop. peek() breaks the cycle by reading the value without registering the dependency.
Pattern: When an effect needs to read a signal that it also writes to, use peek() for the read.
Rule: Backend services must not know about signals or UI components. Callbacks are the boundary. Callbacks only update signals — they never manipulate UI directly.
// CORRECT architecture (UC14, real-time dashboard)
// Service: works with plain data, calls callbacks
public class SchedulerService {
public void scheduleDashboardDataUpdate(Consumer<DashboardData> callback) { ... }
}
// View: registers callback, callback ONLY updates signals
public class DashboardView extends VerticalLayout {
private final ValueSignal<Number> usersSignal = new ValueSignal<>(0);
public DashboardView(SchedulerService service) {
service.scheduleDashboardDataUpdate(this::onDataUpdate);
// ... build UI bound to signals
}
// Callback: signals ONLY — no UI calls
private void onDataUpdate(DashboardData data) {
usersSignal.set(data.currentUsers()); // OK
// label.setText(...) — FORBIDDEN here
}
}No UI.access() needed: Local signals (ValueSignal, ListSignal) are thread-safe and handle UI synchronization internally. You can call .set() from any thread. Push (@Push) must still be enabled for real-time delivery to the browser.
Rule: Wrap any event-based API into a signal using event → signal.set(). The framework provides no magic here — it's always a manual adapter.
// Bridge 1: Upload callbacks → ValueSignal
Upload upload = new Upload(UploadHandler.inMemory(...)
.whenStart(ctx -> uploadStateSignal.set(new UploadState.InProgress(...)))
.onProgress((ctx, transferred, total) -> uploadStateSignal.set(...))
.whenComplete((ctx, success) -> { if (!success) uploadStateSignal.set(new UploadState.Failed(...)); }));
// Bridge 2: Keyboard shortcuts → ValueSignal
Shortcuts.addShortcutListener(this, () -> lastShortcutSignal.set("Ctrl+K"), Key.KEY_K, KeyModifier.CONTROL);
// Bridge 3: Browser matchMedia → ValueSignal (requires @ClientCallable)
getElement().executeJs("""
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => this.$server.onDarkModeChange(mq.matches));
""");
@ClientCallable
public void onDarkModeChange(boolean dark) {
darkModeSignal.set(dark);
}Once bridged, the events participate in all normal signal patterns (computed, effects, bindings).
Always clean up browser-side listeners on detach:
addDetachListener(detach -> {
detach.getUI().getPage().executeJs("if (window[$0]) { window[$0](); delete window[$0]; }", key);
});Complex derivation should be broken into named steps, not one large lambda:
// UC14: Derived UI state from a single enum signal — each step is clear
Signal<Boolean> isLoadingSignal = stateSignal.map(state ->
state == LoadingState.LOADING || state == LoadingState.GENERATING);
// Each metric card gets its own computed signal
Signal<String> revenueSignal = reportDataSignal.map(report ->
report.isEmpty() ? "" : String.format("$%,d", report.getTotalRevenue()));
Signal<String> ordersSignal = reportDataSignal.map(report ->
report.isEmpty() ? "" : String.format("%,d", report.getTotalOrders()));Benefit: Each signal has a clear name and single purpose. When debugging reactivity, you trace the chain by name rather than reading a sprawling lambda.
UC10: Composing unrelated signals into one computed summary:
Signal<String> summarySignal = Signal.computed(() -> {
String uploadPart = switch (uploadStateSignal.get()) { ... };
String shortcutPart = "Shortcut: " + lastShortcutSignal.get();
String darkPart = darkModeSignal.get() ? "Theme: dark" : "Theme: light";
return uploadPart + " | " + shortcutPart + " | " + darkPart;
});This is idiomatic: bridged event signals from multiple unrelated sources composed into one reactive display signal.
For user preferences that must survive navigation between views, inject the signal carrier as a @SessionScope Spring bean:
@Component
@SessionScope
public class UserPreferences {
public static final String DEFAULT_COLOR = "var(--lumo-base-color)";
private final ValueSignal<String> backgroundColorSignal = new ValueSignal<>(DEFAULT_COLOR);
public ValueSignal<String> backgroundColorSignal() {
return backgroundColorSignal;
}
}
// In a view or layout:
public class MainLayout extends AppLayout {
public MainLayout(UserPreferences preferences) {
Signal<String> colorSignal = preferences.backgroundColorSignal();
getContent().getStyle().bind("background-color", colorSignal);
}
}
// In a settings view:
public class UseCase20View extends VerticalLayout {
public UseCase20View(UserPreferences preferences) {
ValueSignal<String> colorSignal = preferences.backgroundColorSignal();
colorPicker.bindValue(colorSignal, colorSignal::set);
}
}Key: The signal lives in the bean (not the view). Views receive and bind to the same signal instance. When any view updates it, all bound views update automatically — including across navigation.
Rule: Signals are thread-safe. Set signal values directly from CompletableFuture callbacks — no UI.access() needed.
private final ValueSignal<LoadingState> stateSignal = new ValueSignal<>(LoadingState.IDLE);
private final ValueSignal<ReportData> reportDataSignal = new ValueSignal<>(ReportData.empty());
private void loadReport() {
stateSignal.set(LoadingState.LOADING);
// Read non-reactive state before async — peek() because we are not in reactive context
boolean shouldFail = shouldFailSignal.peek();
analyticsService.fetchReportData(shouldFail)
.thenCompose(rawData -> {
stateSignal.set(LoadingState.GENERATING); // direct, no UI.access()
return analyticsService.generateReportFromData(rawData, shouldFail);
})
.thenAccept(report -> {
reportDataSignal.set(report); // direct, no UI.access()
stateSignal.set(LoadingState.SUCCESS);
})
.exceptionally(error -> {
stateSignal.set(LoadingState.ERROR);
return null;
});
}UI visibility switches reactively from the signal, not from the async callbacks:
idleContent.bindVisible(() -> stateSignal.get() == LoadingState.IDLE);
loadingContent.bindVisible(isLoadingSignal);
successContent.bindVisible(() -> stateSignal.get() == LoadingState.SUCCESS);
errorContent.bindVisible(() -> stateSignal.get() == LoadingState.ERROR);Per-item async loading (UC19):
Use a ListSignal<DataItem> where each item has its own ValueSignal<LoadingState>. Individual items update independently without causing the whole list to re-render.
Signals have no built-in debounce. Use a ScheduledExecutorService manually:
private final ValueSignal<String> instantQuerySignal = new ValueSignal<>("");
private final ValueSignal<String> debouncedQuerySignal = new ValueSignal<>("");
private final ScheduledExecutorService debounceExecutor = Executors.newSingleThreadScheduledExecutor();
private volatile ScheduledFuture<?> pendingDebounce = null;
// EAGER mode sends on every keystroke
searchField.setValueChangeMode(ValueChangeMode.EAGER);
searchField.bindValue(instantQuerySignal, value -> {
instantQuerySignal.set(value);
scheduleDebouncedSearch(value);
});
private void scheduleDebouncedSearch(String query) {
ScheduledFuture<?> pending = pendingDebounce;
if (pending != null) pending.cancel(false);
pendingDebounce = debounceExecutor.schedule(() -> {
debouncedQuerySignal.set(query); // thread-safe
performSearch(query);
}, 1000, TimeUnit.MILLISECONDS);
}
@Override
protected void onDetach(DetachEvent e) {
super.onDetach(e);
if (pendingDebounce != null) pendingDebounce.cancel(false);
debounceExecutor.shutdownNow();
}instantQuerySignal and debouncedQuerySignal serve different UI roles: instant shows "what you typed", debounced shows "what was searched". Displaying both is a good UX pattern for revealing debounce behavior to users.
Vaadin Binder exposes two signal integration points:
Binder<AccountData> binder = new Binder<>(AccountData.class);
// ... add field bindings with validators ...
// Use Binder's validation signal to drive submit button state
submitButton.bindEnabled(() ->
binder.validationStatusSignal().get().isOk()
&& submissionStateSignal.get() != SubmissionState.SUBMITTING);validationStatusSignal() returns a reactive Signal<BinderValidationStatus<T>> that updates on every validation event. .isOk() is true when all bound fields pass validation.
When a field validator needs the value of another field (e.g., confirm password), read the other binding's valueSignal() inside the validator:
var passwordBinding = binder.forField(passwordField)
.withValidator(value -> value != null && value.length() >= 8, "Password too short")
.bind(AccountData::getPassword, AccountData::setPassword);
binder.forField(confirmField)
.withValidator(
value -> value != null && value.equals(passwordBinding.valueSignal().get()),
"Passwords do not match")
.bind(AccountData::getConfirmPassword, AccountData::setConfirmPassword);passwordBinding.valueSignal().get() reads the current field signal value at validation time — reactive cross-field validation without manual listeners.
A common pattern in UC01:
ValueSignal<SubmissionState> submissionStateSignal = new ValueSignal<>(SubmissionState.IDLE);
// Button text reacts to submission state
submitButton.bindText(submissionStateSignal.map(state -> switch (state) {
case IDLE -> "Create Account";
case SUBMITTING -> "Creating...";
case SUCCESS -> "Success!";
case ERROR -> "Retry";
}));
// Theme variant reacts to success state
submitButton.bindThemeVariant(ButtonVariant.LUMO_SUCCESS,
submissionStateSignal.map(SubmissionState.SUCCESS::equals));
submitButton.bindThemeVariant(ButtonVariant.LUMO_PRIMARY,
Signal.not(submissionStateSignal.map(SubmissionState.SUCCESS::equals)));For non-component DOM elements (e.g., raw SVG elements), use Element.bindAttribute():
Element rect = new Element("rect");
// Single-signal binding
rect.bindAttribute("fill", rectFillSignal);
rect.bindAttribute("opacity", rectOpacitySignal.map(String::valueOf));
// Multi-signal lambda
rect.bindAttribute("stroke-width", () -> {
int base = rectStrokeWidthSignal.get();
boolean selected = selectedShapeSignal.get() == 0;
return String.valueOf(selected ? base + 2 : base);
});
// Signal.computed() for expensive transform calculations
rect.bindAttribute("transform", Signal.computed(() -> {
int centerX = rectXSignal.get() + rectWidthSignal.get() / 2;
int centerY = rectYSignal.get() + rectHeightSignal.get() / 2;
return String.format("rotate(%d %d %d)", rectRotationSignal.get(), centerX, centerY);
}));Same rules apply: single signal → direct; multi-signal → lambda or Signal.computed().
Similarly for inline styles at the element level:
dot.getStyle().bind("background-color",
() -> activeSignal.get() ? "var(--lumo-contrast-80pct)" : "var(--lumo-contrast-30pct)");Signal.not() creates a computed signal that negates a boolean signal. Preferred over .map(v -> !v) for clarity:
ValueSignal<Boolean> loading = new ValueSignal<>(true);
Signal<Boolean> notLoading = Signal.not(loading);
button.bindEnabled(notLoading);For conditional theme variants, the complement pattern from UC01:
Signal<Boolean> isSuccess = stateSignal.map(SubmissionState.SUCCESS::equals);
button.bindThemeVariant(ButtonVariant.LUMO_SUCCESS, isSuccess);
button.bindThemeVariant(ButtonVariant.LUMO_PRIMARY, Signal.not(isSuccess));By default, signal equality uses Objects.equals(). Override to suppress updates when values are semantically equal:
// Skip updates for case-insensitive string equality
ValueSignal<String> name = new ValueSignal<>("John",
(a, b) -> a != null && a.equalsIgnoreCase(b));
name.set("john"); // No update triggered
name.set("Jane"); // Update triggeredFor ListSignal, the equality checker applies to all entry ValueSignal instances in the list:
ListSignal<String> items = new ListSignal<>(
(a, b) -> a != null && a.equalsIgnoreCase(b));Use this when your value type has meaningful equality that differs from Objects.equals(), to avoid unnecessary re-renders.
// CORRECT: signals as fields — reachable, lifecycle managed by component
public class MyView extends VerticalLayout {
private final ValueSignal<String> stateSignal = new ValueSignal<>("idle");
}
// WRONG: signal in constructor scope — effect keeps a closure reference,
// but the signal itself may be GC'd if nothing else holds a reference
public MyView() {
ValueSignal<String> localSignal = new ValueSignal<>("idle");
Signal.effect(this, () -> label.setText(localSignal.get()));
// localSignal not stored anywhere — risky
}ListSignal<String> items = new ListSignal<>();
ValueSignal<String> entry = items.insertLast("value");
// entry is a live ValueSignal — keep a reference if you need to update it later
entry.set("updated"); // updates the item in the list reactively
// Or retrieve it later via peek()
items.peek().get(0).set("updated");Effects run inside a read-only transaction. Modifying a signal from inside an effect normally throws. If you absolutely must:
Signal.effect(component, () -> {
String value = sourceSignal.get();
// WARNING: infinite loop risk — only use when you are certain
Signal.runWithoutTransaction(() -> otherSignal.set(value));
});Prefer Signal.computed() instead for derived values that depend on another signal.
For reading signal values inside an effect for logging or analytics without creating a dependency:
Signal.effect(component, () -> {
String primary = primarySignal.get(); // tracked dependency
Signal.untracked(() -> {
String analytics = analyticsSignal.get(); // NOT a dependency
log(analytics);
});
});| Anti-Pattern | Correct Pattern |
|---|---|
get() in click listener / onAttach() / service callback |
Use peek() outside reactive contexts |
signal.get() inside an effect when you also write to that signal |
Use signal.peek() for the read |
One large Signal.effect() doing many unrelated things |
Split into separate bindXxx() calls or separate effects |
Signal.effect() for simple property binding |
Use bindText(), bindVisible(), bindEnabled(), etc. |
| Storing state in regular fields alongside signals | All mutable UI state in signals |
Signal.unboundEffect() without storing the Registration
|
Always store and call remove()
|
cartItemsSignal.get().stream().filter(...)... inside an effect (subscribes to every item) |
Use peek() in scan-and-write operations |
new ValueSignal<>() as a local constructor variable when used in effects |
Store as a class field |
Calling UI.access() when updating a local signal from a background thread |
Not needed — local signals are thread-safe |
Using Signal.computed() for derived values but mutating the source inside an effect |
Source mutations go through signal.set() / signal.update() outside the computed |
bindValue(signal) only (read-only) when you need two-way binding |
bindValue(readSignal, writeFn) for two-way |
Manually scanning ListSignal.get() to update items in an effect |
Use bindChildren() for component lists |
Not cleaning up ScheduledExecutorService on onDetach()
|
Always shutdownNow() + cancel pending futures on detach |
| Modifying a mutable object retrieved from a signal directly | Use modify(Consumer) or update(UnaryOperator)
|
Source: vaadin/signals-cases all 20+ use cases + Vaadin 25.2 official documentation (effects-computed.md, local-signals.md, building-ui.md, shared-signals.md, usage-examples/).*