From vaadin-claude
Guides Claude on using Vaadin Signals for reactive state management in Vaadin 25 Flow, including local and shared signals, bindings, and thread-safe updates.
npx claudepluginhub vaadin/claude-plugin --plugin vaadin-claudeThis skill uses the workspace's default tool permissions.
Use the Vaadin MCP tools (`search_vaadin_docs`) to look up the latest documentation whenever uncertain about a specific API detail. Always set `vaadin_version` to `"25.1"` and `ui_language` to `"java"`.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Use the Vaadin MCP tools (search_vaadin_docs) to look up the latest documentation whenever uncertain about a specific API detail. Always set vaadin_version to "25.1" and ui_language to "java".
Signals are a reactive state management system for Vaadin Flow. A signal holds a value, and when that value changes, all dependent parts of the UI automatically update without manually adding and removing change listeners.
Key properties:
ui.access()| Local Signals | Shared Signals | |
|---|---|---|
| Scope | Single UI instance | Multiple users/sessions |
| Cluster support | Single server only | Works across cluster |
| Transactions | No | Yes |
| Classes | ValueSignal<T>, ListSignal<T> | SharedValueSignal<T>, SharedNumberSignal, SharedListSignal<T>, SharedMapSignal<T>, SharedNodeSignal |
| Package | com.vaadin.flow.signals.local | com.vaadin.flow.signals.shared |
Use local signals when:
ListSignal)Use shared signals when:
When using shared signals for multi-user scenarios, enable @Push so changes propagate immediately to all connected UIs.
import com.vaadin.flow.signals.local.ValueSignal;
ValueSignal<String> name = new ValueSignal<>("initial value");
name.set("new value"); // write
String current = name.peek(); // read (non-reactive, outside effects)
name.update(n -> n.toUpperCase()); // atomic read-modify-write
boolean ok = name.replace("OLD", "NEW"); // compare-and-set
Reading values:
get() -- reactive read, registers dependency. Must only be called inside a reactive context (effect, computed, transaction). Throws IllegalStateException outside reactive context.peek() -- non-reactive read, no dependency. Use in click listeners, initialization, or any code outside a reactive context.Custom equality checkers to control when updates are skipped:
ValueSignal<String> name = new ValueSignal<>("John",
(a, b) -> a != null && a.equalsIgnoreCase(b));
name.set("john"); // no update triggered (considered equal)
Read-only view for encapsulation:
Signal<String> readOnly = name.asReadonly();
Working with mutable values -- use modify():
ValueSignal<User> userSignal = new ValueSignal<>(new User("Jane", 25));
userSignal.modify(user -> user.setAge(26)); // correct
// Do NOT mutate objects directly -- changes won't be detected
Transforming values with map():
Signal<String> upper = name.map(String::toUpperCase);
import com.vaadin.flow.signals.local.ListSignal;
ListSignal<String> tags = new ListSignal<>();
ValueSignal<String> last = tags.insertLast("item"); // add to end
ValueSignal<String> first = tags.insertFirst("item"); // add to beginning
ValueSignal<String> mid = tags.insertAt(1, "middle"); // add at index
tags.remove(last); // remove specific entry
tags.clear(); // remove all
tags.moveTo(first, 2); // reorder without recreating
List<ValueSignal<String>> entries = tags.peek(); // snapshot
last.set("updated"); // update individual entry (only its dependents re-render)
Each entry is an independent ValueSignal, so updating one entry only triggers re-renders for components bound to that entry, not the entire list.
import com.vaadin.flow.signals.shared.SharedValueSignal;
SharedValueSignal<String> name = new SharedValueSignal<>(String.class);
name.set("John Doe");
String current = name.peek();
name.update(n -> n.toUpperCase());
name.replace("expected", "newValue");
import com.vaadin.flow.signals.shared.SharedNumberSignal;
SharedNumberSignal counter = new SharedNumberSignal();
counter.set(5);
counter.incrementBy(1);
counter.incrementBy(-2);
int count = counter.getAsInt();
import com.vaadin.flow.signals.shared.SharedListSignal;
import com.vaadin.flow.signals.shared.SharedListSignal.ListPosition;
SharedListSignal<Person> people = new SharedListSignal<>(Person.class);
SharedValueSignal<Person> entry = people.insertLast(new Person("Jane", 25)).signal();
people.insertFirst(new Person("John", 30));
// Precise positioning with ListPosition
people.insertAt("item", ListPosition.after(entry));
people.insertAt("item", ListPosition.before(entry));
people.insertAt("item", ListPosition.first());
people.insertAt("item", ListPosition.last());
// Reorder
people.moveTo(entry, ListPosition.first());
// Read
List<SharedValueSignal<Person>> list = people.peek();
list.get(0).set(new Person("Updated", 26));
import com.vaadin.flow.signals.shared.SharedMapSignal;
SharedMapSignal<String> config = new SharedMapSignal<>(String.class);
config.put("theme", "dark");
config.putIfAbsent("language", "en");
config.remove("language");
Map<String, SharedValueSignal<String>> map = config.peek();
SharedValueSignal<String> themeSignal = map.get("theme");
import com.vaadin.flow.signals.shared.SharedNodeSignal;
SharedNodeSignal user = new SharedNodeSignal();
user.putChildWithValue("name", "John Doe");
user.putChildWithValue("age", 30);
user.insertChildWithValue("Reading", ListPosition.last());
user.peek().mapChildren().get("name").asValue(String.class).peek(); // "John Doe"
user.peek().listChildren().getLast().asValue(String.class).peek(); // "Reading"
SharedMapSignal<String> mapChildren = user.asMap(String.class);
Signal.effect(component, () -> {
// Re-runs automatically when any signal read with get() changes
// Active while component is attached, inactive while detached
System.out.println("Name: " + firstName.get() + " " + lastName.get());
});
Returns a Registration that can be used to remove the effect:
Registration reg = Signal.effect(component, () -> { ... });
reg.remove(); // stop the effect
Contextual effects with EffectContext:
Signal.effect(component, ctx -> {
String value = priceSignal.get();
span.setText("$" + value);
if (!ctx.isInitialRun() && ctx.isBackgroundChange()) {
span.getElement().flashClass("highlight");
}
});
Standalone effects (not tied to a component -- must clean up manually):
Registration cleanup = Signal.unboundEffect(() -> {
System.out.println("Counter: " + counter.get());
});
cleanup.remove(); // required to avoid memory leaks
Signal<String> fullName = Signal.computed(() ->
firstName.get() + " " + lastName.get());
Computed signals are read-only, cached, and recalculate only when dependencies change.
Signal<Boolean> notLoading = Signal.not(loading);
Signal.effect(component, () -> {
String name = nameSignal.get(); // tracked dependency
int count = countSignal.peek(); // NOT tracked, effect won't re-run for this
});
Signal.effect(component, () -> {
String tracked = trackedSignal.get();
Signal.untracked(() -> {
String notTracked = anotherSignal.get(); // not tracked
});
});
Text binding:
span.bindText(nameSignal);
span.bindText(counter.map(c -> String.format("Count: %.0f", c)));
span.bindText(() -> firstName.get() + " " + lastName.get()); // lambda variant
HTML components (Span, Paragraph, H1--H6) also accept signals in constructors:
Paragraph p = new Paragraph(signal); // shorthand for new + bindText
Visibility binding:
detailsPanel.bindVisible(showDetails);
noResults.bindVisible(searchText.map(String::isEmpty));
Enabled state binding:
submitButton.bindEnabled(formValid);
submitButton.bindEnabled(Signal.computed(() ->
!email.get().isEmpty() && password.get().length() >= 8));
Two-way form field binding:
TextField field = new TextField("Name");
field.bindValue(nameSignal, nameSignal::set);
// User types -> signal updates; signal changes -> field updates
Works with all HasValue fields: TextField, TextArea, Checkbox, NumberField, ComboBox, DatePicker, etc.
Two-way binding to record properties:
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), todoSignal.updater(Todo::withDone));
Two-way binding to mutable bean properties:
TextField nameField = new TextField("Name");
nameField.bindValue(userSignal.map(User::getName), userSignal.modifier(User::setName));
Read-only, required indicator, placeholder, helper text:
field.bindReadOnly(lockedSignal);
field.bindRequiredIndicatorVisible(requiredSignal);
field.bindPlaceholder(placeholderSignal);
field.bindHelperText(remaining.map(r -> r + " characters remaining"));
Dynamic children from list signal:
container.bindChildren(items, itemSignal -> {
Span itemView = new Span();
itemView.bindText(itemSignal);
return itemView;
});
The factory runs once per item. Adding/removing items only affects those items. Reordering moves components, not recreates them. Updating an entry value updates only that entry's bindings.
Binding items to data components (Grid, ComboBox):
Signal.effect(grid, () -> grid.setItems(items.getValues().toList()));
Size, class names, themes, styles:
panel.bindWidth(widthSignal);
panel.bindHeight(heightSignal);
panel.bindClassName("highlighted", highlightedSignal);
panel.bindClassNames(classListSignal);
panel.bindThemeName("compact", compactSignal);
layout.getThemeList().bind("dark", darkModeSignal);
panel.getStyle().bind("background-color", bgColorSignal);
Change callbacks on bindings:
span.bindText(priceSignal.map(p -> "$" + p))
.onChange(ctx -> {
if (ctx.isBackgroundChange()) {
ctx.getElement().flashClass("highlight");
}
});
For custom components or fine-grained DOM control:
element.bindText(signal);
element.bindAttribute("aria-label", labelSignal);
element.bindProperty("hidden", hiddenSignal, null); // read-only
element.bindProperty("hidden", hiddenSignal, hiddenSignal::set); // two-way
element.bindVisible(visibleSignal);
element.bindEnabled(enabledSignal);
element.getClassList().bind("active", isActiveSignal);
element.getClassList().bind(classListSignal); // group binding
element.getStyle().bind("color", colorSignal);
element.flashClass("highlight"); // trigger CSS animation
While a signal is bound to an element property, manual changes to that property throw BindingActiveException. Unbind with bindText(null), bindProperty(null), etc.
Declare signals as private instance fields. Each navigation creates a new instance:
@Route("dashboard")
public class DashboardView extends VerticalLayout {
private final ValueSignal<Integer> counter = new ValueSignal<>(0);
// ...
}
Use @Component @VaadinSessionScope beans. One instance per HTTP session, shared across tabs:
@Component
@VaadinSessionScope
public class UserPreferences {
private final ValueSignal<String> theme = new ValueSignal<>("light");
public ValueSignal<String> getThemeSignal() { return theme; }
}
Use @Component (singleton) beans. Shared by all users:
@Component
public class SystemStatus {
private final SharedValueSignal<String> status = new SharedValueSignal<>(String.class);
public Signal<String> getStatus() { return status.asReadonly(); }
public void setStatus(String s) { status.set(s); }
}
| Scope | Declaration | Lifetime |
|---|---|---|
| View | Private instance field | View instance lifetime, new per navigation |
| Session | @Component @VaadinSessionScope bean | HTTP session lifetime |
| Application | @Component (singleton) bean or static field | Application lifetime, shared by all users |
Transactions group multiple shared signal operations into a single atomic unit. Observers see all changes or none:
Signal.runInTransaction(() -> {
firstNameSignal.set("John");
lastNameSignal.set("Doe");
ageSignal.set(30);
});
Verification methods for conditional updates:
Signal.runInTransaction(() -> {
statusSignal.verifyValue("pending");
statusSignal.set("processing");
});
Transactions can return values:
TransactionOperation<String> txOp = Signal.runInTransaction(() -> {
statusSignal.verifyValue("pending");
statusSignal.set("confirmed");
return "Order confirmed";
});
String result = txOp.returnValue();
Local signals (ValueSignal, ListSignal) cannot participate in transactions. Using local signals inside runInTransaction() throws an exception. Use shared signals if you need transactional guarantees.
@Push
public class SharedCounter extends VerticalLayout {
private final SharedNumberSignal counter = new SharedNumberSignal();
public SharedCounter() {
Button button = new Button();
button.addClickListener(click -> counter.incrementBy(1));
button.bindText(counter.map(c -> String.format("Clicked %.0f times", c)));
add(button);
}
}
All users see the same counter value. Clicking in any browser updates all connected UIs. Requires @Push on AppShellConfigurator.
public class TodoList extends VerticalLayout {
record Todo(String text, boolean done) {
Todo withDone(boolean done) { return new Todo(this.text, done); }
}
private final ListSignal<Todo> todos = new ListSignal<>();
private final ValueSignal<String> newTaskText = new ValueSignal<>("");
public TodoList() {
TextField input = new TextField("New todo");
input.bindValue(newTaskText, newTaskText::set);
Button addBtn = new Button("Add");
addBtn.bindEnabled(newTaskText.map(t -> !t.isBlank()));
addBtn.addClickListener(e -> {
todos.insertLast(new Todo(newTaskText.peek(), false));
newTaskText.set("");
});
VerticalLayout list = new VerticalLayout();
list.setPadding(false);
list.bindChildren(todos, todoSignal -> {
HorizontalLayout row = new HorizontalLayout();
row.setAlignItems(FlexComponent.Alignment.CENTER);
Checkbox checkbox = new Checkbox();
checkbox.bindValue(
todoSignal.map(Todo::done),
todoSignal.updater(Todo::withDone));
Span text = new Span();
text.bindText(todoSignal.map(Todo::text));
text.getStyle().bind("text-decoration",
todoSignal.map(t -> t.done() ? "line-through" : "none"));
Button deleteBtn = new Button("Delete",
e -> todos.remove(todoSignal));
row.add(checkbox, text, deleteBtn);
return row;
});
add(new HorizontalLayout(input, addBtn), list);
}
}
Use immutable values -- Strings, primitives, Java records. Mutating an object directly won't trigger reactivity. Always create a new value with update(), or use modify() for mutable beans:
// GOOD: new immutable record
user.update(u -> new User(u.name(), u.age() + 1));
// GOOD: modify() for mutable beans
userSignal.modify(u -> u.setAge(26));
// BAD: mutating in place without modify()
User u = user.peek();
u.setAge(u.getAge() + 1); // change not detected
Prefer direct bindings over effects -- bindText(), bindVisible(), bindEnabled(), bindValue() are more concise and efficient than writing a full Signal.effect() for simple property bindings.
Use peek() outside reactive contexts -- In click listeners, initialization code, or anywhere outside an effect/computed, use peek(). Using get() outside a reactive context throws IllegalStateException.
Use transactions for multi-signal atomic updates (shared signals only) -- prevents observers from seeing partial state.
Use update() for atomic read-modify-write -- counter.update(c -> c + 1) is atomic; reading and then setting is not.
Don't modify signals inside effects or computed callbacks -- they run in read-only transactions. If you must, use Signal.runWithoutTransaction(), but beware of infinite loops.
Use peek() inside effects to read without tracking -- signal.peek() reads the value without creating a dependency.
Enable @Push for shared signals -- when multiple users share signals, enable server push so changes propagate immediately.
Store signals as class fields -- keep all reactive state together at the top of the class for clarity. Computed signals can be declared alongside their sources.
Local signals cannot participate in transactions -- use shared signals if you need runInTransaction().