Shopping Cart Example
- Overview
- State Management with Signals
- Computed Totals
- Binding Totals to UI
- Form Field Binding
- Dynamic List Rendering with Children Binding
- Creating Cart Item Rows
- Adding Items to Cart
- Reactivity Flow
- When to Use ListSignal
- Key Takeaways
This example demonstrates building a reactive shopping cart with automatic total calculations. It shows how to manage dynamic lists of items and efficiently update the UI when items are added, removed, or modified.
Overview
The shopping cart tracks:
-
Cart items with quantities
-
Discount codes
-
Shipping options
-
Real-time totals (subtotal, discount, tax, shipping, and grand total)
All calculations update automatically as users modify the cart, apply discounts, or change shipping options.
State Management with Signals
The cart uses three signals to track its state:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
ListSignal<CartItem>-
Manages the list of cart items. Each item in the list is itself a signal (
ValueSignal<CartItem>), enabling reactive updates when quantities change. ValueSignal<String>-
Tracks the discount code entered by the user.
ValueSignal<ShippingOption>-
Tracks the selected shipping method.
Computed Totals
Each total is a computed signal that automatically recalculates when dependencies change.
Subtotal
The subtotal sums the price of all items in the cart:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
This computed signal reads from cartItemsSignal. It accesses each item’s signal value using map(ValueSignal::get), then multiplies the product price by quantity. The result is summed using reduce().
When cart items are added, removed, or quantities change, this signal recalculates automatically.
Discount
The discount depends on both the discount code and the subtotal:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
This computed signal reads from both discountCodeSignal and subtotalSignal. When either changes, the discount recalculates.
Tax
Tax is calculated on the discounted subtotal:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
This demonstrates chaining computed signals. The tax signal depends on subtotalSignal and discountSignal, both of which are themselves computed signals.
Shipping
Shipping cost depends on the selected shipping option:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
Grand Total
The final total combines all computed values:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
This computed signal depends on four other computed signals: subtotalSignal, discountSignal, shippingSignal, and taxSignal. When any of these changes, the total updates automatically.
Binding Totals to UI
Each total is bound to a label that displays it:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
Key techniques:
bindText()-
Updates the label text whenever the signal changes
map()-
Transforms the signal value (e.g., formatting currency)
bindVisible()-
Shows the discount label only when a discount is applied
Form Field Binding
The discount code and shipping option use two-way binding:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
The bindValue(signal, signal::set) method creates two-way binding:
-
When the user types in the field, the write callback (
signal::set) updates the signal -
When the signal is updated programmatically, the framework reads via
signal.get()and updates the field
This is different from read-only bindings like bindText(), which only update the UI when the signal changes.
Dynamic List Rendering with Children Binding
The core of the shopping cart is rendering the list of cart items. The bindChildren() method on any container component efficiently manages this:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
bindChildren() takes two parameters:
-
ListSignal: The signal containing the list of items -
Factory function: A function that creates a component for each item signal
The factory function receives a ValueSignal<CartItem> for each item and creates the component once. The component then binds to this signal to receive updates. Crucially, component instances are created only when new items are added to the list. When item data changes (such as quantity updates), the existing component updates through its signal bindings rather than being recreated. This approach minimizes memory allocation and DOM operations: components are reused when items are reordered, detached only when items are removed, and their internal state updates reactively without component recreation.
Creating Cart Item Rows
Each cart item is rendered as a row with reactive bindings:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
Key techniques used:
- Two-way signal mapping
-
itemSignal.map(CartItem::quantity, CartItem::withQuantity)creates a writable signal that reads the quantity usingget()and writes it back using thewithQuantitymethod viaset(). This enables two-way binding between the quantity field and the cart item. - Nested computed signal
-
Signal.computed()calculates the item total by multiplying price by quantity. This computed signal updates automatically when the quantity changes. - Removing items
-
Calling
cartItemsSignal.remove(itemSignal)removes the item from the list. The component is automatically detached from the DOM.
See Two-Way Signal Mapping for more details on mapping signals to nested properties.
Adding Items to Cart
When a user adds a product, the cart checks if it’s already present:
Source code
ShoppingCartSignals.java
package com.vaadin.demo.flow.signals;
import com.vaadin.flow.router.Route;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
@Route("shopping-cart-with-signals")
public class ShoppingCartSignals extends VerticalLayout {
record Product(String id, String name, BigDecimal price) {}
record CartItem(Product product, int quantity) {
CartItem withQuantity(int newQuantity) {
return new CartItem(this.product, newQuantity);
}
}
record DiscountCode(String code, BigDecimal percentage) {}
enum ShippingOption { STANDARD, EXPRESS, OVERNIGHT }
public ShoppingCartSignals() {
// Create signals for cart state
ListSignal<CartItem> cartItemsSignal = new ListSignal<>();
ValueSignal<String> discountCodeSignal = new ValueSignal<>("");
ValueSignal<ShippingOption> shippingOptionSignal = new ValueSignal<>(ShippingOption.STANDARD);
// Computed signal for subtotal
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));
// Computed signal for discount
Signal<BigDecimal> discountSignal = Signal.computed(() -> {
String code = discountCodeSignal.get();
DiscountCode discount = validateDiscountCode(code);
if (discount != null) {
return subtotalSignal.get().multiply(discount.percentage())
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
});
// Computed signal for shipping cost
Signal<BigDecimal> shippingSignal = Signal
.computed(() -> getShippingCost(shippingOptionSignal.get()));
// Computed signal for tax (8%)
Signal<BigDecimal> taxSignal = Signal.computed(
() -> subtotalSignal.get().subtract(discountSignal.get())
.multiply(new BigDecimal("0.08"))
.setScale(2, RoundingMode.HALF_UP));
// Computed signal for grand total
Signal<BigDecimal> totalSignal = Signal.computed(() -> subtotalSignal.get()
.subtract(discountSignal.get()).add(shippingSignal.get())
.add(taxSignal.get()).setScale(2, RoundingMode.HALF_UP));
List<Product> products = List.of(
new Product("1", "Laptop", new BigDecimal("999.99")),
new Product("2", "Mouse", new BigDecimal("25.99")),
new Product("3", "Keyboard", new BigDecimal("79.99")),
new Product("4", "Monitor", new BigDecimal("249.50")));
// Products list
H3 productsTitle = new H3("Available Products");
Div productsContainer = new Div();
products.forEach(product -> productsContainer
.add(createProductRow(product, cartItemsSignal)));
// Cart items display
H3 cartTitle = new H3("Shopping Cart");
Div cartItemsContainer = new Div();
Div cartItemsList = new Div();
cartItemsList.bindChildren(cartItemsSignal,
itemSignal -> createCartItemRow(itemSignal, cartItemsSignal));
Paragraph emptyCart = new Paragraph("Empty cart");
emptyCart.bindVisible(
Signal.computed(() -> cartItemsSignal.get().isEmpty()));
cartItemsContainer.add(emptyCart, cartItemsList);
// Options section
HorizontalLayout optionsLayout = new HorizontalLayout();
optionsLayout.setWidthFull();
TextField discountField = new TextField("Discount Code");
discountField.setPlaceholder("Enter SAVE10 or SAVE20");
discountField.bindValue(discountCodeSignal, discountCodeSignal::set);
ComboBox<ShippingOption> shippingSelect = new ComboBox<>("Shipping Method",
ShippingOption.values());
shippingSelect.setValue(ShippingOption.STANDARD);
shippingSelect.bindValue(shippingOptionSignal, shippingOptionSignal::set);
optionsLayout.add(discountField, shippingSelect);
// Totals display
Div totalsBox = new Div();
H3 summaryTitle = new H3("Order Summary");
Span subtotalLabel = new Span();
subtotalLabel.bindText(subtotalSignal.map(total -> "Subtotal: $"
+ total.setScale(2, RoundingMode.HALF_UP)));
Span discountLabel = new Span();
discountLabel.bindText(discountSignal.map(discount -> "Discount: -$"
+ discount.setScale(2, RoundingMode.HALF_UP)));
discountLabel.bindVisible(
discountSignal.map(d -> d.compareTo(BigDecimal.ZERO) > 0));
Span shippingLabel = new Span();
shippingLabel.bindText(shippingSignal.map(shipping -> "Shipping: $"
+ shipping.setScale(2, RoundingMode.HALF_UP)));
Span taxLabel = new Span();
taxLabel.bindText(taxSignal.map(
tax -> "Tax (8%): $" + tax.setScale(2, RoundingMode.HALF_UP)));
Span totalLabel = new Span();
totalLabel.bindText(totalSignal.map(
total -> "Total: $" + total.setScale(2, RoundingMode.HALF_UP)));
totalsBox.add(summaryTitle, subtotalLabel, discountLabel, shippingLabel,
taxLabel, totalLabel);
add(productsTitle, productsContainer, cartTitle, cartItemsContainer,
optionsLayout, totalsBox);
}
private HorizontalLayout createProductRow(Product product,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(product.name() + " - $"
+ product.price().setScale(2, RoundingMode.HALF_UP));
Button addButton = new Button("Add",
e -> addToCart(product, cartItemsSignal));
row.add(nameLabel, addButton);
return row;
}
private HorizontalLayout createCartItemRow(ValueSignal<CartItem> itemSignal,
ListSignal<CartItem> cartItemsSignal) {
HorizontalLayout row = new HorizontalLayout();
Span nameLabel = new Span(itemSignal.map(item -> item.product().name()
+ " - $" + item.product().price().setScale(2,
RoundingMode.HALF_UP)));
IntegerField quantityField = new IntegerField();
quantityField.setMin(1);
quantityField.setMax(99);
quantityField.setWidth("120px");
quantityField.setStepButtonsVisible(true);
// Two-way mapped signal for quantity (immutable value)
quantityField.bindValue(itemSignal.map(CartItem::quantity),
itemSignal.updater(CartItem::withQuantity));
// Handle removal when quantity drops below 1
quantityField.addValueChangeListener(e -> {
Integer value = e.getValue();
if (value == null || value < 1) {
cartItemsSignal.remove(itemSignal);
}
});
Span itemTotalLabel = new Span(itemSignal.map(item -> "$"
+ item.product().price()
.multiply(BigDecimal.valueOf(item.quantity()))
.setScale(2, RoundingMode.HALF_UP)));
Button removeButton = new Button("Remove", e -> {
cartItemsSignal.remove(itemSignal);
});
row.add(nameLabel, quantityField, itemTotalLabel, removeButton);
return row;
}
private void addToCart(Product product,
ListSignal<CartItem> cartItemsSignal) {
cartItemsSignal.get().stream().filter(
signal -> signal.get().product().id().equals(product.id()))
.findFirst().ifPresentOrElse(
existing -> existing.set(existing.get()
.withQuantity(existing.get().quantity() + 1)),
() -> cartItemsSignal
.insertLast(new CartItem(product, 1)));
}
private DiscountCode validateDiscountCode(String code) {
return switch (code.toUpperCase()) {
case "SAVE10" -> new DiscountCode("SAVE10", new BigDecimal("10"));
case "SAVE20" -> new DiscountCode("SAVE20", new BigDecimal("20"));
default -> null;
};
}
private BigDecimal getShippingCost(ShippingOption option) {
return switch (option) {
case STANDARD -> new BigDecimal("5.99");
case EXPRESS -> new BigDecimal("12.99");
case OVERNIGHT -> new BigDecimal("24.99");
};
}
}
This logic:
-
Searches the cart for an item with the same product ID using
signal.get() -
If found, increments its quantity by updating the signal’s value using
signal.set() -
If not found, inserts a new cart item at the end of the list
When the quantity is updated on an existing item, the component row updates automatically without being recreated. Only the quantity field and item total update.
Reactivity Flow
Here’s what happens when a user changes a quantity:
-
User types in the quantity field
-
The two-way mapped signal updates the
CartItemvalue -
The item total computed signal recalculates
-
The subtotal computed signal recalculates
-
The discount computed signal recalculates (because it depends on subtotal)
-
The tax computed signal recalculates (because it depends on subtotal and discount)
-
The grand total computed signal recalculates (because it depends on all other totals)
-
All bound labels update automatically
All of this happens automatically without manual event listeners or state synchronization.
When to Use ListSignal
ListSignal is ideal for:
-
Single-user scenarios like this shopping cart
-
Dynamic form fields (add/remove rows)
-
Local selections and filters
-
Any list where items are added, removed, or reordered
For multi-user scenarios where multiple users need to see the same list in real-time (like a collaborative task board), use SharedListSignal instead. See Shared Signals for details.
Key Takeaways
ListSignal-
Manages dynamic lists with per-item reactivity. Each item is a
ValueSignalthat can be updated independently. bindChildren()-
Efficiently renders components from a list signal. Available on any container component (
HasComponents), components are added, removed, and reordered without unnecessary recreation. - Two-way mapping
-
signal.map(getter, merger)creates writable signals for nested properties, enabling direct binding to form fields. - Computed signal chains
-
Complex calculations can be broken into multiple computed signals that depend on each other. Changes propagate automatically through the chain.
- Automatic updates
-
When any signal changes, all dependent computed signals and bindings update automatically without manual synchronization.