We just released Vaadin 25.1.0-beta1 where one of the big new features is that signals move out of the preview phase. This is an excellent moment to try out signals in practice and give us feedback in case you notice anything in the API that we should still tweak before the final 25.1.0 release. If you want to understand more about the benefits of signals, then look here.
To see how signals can be used in practice, let’s build a simple shopping cart view. The business logic in the tutorial is somewhat simplistic to help keep focus on state management. As an example of the simplicity, all prices are defined as integers.
1. Hello signals
Start by creating a Vaadin 25.1 application at start.vaadin.com without a sample view. The tutorial has been tested with beta1. If you encounter any issues with a newer version, then it might be worthwhile to try again specifically with beta1.
Create a new view class with this content:
@Route
public class MainView extends VerticalLayout {
public MainView() {
var shippingCost = new ValueSignal<>("$0.00");
Button expressButton = new Button("Express shipping", click -> {
shippingCost.set("$10.00");
});
add(expressButton);
Span shippingCostSpan = new Span(shippingCost);
add(shippingCostSpan);
}
}
In this very simple example, shippingCost is a signal that contains a string with the shipping cost. The span is created with a binding to the signal so that the current signal value is always used as the span content. Finally, there’s a button that changes the signal value, which in turn automatically updates the span content.
2. Shipping cost as an integer: inline computed signal
The first improvement is to change the type of the signal to Integer and format that number in the signal binding. The formatting is implemented as a lambda expression that defines a computed signal. When the callback is run, the dependency on shippingCost is automatically detected.
var shippingCost = new ValueSignal<>(0);
Button expressButton = new Button("Express shipping", click -> {
shippingCost.set(10);
});
add(expressButton);
Span shippingCostSpan = new Span(() -> "Shipping: " + formatAmount(shippingCost.get()));
add(shippingCostSpan);
formatAmount is a simple helper method that knows nothing about signals.
private static String formatAmount(int amount) {
return "$" + amount + ".00";
}
3. Express shipping signal: computed signal using map()
Rather than using the shipping cost as the authoritative state, we want to keep track of the chosen shipping method. In this simple case, that’s just a boolean value to indicate whether express shipping is selected. The cost is computed based on the shipping method.
var expressShipping = new ValueSignal<>(false);
var shippingCost = expressShipping.map(express -> express ? 10 : 0);
Button expressButton = new Button("Express shipping", click -> {
expressShipping.set(true);
});
While the new shipping cost signal could be directly defined using a lambda function, we’re instead using the map() method here. Both approaches are functionally equivalent. In this case, the map() method is more practical since there’s no need to explicitly declare the type of the variable.
Computed signals can be chained. The logic that formats the content in the span works the same regardless of whether shippingCost is a ValueSignal that directly holds the value or a computed signal.
4. Checkbox for toggling express shipping: two-way binding
We have so far used a one-way binding to update the span content based on a signal value and then updated that signal value by explicitly setting it in the button click listener. It is also possible to bind the signal to a component using a two-way binding so that the signal value is updated by the component. We can add a checkbox that allows toggling the expressShipping value both on and off.
Checkbox expressShippingToggle = new Checkbox("Express shipping");
expressShippingToggle.bindValue(expressShipping, expressShipping::set);
add(expressShippingToggle);
You can recognize a two-way binding by the second parameter to the bind method that updates the signal when the component value changes. This simple example uses a method reference to directly set the signal value. We’ll later see a more complex setup.
Note that the checkbox binding works in both directions. Changing the signal value by clicking the button will also check the checkbox. The button no longer serves any purpose in the tutorial and can be removed.
5. Show the shipping label only when relevant: visibility binding
We might want to show the shipping cost span only in case express shipping is selected but keep it hidden when standard shipping is selected. This is done by binding the visibility of the span to a signal. It might feel inefficient to always add the span to the layout even when it’s not shown. While there is indeed some overhead, the case is well optimized in the framework to the point where the simplicity is worthwhile in most cases.
shippingCostSpan.bindVisible(expressShipping);
In addition to the cases we’ve seen with binding text content, field value and visibility, there are also bind methods for many other component features.
6. Price from shipping cart items: cached computed signal
With the shipping cost functionality completed, let’s turn our attention to the items in the shopping cart. There are multiple steps and we’ll get started by adding just a single item so that we can add spans for a subtotal and total price. First, we need a record that represents an item in the cart.
record CartItem(String product, int unitPrice, int quantity) {
int price() {
return unitPrice * quantity;
}
CartItem withQuantity(int quantity) {
return new CartItem(product, unitPrice, quantity);
}
static CartItem randomize() {
ThreadLocalRandom random = ThreadLocalRandom.current();
return new CartItem("Product " + random.nextInt(100), 5 + random.nextInt(4), 1);
}
}
We can then create a signal that contains a single item and a computed signal for the price of the cart content. We’ll use Signal.computed() to create a computed signal that caches its value as long as no dependency changes. This won’t really matter with the simple computation that we have right now but we will later change it to loop over all items in the cart.
var cart = new ValueSignal<>(CartItem.randomize());
var subtotal = Signal.computed(() -> cart.get().price());
The last step is to add spans for showing the total and subtotal values to the user. The total price is the sum of two different signal values. A computed signal can use an arbitrary number of other signals and it will automatically update whenever any of the relevant signals change.
add(new Span(() -> "Subtotal: " + formatAmount(subtotal.get())));
add(shippingCostSpan);
add(new Span(() -> "Total: " + formatAmount(subtotal.get() + shippingCost.get())));
7. Row factory method: create a component for a signal
We will now implement a factory method that receives a signal instance holding a cart item and returns a component hierarchy with bindings for showing the values from that signal. This will be used for only a single row for now and will later be used also for supporting multiple rows.
private static HorizontalLayout createCartRow(ValueSignal<CartItem> item) {
HorizontalLayout layout = new HorizontalLayout();
layout.setAlignItems(Alignment.BASELINE);
layout.add(new Span(item.map(CartItem::product)));
Span quantityField = new Span(item.map(CartItem::quantity).map(String::valueOf));
layout.add(quantityField);
layout.add(new Span(() -> " * " + formatAmount(item.get().unitPrice) + " = " + formatAmount(item.get().price())));
return layout;
}
quantityField just shows the quantity as a string for now but we will later change it to an integer field to allow changing the quantity.
Note that the span with the product name and the span with the unit and total prices use different APIs for creating the computed signals used for the bindings. Both ways are functionally equivalent and the choice between them is made based on what makes the code more practical. For the product name, the map() method is very practical because we can use a method reference to extract the product name. The prices are on the other hand more practical as an inline lambda function for string concatenation.
We also need to update the view itself to show a cart row for the single item in the cart.
add(createCartRow(cart));
8. Changing the quantity: computed two-way binding
Changing the quantity of an item in the cart with an integer field means that there’s a two-way binding. Since the quantity is computed based on the signal containing the cart item, a conversion is also needed in the reverse direction. We use an updater helper method that creates a write callback that accepts the new quantity value from the field and uses CartItem::withQuantity to create a new cart item instance that is set as the value of the item signal.
IntegerField quantityField = new IntegerField();
quantityField.setWidth("7em");
quantityField.setStepButtonsVisible(true);
quantityField.bindValue(item.map(CartItem::quantity), item.updater(CartItem::withQuantity));
At this point, we can start to really appreciate the benefit of using signals. Changing the quantity will automatically update calculations in three different places: the cart row, the subtotal and the total price. All of those are automatically updated by signal bindings whenever the two-way binding for the quantity updates the signal value. Another benefit is that there’s no need to separately configure the component with an initial value: the value in the integer field is automatically initialized with the quantity from the signal when the binding is created.
9. Multiple items: binding child components
Support for multiple items requires three changes: define the cart signal as a list, update the subtotal calculation to loop through all items, and create a layout component with a binding that sets the content of the layout based on what’s in the list.
When creating the list, we also insert an initial item so that something is shown right away.
var cart = new ListSignal<CartItem>();
cart.insertLast(CartItem.randomize());
The computation for the subtotal now loops over all items in the list. Note that the value of the cart signal is a list of signals. In this way, updates for the value of one item in the list will not affect bindings for other items. When iterating over the list, it also means that we need to separately get the value of each of the signals.
var subtotal = Signal.computed(() -> cart.get().stream().map(Signal::get).mapToInt(CartItem::price).sum());
The list of cart rows is shown in a vertical layout. There is a binding for the children of the list based on the cart list with the previously implemented helper method that creates a cart row component based on a signal containing a cart item.
VerticalLayout cartRows = new VerticalLayout();
cartRows.bindChildren(cart, item -> createCartRow(item));
add(cartRows);
10. Adding and removing items: manipulating list signals
Adding a new item to the shopping cart is again showing the power of using signals. We only need to create a new cart item instance and insert it into the list. All computed costs are automatically updated and a new cart row component is automatically created and added to the layout
add(new Button("Add something to the cart", click -> {
cart.insertLast(CartItem.randomize());
}));
Removing an item is just as simple in itself. The only additional complication in this case is that the factory method that creates a cart row now also needs a reference to the list signal so that the row can remove itself when the remove button is clicked.
Pass a reference the cart to the createCartRow method.
cartRows.bindChildren(cart, itemSignal -> createCartRow(itemSignal, cart));
Add a new argument to the createCartRow method to receive the cart reference.
private static HorizontalLayout createCartRow(ValueSignal<CartItem> item, ListSignal<CartItem> cart) {
And then finally add a button to each cart row that removes the corresponding item from the list.
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
removeButton.addClickListener(click -> cart.remove(item));
layout.add(removeButton);
11. Bonus task: share the cart with multiple users
A collaborative shopping cart that is shared between all users of the application is maybe not the best idea in a real application. But we’ll still do it here just to understand the concepts. The key to any collaborative functionality is to make some part of the state shared between all collaborating users. To make this case even more weird, we will share the shopping cart items between all users while allowing each user to have their own shipping method selection.
The first step for anything collaborative is to enable Vaadin’s push functionality so that changes can be pushed out to the user at any time and not only when that user interacts with the application. This is done by adding the @Push annotation to the application’s AppShellConfigurator implementation, which is in the Application class with the default project setup.
@Push
public class Application implements AppShellConfigurator {
Going back to the view, we change the type of the list to SharedListSignal. The shared signal types are intended for state that is shared. They also help with handling potential conflicts in case multiple users try to update the state at the same time.
SharedListSignal<CartItem> cart = new SharedListSignal<>(CartItem.class);
We also need to update the signature of the createCartRow method to accept shared signals instead of the regular local signal types.
private static HorizontalLayout createCartRow(SharedValueSignal<CartItem> item, SharedListSignal<CartItem> cart) {
The result is still not collaborative since each view creates its own instance of the cart list. As a final step, we move the variable out from the constructor and convert it to a static field so that all views within the same JVM will use the same list instance.
private static SharedListSignal<CartItem> cart = new SharedListSignal<>(CartItem.class);
static {
cart.insertLast(CartItem.randomize());
}
Note that this last example does not currently work fully due to Can't read LocalSignal in compute callback that was directly triggered from a shared signal transaction · Issue #23782 · vaadin/flow · GitHub. We can work around this by changing the computed signal for the total price span to bypass the internal transaction handling that triggers the bug.
add(new Span(() -> {
return Signal.runWithoutTransaction(() -> {
return "Total: " + formatAmount(subtotal.get() + shippingCost.get());
});
}));