Real-Time Dashboard with Signals
- Architecture Overview
- Design Principles
- State Declaration
- Service Integration
- Data Update Callback
- Chart Updates with Effects
- Change Tracking Pattern
- Key Patterns Summary
- Best Practices
- Summary
This guide demonstrates how to build a real-time dashboard using signals for reactive state management. The example shows a clean separation between backend data services and UI state, where signals serve as the reactive layer connecting them.
Architecture Overview
The dashboard follows a signal-based architecture with clear separation of concerns:
Backend service layer - Generates data and doesn’t work with signals directly. It only calls callbacks with plain data objects (records, POJOs).
View callback layer - Receives data from the service and updates signals. No UI manipulation happens here, only signal updates.
UI binding layer - Components and charts are bound to signals via effects. When signals change, effects automatically update the UI.
No manual state management - No state stored in regular class fields. All state lives in signals. No manual listeners, no UI.access() calls, no manual chart redraws.
This separation makes the code easier to test, maintain, and reason about. The service layer can be tested without UI concerns, and the UI layer automatically stays in sync with state.
Design Principles
The dashboard demonstrates key signal design principles:
Signals as the single source of truth - All dashboard state (metrics, timeline data, service health) is stored in signals. Regular instance fields are only used for constants or non-reactive data.
Service-to-signal pattern - Backend services call callbacks with plain data objects. The callback only updates signals, never touches the UI directly.
Effect-based UI updates - Charts and components update via effects that watch signals. No manual update calls needed.
Separation of concerns - Data generation (service), state management (signals), and UI rendering (effects) are cleanly separated.
No listeners - Components don’t need change listeners. Effects automatically detect signal changes and update the UI.
State Declaration
All dashboard state is declared as signals:
Source code
RealtimeDashboard.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.charts.Chart;
import com.vaadin.flow.component.charts.model.ChartType;
import com.vaadin.flow.component.charts.model.Configuration;
import com.vaadin.flow.component.charts.model.ListSeries;
import com.vaadin.flow.component.charts.model.XAxis;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
import com.vaadin.flow.theme.lumo.LumoUtility.FontSize;
import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import com.vaadin.flow.theme.lumo.LumoUtility.TextColor;
import java.util.function.Function;
import org.springframework.stereotype.Service;
/**
* Real-time dashboard demonstrating signal-based architecture.
*
* Key design principles:
* - Backend service doesn't work with signals directly
* - Service calls a callback with plain data objects
* - View provides callback that only updates signals
* - No state stored in regular fields, only in signals
* - No manual listeners or UI.access() calls needed
* - Charts update via separate effects watching signals
*/
@Route("real-time-dashboard-with-signals")
public class RealtimeDashboard extends VerticalLayout {
private static final int TIMELINE_POINTS = 12;
// All state is stored in signals - no regular instance fields for state
private final ValueSignal<Number> currentUsersSignal = new ValueSignal<>(0);
private final ValueSignal<Number> viewEventsSignal = new ValueSignal<>(0);
private final ListSignal<String> timelineCategoriesSignal = new ListSignal<>();
private final ListSignal<Number> berlinTimelineSignal = new ListSignal<>();
private final ListSignal<Number> londonTimelineSignal = new ListSignal<>();
private final ListSignal<Number> newYorkTimelineSignal = new ListSignal<>();
public RealtimeDashboard(SchedulerService schedulerService) {
// Create UI components bound to signals
add(
createHighlightCard("Current users", currentUsersSignal, this::formatNumber),
createHighlightCard("View events", viewEventsSignal, this::formatCompactNumber),
createViewEventsChart()
);
// Register with scheduler - service will call our callback
// No need for UI.access() - signals handle thread safety
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
}
/**
* Callback invoked by the scheduler service with new dashboard data.
* This method ONLY updates signals - no UI manipulation, no listeners.
* UI updates happen automatically via effects.
*/
private void onDataUpdate(DashboardData data) {
// Update simple value signals
currentUsersSignal.set(data.currentUsers());
viewEventsSignal.set(data.viewEvents());
// Update timeline - maintain sliding window of last N points
updateTimelineSignal(berlinTimelineSignal, data.berlinValue());
updateTimelineSignal(londonTimelineSignal, data.londonValue());
updateTimelineSignal(newYorkTimelineSignal, data.newYorkValue());
// Update categories
if (timelineCategoriesSignal.get().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.get().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}
private void updateTimelineSignal(ListSignal<Number> signal, Number newValue) {
if (signal.get().size() >= TIMELINE_POINTS) {
signal.remove(signal.get().getFirst());
}
signal.insertLast(newValue);
}
/**
* Creates a chart that updates automatically when signals change.
* No manual chart.drawChart() calls needed - effects handle it.
*/
private Component createViewEventsChart() {
Chart chart = new Chart(ChartType.AREASPLINE);
Configuration conf = chart.getConfiguration();
XAxis xAxis = new XAxis();
conf.addxAxis(xAxis);
// Create chart series
ListSeries berlinSeries = new ListSeries("Berlin", new Number[0]);
ListSeries londonSeries = new ListSeries("London", new Number[0]);
ListSeries newYorkSeries = new ListSeries("New York", new Number[0]);
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Effect to update x-axis categories
Signal.effect(chart, () ->
xAxis.setCategories(timelineCategoriesSignal.get()
.stream().map(Signal::get).toArray(String[]::new))
);
conf.addSeries(berlinSeries);
conf.addSeries(londonSeries);
conf.addSeries(newYorkSeries);
// Separate effect to trigger chart redraw when any data changes
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});
return chart;
}
/**
* Creates an effect that updates chart series data when signal changes.
* Each series has its own independent effect.
*/
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new)));
}
/**
* A highlight card showing a metric value with percentage change indicator.
* Demonstrates: computed signals, signal mapping, and derived state.
*/
private static final class HighlightCard extends VerticalLayout {
// Internal record tracking previous and current values
record Change(double previous, double current) {}
private HighlightCard(String title, ValueSignal<Number> signal,
Function<Number, String> format) {
// Create a signal to track previous-current value pairs
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue())
);
// Effect: Update changeSignal when the main signal changes
// This tracks the previous value to calculate percentage change
Signal.unboundEffect(() -> {
double current = signal.get().doubleValue();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});
// Computed signal: Calculate percentage change from Change record
Signal<Double> percentageSignal = changeSignal.map(change ->
calculatePercentageChange(change.current(), change.previous())
);
// Derived signals: Map percentage to display properties
Signal<String> prefixSignal = percentageSignal.map(this::getPrefix);
Signal<VaadinIcon> iconSignal = percentageSignal.map(this::getIcon);
Signal<Boolean> successSignal = percentageSignal.map(percentage -> percentage > 0);
// Build UI components
H2 h2 = new H2(title);
h2.addClassNames(FontWeight.NORMAL, Margin.NONE,
TextColor.SECONDARY, FontSize.XSMALL);
// Bind value display to formatted signal
Span valueSpan = new Span();
valueSpan.addClassNames(FontWeight.SEMIBOLD, FontSize.XXXLARGE);
valueSpan.bindText(signal.map(format::apply));
// Bind percentage text with prefix
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix ->
prefix + percentageSignal.get()
));
// Bind icon to computed signal
Icon icon = new Icon(iconSignal);
icon.setSize("10px");
icon.getStyle().setMarginRight("4px").setMarginLeft("0");
// Create badge with conditional theme binding
Span badge = new Span();
badge.add(icon, percentageSpan);
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));
add(h2, valueSpan, badge);
getStyle().setGap("5px");
}
private String getPrefix(double percentage) {
if (percentage == 0) {
return "±";
} else if (percentage > 0) {
return "+";
} else {
return "";
}
}
private VaadinIcon getIcon(double percentage) {
return percentage < 0 ? VaadinIcon.ARROW_DOWN : VaadinIcon.ARROW_UP;
}
private double calculatePercentageChange(double current, double previous) {
if (previous == 0.0) {
return 0.0;
}
double percent = ((current - previous) / Math.abs(previous)) * 100.0;
return Math.round(percent * 10.0) / 10.0;
}
}
private Component createHighlightCard(String title,
ValueSignal<Number> signal, Function<Number, String> format) {
return new HighlightCard(title, signal, format);
}
private String formatNumber(Number value) {
return String.valueOf(value);
}
private String formatCompactNumber(Number value) {
if (value.doubleValue() >= 1000) {
double rounded = Math.round(value.doubleValue() / 100.0) / 10.0;
return rounded + "k";
}
return String.valueOf(value);
}
// Supporting classes (simplified for documentation)
// Using Spring's DI in this example
@Service
static class SchedulerService {
private final java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newScheduledThreadPool(1);
private final java.util.Random random = new java.util.Random();
private final java.time.format.DateTimeFormatter timeFormatter =
java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
void scheduleDashboardDataUpdate(java.util.function.Consumer<DashboardData> callback) {
// Schedule periodic updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
DashboardData data = generateData();
callback.accept(data);
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS);
}
private DashboardData generateData() {
return new DashboardData(
randomBetween(650, 820), // currentUsers
randomBetween(42000, 62000), // viewEvents
java.time.LocalTime.now().format(timeFormatter), // timestamp
randomBetween(480, 920), // berlinValue
randomBetween(420, 820), // londonValue
randomBetween(220, 520) // newYorkValue
);
}
private int randomBetween(int min, int max) {
return min + random.nextInt(max - min + 1);
}
}
record DashboardData(
int currentUsers,
int viewEvents,
String timestamp,
int berlinValue,
int londonValue,
int newYorkValue
) {}
}
Notice there are no regular instance fields for storing metric values or chart data. Everything that changes over time is a signal. This makes the reactive state explicit and easy to identify.
The signals are organized by type:
-
ValueSignal<Number>for simple metrics (current users, view events) -
ListSignal<Number>for time-series data (Berlin, London, New York timelines) -
ListSignal<String>for category labels (timestamps)
Each signal can be independently updated and watched, creating a flexible reactive system.
Service Integration
The view registers a callback with the scheduler service:
Source code
RealtimeDashboard.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.charts.Chart;
import com.vaadin.flow.component.charts.model.ChartType;
import com.vaadin.flow.component.charts.model.Configuration;
import com.vaadin.flow.component.charts.model.ListSeries;
import com.vaadin.flow.component.charts.model.XAxis;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
import com.vaadin.flow.theme.lumo.LumoUtility.FontSize;
import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import com.vaadin.flow.theme.lumo.LumoUtility.TextColor;
import java.util.function.Function;
import org.springframework.stereotype.Service;
/**
* Real-time dashboard demonstrating signal-based architecture.
*
* Key design principles:
* - Backend service doesn't work with signals directly
* - Service calls a callback with plain data objects
* - View provides callback that only updates signals
* - No state stored in regular fields, only in signals
* - No manual listeners or UI.access() calls needed
* - Charts update via separate effects watching signals
*/
@Route("real-time-dashboard-with-signals")
public class RealtimeDashboard extends VerticalLayout {
private static final int TIMELINE_POINTS = 12;
// All state is stored in signals - no regular instance fields for state
private final ValueSignal<Number> currentUsersSignal = new ValueSignal<>(0);
private final ValueSignal<Number> viewEventsSignal = new ValueSignal<>(0);
private final ListSignal<String> timelineCategoriesSignal = new ListSignal<>();
private final ListSignal<Number> berlinTimelineSignal = new ListSignal<>();
private final ListSignal<Number> londonTimelineSignal = new ListSignal<>();
private final ListSignal<Number> newYorkTimelineSignal = new ListSignal<>();
public RealtimeDashboard(SchedulerService schedulerService) {
// Create UI components bound to signals
add(
createHighlightCard("Current users", currentUsersSignal, this::formatNumber),
createHighlightCard("View events", viewEventsSignal, this::formatCompactNumber),
createViewEventsChart()
);
// Register with scheduler - service will call our callback
// No need for UI.access() - signals handle thread safety
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
}
/**
* Callback invoked by the scheduler service with new dashboard data.
* This method ONLY updates signals - no UI manipulation, no listeners.
* UI updates happen automatically via effects.
*/
private void onDataUpdate(DashboardData data) {
// Update simple value signals
currentUsersSignal.set(data.currentUsers());
viewEventsSignal.set(data.viewEvents());
// Update timeline - maintain sliding window of last N points
updateTimelineSignal(berlinTimelineSignal, data.berlinValue());
updateTimelineSignal(londonTimelineSignal, data.londonValue());
updateTimelineSignal(newYorkTimelineSignal, data.newYorkValue());
// Update categories
if (timelineCategoriesSignal.get().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.get().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}
private void updateTimelineSignal(ListSignal<Number> signal, Number newValue) {
if (signal.get().size() >= TIMELINE_POINTS) {
signal.remove(signal.get().getFirst());
}
signal.insertLast(newValue);
}
/**
* Creates a chart that updates automatically when signals change.
* No manual chart.drawChart() calls needed - effects handle it.
*/
private Component createViewEventsChart() {
Chart chart = new Chart(ChartType.AREASPLINE);
Configuration conf = chart.getConfiguration();
XAxis xAxis = new XAxis();
conf.addxAxis(xAxis);
// Create chart series
ListSeries berlinSeries = new ListSeries("Berlin", new Number[0]);
ListSeries londonSeries = new ListSeries("London", new Number[0]);
ListSeries newYorkSeries = new ListSeries("New York", new Number[0]);
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Effect to update x-axis categories
Signal.effect(chart, () ->
xAxis.setCategories(timelineCategoriesSignal.get()
.stream().map(Signal::get).toArray(String[]::new))
);
conf.addSeries(berlinSeries);
conf.addSeries(londonSeries);
conf.addSeries(newYorkSeries);
// Separate effect to trigger chart redraw when any data changes
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});
return chart;
}
/**
* Creates an effect that updates chart series data when signal changes.
* Each series has its own independent effect.
*/
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new)));
}
/**
* A highlight card showing a metric value with percentage change indicator.
* Demonstrates: computed signals, signal mapping, and derived state.
*/
private static final class HighlightCard extends VerticalLayout {
// Internal record tracking previous and current values
record Change(double previous, double current) {}
private HighlightCard(String title, ValueSignal<Number> signal,
Function<Number, String> format) {
// Create a signal to track previous-current value pairs
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue())
);
// Effect: Update changeSignal when the main signal changes
// This tracks the previous value to calculate percentage change
Signal.unboundEffect(() -> {
double current = signal.get().doubleValue();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});
// Computed signal: Calculate percentage change from Change record
Signal<Double> percentageSignal = changeSignal.map(change ->
calculatePercentageChange(change.current(), change.previous())
);
// Derived signals: Map percentage to display properties
Signal<String> prefixSignal = percentageSignal.map(this::getPrefix);
Signal<VaadinIcon> iconSignal = percentageSignal.map(this::getIcon);
Signal<Boolean> successSignal = percentageSignal.map(percentage -> percentage > 0);
// Build UI components
H2 h2 = new H2(title);
h2.addClassNames(FontWeight.NORMAL, Margin.NONE,
TextColor.SECONDARY, FontSize.XSMALL);
// Bind value display to formatted signal
Span valueSpan = new Span();
valueSpan.addClassNames(FontWeight.SEMIBOLD, FontSize.XXXLARGE);
valueSpan.bindText(signal.map(format::apply));
// Bind percentage text with prefix
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix ->
prefix + percentageSignal.get()
));
// Bind icon to computed signal
Icon icon = new Icon(iconSignal);
icon.setSize("10px");
icon.getStyle().setMarginRight("4px").setMarginLeft("0");
// Create badge with conditional theme binding
Span badge = new Span();
badge.add(icon, percentageSpan);
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));
add(h2, valueSpan, badge);
getStyle().setGap("5px");
}
private String getPrefix(double percentage) {
if (percentage == 0) {
return "±";
} else if (percentage > 0) {
return "+";
} else {
return "";
}
}
private VaadinIcon getIcon(double percentage) {
return percentage < 0 ? VaadinIcon.ARROW_DOWN : VaadinIcon.ARROW_UP;
}
private double calculatePercentageChange(double current, double previous) {
if (previous == 0.0) {
return 0.0;
}
double percent = ((current - previous) / Math.abs(previous)) * 100.0;
return Math.round(percent * 10.0) / 10.0;
}
}
private Component createHighlightCard(String title,
ValueSignal<Number> signal, Function<Number, String> format) {
return new HighlightCard(title, signal, format);
}
private String formatNumber(Number value) {
return String.valueOf(value);
}
private String formatCompactNumber(Number value) {
if (value.doubleValue() >= 1000) {
double rounded = Math.round(value.doubleValue() / 100.0) / 10.0;
return rounded + "k";
}
return String.valueOf(value);
}
// Supporting classes (simplified for documentation)
// Using Spring's DI in this example
@Service
static class SchedulerService {
private final java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newScheduledThreadPool(1);
private final java.util.Random random = new java.util.Random();
private final java.time.format.DateTimeFormatter timeFormatter =
java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
void scheduleDashboardDataUpdate(java.util.function.Consumer<DashboardData> callback) {
// Schedule periodic updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
DashboardData data = generateData();
callback.accept(data);
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS);
}
private DashboardData generateData() {
return new DashboardData(
randomBetween(650, 820), // currentUsers
randomBetween(42000, 62000), // viewEvents
java.time.LocalTime.now().format(timeFormatter), // timestamp
randomBetween(480, 920), // berlinValue
randomBetween(420, 820), // londonValue
randomBetween(220, 520) // newYorkValue
);
}
private int randomBetween(int min, int max) {
return min + random.nextInt(max - min + 1);
}
}
record DashboardData(
int currentUsers,
int viewEvents,
String timestamp,
int berlinValue,
int londonValue,
int newYorkValue
) {}
}
The service doesn’t know about signals or UI components. It just calls the callback periodically with new data. The comment highlights an important point: you don’t need UI.access() because signals handle thread safety internally.
|
Note
|
For real-time updates to reach users immediately, you need to enable push in your application. Without push, updates only appear when the user interacts with the UI. |
Data Update Callback
The callback receives plain data objects and updates signals:
Source code
RealtimeDashboard.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.charts.Chart;
import com.vaadin.flow.component.charts.model.ChartType;
import com.vaadin.flow.component.charts.model.Configuration;
import com.vaadin.flow.component.charts.model.ListSeries;
import com.vaadin.flow.component.charts.model.XAxis;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
import com.vaadin.flow.theme.lumo.LumoUtility.FontSize;
import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import com.vaadin.flow.theme.lumo.LumoUtility.TextColor;
import java.util.function.Function;
import org.springframework.stereotype.Service;
/**
* Real-time dashboard demonstrating signal-based architecture.
*
* Key design principles:
* - Backend service doesn't work with signals directly
* - Service calls a callback with plain data objects
* - View provides callback that only updates signals
* - No state stored in regular fields, only in signals
* - No manual listeners or UI.access() calls needed
* - Charts update via separate effects watching signals
*/
@Route("real-time-dashboard-with-signals")
public class RealtimeDashboard extends VerticalLayout {
private static final int TIMELINE_POINTS = 12;
// All state is stored in signals - no regular instance fields for state
private final ValueSignal<Number> currentUsersSignal = new ValueSignal<>(0);
private final ValueSignal<Number> viewEventsSignal = new ValueSignal<>(0);
private final ListSignal<String> timelineCategoriesSignal = new ListSignal<>();
private final ListSignal<Number> berlinTimelineSignal = new ListSignal<>();
private final ListSignal<Number> londonTimelineSignal = new ListSignal<>();
private final ListSignal<Number> newYorkTimelineSignal = new ListSignal<>();
public RealtimeDashboard(SchedulerService schedulerService) {
// Create UI components bound to signals
add(
createHighlightCard("Current users", currentUsersSignal, this::formatNumber),
createHighlightCard("View events", viewEventsSignal, this::formatCompactNumber),
createViewEventsChart()
);
// Register with scheduler - service will call our callback
// No need for UI.access() - signals handle thread safety
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
}
/**
* Callback invoked by the scheduler service with new dashboard data.
* This method ONLY updates signals - no UI manipulation, no listeners.
* UI updates happen automatically via effects.
*/
private void onDataUpdate(DashboardData data) {
// Update simple value signals
currentUsersSignal.set(data.currentUsers());
viewEventsSignal.set(data.viewEvents());
// Update timeline - maintain sliding window of last N points
updateTimelineSignal(berlinTimelineSignal, data.berlinValue());
updateTimelineSignal(londonTimelineSignal, data.londonValue());
updateTimelineSignal(newYorkTimelineSignal, data.newYorkValue());
// Update categories
if (timelineCategoriesSignal.get().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.get().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}
private void updateTimelineSignal(ListSignal<Number> signal, Number newValue) {
if (signal.get().size() >= TIMELINE_POINTS) {
signal.remove(signal.get().getFirst());
}
signal.insertLast(newValue);
}
/**
* Creates a chart that updates automatically when signals change.
* No manual chart.drawChart() calls needed - effects handle it.
*/
private Component createViewEventsChart() {
Chart chart = new Chart(ChartType.AREASPLINE);
Configuration conf = chart.getConfiguration();
XAxis xAxis = new XAxis();
conf.addxAxis(xAxis);
// Create chart series
ListSeries berlinSeries = new ListSeries("Berlin", new Number[0]);
ListSeries londonSeries = new ListSeries("London", new Number[0]);
ListSeries newYorkSeries = new ListSeries("New York", new Number[0]);
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Effect to update x-axis categories
Signal.effect(chart, () ->
xAxis.setCategories(timelineCategoriesSignal.get()
.stream().map(Signal::get).toArray(String[]::new))
);
conf.addSeries(berlinSeries);
conf.addSeries(londonSeries);
conf.addSeries(newYorkSeries);
// Separate effect to trigger chart redraw when any data changes
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});
return chart;
}
/**
* Creates an effect that updates chart series data when signal changes.
* Each series has its own independent effect.
*/
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new)));
}
/**
* A highlight card showing a metric value with percentage change indicator.
* Demonstrates: computed signals, signal mapping, and derived state.
*/
private static final class HighlightCard extends VerticalLayout {
// Internal record tracking previous and current values
record Change(double previous, double current) {}
private HighlightCard(String title, ValueSignal<Number> signal,
Function<Number, String> format) {
// Create a signal to track previous-current value pairs
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue())
);
// Effect: Update changeSignal when the main signal changes
// This tracks the previous value to calculate percentage change
Signal.unboundEffect(() -> {
double current = signal.get().doubleValue();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});
// Computed signal: Calculate percentage change from Change record
Signal<Double> percentageSignal = changeSignal.map(change ->
calculatePercentageChange(change.current(), change.previous())
);
// Derived signals: Map percentage to display properties
Signal<String> prefixSignal = percentageSignal.map(this::getPrefix);
Signal<VaadinIcon> iconSignal = percentageSignal.map(this::getIcon);
Signal<Boolean> successSignal = percentageSignal.map(percentage -> percentage > 0);
// Build UI components
H2 h2 = new H2(title);
h2.addClassNames(FontWeight.NORMAL, Margin.NONE,
TextColor.SECONDARY, FontSize.XSMALL);
// Bind value display to formatted signal
Span valueSpan = new Span();
valueSpan.addClassNames(FontWeight.SEMIBOLD, FontSize.XXXLARGE);
valueSpan.bindText(signal.map(format::apply));
// Bind percentage text with prefix
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix ->
prefix + percentageSignal.get()
));
// Bind icon to computed signal
Icon icon = new Icon(iconSignal);
icon.setSize("10px");
icon.getStyle().setMarginRight("4px").setMarginLeft("0");
// Create badge with conditional theme binding
Span badge = new Span();
badge.add(icon, percentageSpan);
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));
add(h2, valueSpan, badge);
getStyle().setGap("5px");
}
private String getPrefix(double percentage) {
if (percentage == 0) {
return "±";
} else if (percentage > 0) {
return "+";
} else {
return "";
}
}
private VaadinIcon getIcon(double percentage) {
return percentage < 0 ? VaadinIcon.ARROW_DOWN : VaadinIcon.ARROW_UP;
}
private double calculatePercentageChange(double current, double previous) {
if (previous == 0.0) {
return 0.0;
}
double percent = ((current - previous) / Math.abs(previous)) * 100.0;
return Math.round(percent * 10.0) / 10.0;
}
}
private Component createHighlightCard(String title,
ValueSignal<Number> signal, Function<Number, String> format) {
return new HighlightCard(title, signal, format);
}
private String formatNumber(Number value) {
return String.valueOf(value);
}
private String formatCompactNumber(Number value) {
if (value.doubleValue() >= 1000) {
double rounded = Math.round(value.doubleValue() / 100.0) / 10.0;
return rounded + "k";
}
return String.valueOf(value);
}
// Supporting classes (simplified for documentation)
// Using Spring's DI in this example
@Service
static class SchedulerService {
private final java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newScheduledThreadPool(1);
private final java.util.Random random = new java.util.Random();
private final java.time.format.DateTimeFormatter timeFormatter =
java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
void scheduleDashboardDataUpdate(java.util.function.Consumer<DashboardData> callback) {
// Schedule periodic updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
DashboardData data = generateData();
callback.accept(data);
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS);
}
private DashboardData generateData() {
return new DashboardData(
randomBetween(650, 820), // currentUsers
randomBetween(42000, 62000), // viewEvents
java.time.LocalTime.now().format(timeFormatter), // timestamp
randomBetween(480, 920), // berlinValue
randomBetween(420, 820), // londonValue
randomBetween(220, 520) // newYorkValue
);
}
private int randomBetween(int min, int max) {
return min + random.nextInt(max - min + 1);
}
}
record DashboardData(
int currentUsers,
int viewEvents,
String timestamp,
int berlinValue,
int londonValue,
int newYorkValue
) {}
}
This method demonstrates the service-to-signal pattern:
-
Receive plain data object (
DashboardData) -
Extract values and update signals
-
No UI manipulation, no chart redraws, no manual updates
The callback is pure signal updates. It doesn’t care what UI components are listening or how they render the data. This separation makes the code easier to test and maintain.
Managing Sliding Window Data
The helper method maintains a sliding window of the last N data points:
Source code
RealtimeDashboard.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.charts.Chart;
import com.vaadin.flow.component.charts.model.ChartType;
import com.vaadin.flow.component.charts.model.Configuration;
import com.vaadin.flow.component.charts.model.ListSeries;
import com.vaadin.flow.component.charts.model.XAxis;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
import com.vaadin.flow.theme.lumo.LumoUtility.FontSize;
import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import com.vaadin.flow.theme.lumo.LumoUtility.TextColor;
import java.util.function.Function;
import org.springframework.stereotype.Service;
/**
* Real-time dashboard demonstrating signal-based architecture.
*
* Key design principles:
* - Backend service doesn't work with signals directly
* - Service calls a callback with plain data objects
* - View provides callback that only updates signals
* - No state stored in regular fields, only in signals
* - No manual listeners or UI.access() calls needed
* - Charts update via separate effects watching signals
*/
@Route("real-time-dashboard-with-signals")
public class RealtimeDashboard extends VerticalLayout {
private static final int TIMELINE_POINTS = 12;
// All state is stored in signals - no regular instance fields for state
private final ValueSignal<Number> currentUsersSignal = new ValueSignal<>(0);
private final ValueSignal<Number> viewEventsSignal = new ValueSignal<>(0);
private final ListSignal<String> timelineCategoriesSignal = new ListSignal<>();
private final ListSignal<Number> berlinTimelineSignal = new ListSignal<>();
private final ListSignal<Number> londonTimelineSignal = new ListSignal<>();
private final ListSignal<Number> newYorkTimelineSignal = new ListSignal<>();
public RealtimeDashboard(SchedulerService schedulerService) {
// Create UI components bound to signals
add(
createHighlightCard("Current users", currentUsersSignal, this::formatNumber),
createHighlightCard("View events", viewEventsSignal, this::formatCompactNumber),
createViewEventsChart()
);
// Register with scheduler - service will call our callback
// No need for UI.access() - signals handle thread safety
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
}
/**
* Callback invoked by the scheduler service with new dashboard data.
* This method ONLY updates signals - no UI manipulation, no listeners.
* UI updates happen automatically via effects.
*/
private void onDataUpdate(DashboardData data) {
// Update simple value signals
currentUsersSignal.set(data.currentUsers());
viewEventsSignal.set(data.viewEvents());
// Update timeline - maintain sliding window of last N points
updateTimelineSignal(berlinTimelineSignal, data.berlinValue());
updateTimelineSignal(londonTimelineSignal, data.londonValue());
updateTimelineSignal(newYorkTimelineSignal, data.newYorkValue());
// Update categories
if (timelineCategoriesSignal.get().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.get().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}
private void updateTimelineSignal(ListSignal<Number> signal, Number newValue) {
if (signal.get().size() >= TIMELINE_POINTS) {
signal.remove(signal.get().getFirst());
}
signal.insertLast(newValue);
}
/**
* Creates a chart that updates automatically when signals change.
* No manual chart.drawChart() calls needed - effects handle it.
*/
private Component createViewEventsChart() {
Chart chart = new Chart(ChartType.AREASPLINE);
Configuration conf = chart.getConfiguration();
XAxis xAxis = new XAxis();
conf.addxAxis(xAxis);
// Create chart series
ListSeries berlinSeries = new ListSeries("Berlin", new Number[0]);
ListSeries londonSeries = new ListSeries("London", new Number[0]);
ListSeries newYorkSeries = new ListSeries("New York", new Number[0]);
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Effect to update x-axis categories
Signal.effect(chart, () ->
xAxis.setCategories(timelineCategoriesSignal.get()
.stream().map(Signal::get).toArray(String[]::new))
);
conf.addSeries(berlinSeries);
conf.addSeries(londonSeries);
conf.addSeries(newYorkSeries);
// Separate effect to trigger chart redraw when any data changes
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});
return chart;
}
/**
* Creates an effect that updates chart series data when signal changes.
* Each series has its own independent effect.
*/
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new)));
}
/**
* A highlight card showing a metric value with percentage change indicator.
* Demonstrates: computed signals, signal mapping, and derived state.
*/
private static final class HighlightCard extends VerticalLayout {
// Internal record tracking previous and current values
record Change(double previous, double current) {}
private HighlightCard(String title, ValueSignal<Number> signal,
Function<Number, String> format) {
// Create a signal to track previous-current value pairs
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue())
);
// Effect: Update changeSignal when the main signal changes
// This tracks the previous value to calculate percentage change
Signal.unboundEffect(() -> {
double current = signal.get().doubleValue();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});
// Computed signal: Calculate percentage change from Change record
Signal<Double> percentageSignal = changeSignal.map(change ->
calculatePercentageChange(change.current(), change.previous())
);
// Derived signals: Map percentage to display properties
Signal<String> prefixSignal = percentageSignal.map(this::getPrefix);
Signal<VaadinIcon> iconSignal = percentageSignal.map(this::getIcon);
Signal<Boolean> successSignal = percentageSignal.map(percentage -> percentage > 0);
// Build UI components
H2 h2 = new H2(title);
h2.addClassNames(FontWeight.NORMAL, Margin.NONE,
TextColor.SECONDARY, FontSize.XSMALL);
// Bind value display to formatted signal
Span valueSpan = new Span();
valueSpan.addClassNames(FontWeight.SEMIBOLD, FontSize.XXXLARGE);
valueSpan.bindText(signal.map(format::apply));
// Bind percentage text with prefix
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix ->
prefix + percentageSignal.get()
));
// Bind icon to computed signal
Icon icon = new Icon(iconSignal);
icon.setSize("10px");
icon.getStyle().setMarginRight("4px").setMarginLeft("0");
// Create badge with conditional theme binding
Span badge = new Span();
badge.add(icon, percentageSpan);
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));
add(h2, valueSpan, badge);
getStyle().setGap("5px");
}
private String getPrefix(double percentage) {
if (percentage == 0) {
return "±";
} else if (percentage > 0) {
return "+";
} else {
return "";
}
}
private VaadinIcon getIcon(double percentage) {
return percentage < 0 ? VaadinIcon.ARROW_DOWN : VaadinIcon.ARROW_UP;
}
private double calculatePercentageChange(double current, double previous) {
if (previous == 0.0) {
return 0.0;
}
double percent = ((current - previous) / Math.abs(previous)) * 100.0;
return Math.round(percent * 10.0) / 10.0;
}
}
private Component createHighlightCard(String title,
ValueSignal<Number> signal, Function<Number, String> format) {
return new HighlightCard(title, signal, format);
}
private String formatNumber(Number value) {
return String.valueOf(value);
}
private String formatCompactNumber(Number value) {
if (value.doubleValue() >= 1000) {
double rounded = Math.round(value.doubleValue() / 100.0) / 10.0;
return rounded + "k";
}
return String.valueOf(value);
}
// Supporting classes (simplified for documentation)
// Using Spring's DI in this example
@Service
static class SchedulerService {
private final java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newScheduledThreadPool(1);
private final java.util.Random random = new java.util.Random();
private final java.time.format.DateTimeFormatter timeFormatter =
java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
void scheduleDashboardDataUpdate(java.util.function.Consumer<DashboardData> callback) {
// Schedule periodic updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
DashboardData data = generateData();
callback.accept(data);
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS);
}
private DashboardData generateData() {
return new DashboardData(
randomBetween(650, 820), // currentUsers
randomBetween(42000, 62000), // viewEvents
java.time.LocalTime.now().format(timeFormatter), // timestamp
randomBetween(480, 920), // berlinValue
randomBetween(420, 820), // londonValue
randomBetween(220, 520) // newYorkValue
);
}
private int randomBetween(int min, int max) {
return min + random.nextInt(max - min + 1);
}
}
record DashboardData(
int currentUsers,
int viewEvents,
String timestamp,
int berlinValue,
int londonValue,
int newYorkValue
) {}
}
This pattern is common for time-series dashboards: when new data arrives, remove the oldest point and add the new one. The ListSignal API makes this straightforward with remove() and insertLast().
Chart Updates with Effects
Charts update automatically via effects that watch signals:
Source code
RealtimeDashboard.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.charts.Chart;
import com.vaadin.flow.component.charts.model.ChartType;
import com.vaadin.flow.component.charts.model.Configuration;
import com.vaadin.flow.component.charts.model.ListSeries;
import com.vaadin.flow.component.charts.model.XAxis;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
import com.vaadin.flow.theme.lumo.LumoUtility.FontSize;
import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import com.vaadin.flow.theme.lumo.LumoUtility.TextColor;
import java.util.function.Function;
import org.springframework.stereotype.Service;
/**
* Real-time dashboard demonstrating signal-based architecture.
*
* Key design principles:
* - Backend service doesn't work with signals directly
* - Service calls a callback with plain data objects
* - View provides callback that only updates signals
* - No state stored in regular fields, only in signals
* - No manual listeners or UI.access() calls needed
* - Charts update via separate effects watching signals
*/
@Route("real-time-dashboard-with-signals")
public class RealtimeDashboard extends VerticalLayout {
private static final int TIMELINE_POINTS = 12;
// All state is stored in signals - no regular instance fields for state
private final ValueSignal<Number> currentUsersSignal = new ValueSignal<>(0);
private final ValueSignal<Number> viewEventsSignal = new ValueSignal<>(0);
private final ListSignal<String> timelineCategoriesSignal = new ListSignal<>();
private final ListSignal<Number> berlinTimelineSignal = new ListSignal<>();
private final ListSignal<Number> londonTimelineSignal = new ListSignal<>();
private final ListSignal<Number> newYorkTimelineSignal = new ListSignal<>();
public RealtimeDashboard(SchedulerService schedulerService) {
// Create UI components bound to signals
add(
createHighlightCard("Current users", currentUsersSignal, this::formatNumber),
createHighlightCard("View events", viewEventsSignal, this::formatCompactNumber),
createViewEventsChart()
);
// Register with scheduler - service will call our callback
// No need for UI.access() - signals handle thread safety
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
}
/**
* Callback invoked by the scheduler service with new dashboard data.
* This method ONLY updates signals - no UI manipulation, no listeners.
* UI updates happen automatically via effects.
*/
private void onDataUpdate(DashboardData data) {
// Update simple value signals
currentUsersSignal.set(data.currentUsers());
viewEventsSignal.set(data.viewEvents());
// Update timeline - maintain sliding window of last N points
updateTimelineSignal(berlinTimelineSignal, data.berlinValue());
updateTimelineSignal(londonTimelineSignal, data.londonValue());
updateTimelineSignal(newYorkTimelineSignal, data.newYorkValue());
// Update categories
if (timelineCategoriesSignal.get().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.get().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}
private void updateTimelineSignal(ListSignal<Number> signal, Number newValue) {
if (signal.get().size() >= TIMELINE_POINTS) {
signal.remove(signal.get().getFirst());
}
signal.insertLast(newValue);
}
/**
* Creates a chart that updates automatically when signals change.
* No manual chart.drawChart() calls needed - effects handle it.
*/
private Component createViewEventsChart() {
Chart chart = new Chart(ChartType.AREASPLINE);
Configuration conf = chart.getConfiguration();
XAxis xAxis = new XAxis();
conf.addxAxis(xAxis);
// Create chart series
ListSeries berlinSeries = new ListSeries("Berlin", new Number[0]);
ListSeries londonSeries = new ListSeries("London", new Number[0]);
ListSeries newYorkSeries = new ListSeries("New York", new Number[0]);
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Effect to update x-axis categories
Signal.effect(chart, () ->
xAxis.setCategories(timelineCategoriesSignal.get()
.stream().map(Signal::get).toArray(String[]::new))
);
conf.addSeries(berlinSeries);
conf.addSeries(londonSeries);
conf.addSeries(newYorkSeries);
// Separate effect to trigger chart redraw when any data changes
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});
return chart;
}
/**
* Creates an effect that updates chart series data when signal changes.
* Each series has its own independent effect.
*/
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new)));
}
/**
* A highlight card showing a metric value with percentage change indicator.
* Demonstrates: computed signals, signal mapping, and derived state.
*/
private static final class HighlightCard extends VerticalLayout {
// Internal record tracking previous and current values
record Change(double previous, double current) {}
private HighlightCard(String title, ValueSignal<Number> signal,
Function<Number, String> format) {
// Create a signal to track previous-current value pairs
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue())
);
// Effect: Update changeSignal when the main signal changes
// This tracks the previous value to calculate percentage change
Signal.unboundEffect(() -> {
double current = signal.get().doubleValue();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});
// Computed signal: Calculate percentage change from Change record
Signal<Double> percentageSignal = changeSignal.map(change ->
calculatePercentageChange(change.current(), change.previous())
);
// Derived signals: Map percentage to display properties
Signal<String> prefixSignal = percentageSignal.map(this::getPrefix);
Signal<VaadinIcon> iconSignal = percentageSignal.map(this::getIcon);
Signal<Boolean> successSignal = percentageSignal.map(percentage -> percentage > 0);
// Build UI components
H2 h2 = new H2(title);
h2.addClassNames(FontWeight.NORMAL, Margin.NONE,
TextColor.SECONDARY, FontSize.XSMALL);
// Bind value display to formatted signal
Span valueSpan = new Span();
valueSpan.addClassNames(FontWeight.SEMIBOLD, FontSize.XXXLARGE);
valueSpan.bindText(signal.map(format::apply));
// Bind percentage text with prefix
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix ->
prefix + percentageSignal.get()
));
// Bind icon to computed signal
Icon icon = new Icon(iconSignal);
icon.setSize("10px");
icon.getStyle().setMarginRight("4px").setMarginLeft("0");
// Create badge with conditional theme binding
Span badge = new Span();
badge.add(icon, percentageSpan);
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));
add(h2, valueSpan, badge);
getStyle().setGap("5px");
}
private String getPrefix(double percentage) {
if (percentage == 0) {
return "±";
} else if (percentage > 0) {
return "+";
} else {
return "";
}
}
private VaadinIcon getIcon(double percentage) {
return percentage < 0 ? VaadinIcon.ARROW_DOWN : VaadinIcon.ARROW_UP;
}
private double calculatePercentageChange(double current, double previous) {
if (previous == 0.0) {
return 0.0;
}
double percent = ((current - previous) / Math.abs(previous)) * 100.0;
return Math.round(percent * 10.0) / 10.0;
}
}
private Component createHighlightCard(String title,
ValueSignal<Number> signal, Function<Number, String> format) {
return new HighlightCard(title, signal, format);
}
private String formatNumber(Number value) {
return String.valueOf(value);
}
private String formatCompactNumber(Number value) {
if (value.doubleValue() >= 1000) {
double rounded = Math.round(value.doubleValue() / 100.0) / 10.0;
return rounded + "k";
}
return String.valueOf(value);
}
// Supporting classes (simplified for documentation)
// Using Spring's DI in this example
@Service
static class SchedulerService {
private final java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newScheduledThreadPool(1);
private final java.util.Random random = new java.util.Random();
private final java.time.format.DateTimeFormatter timeFormatter =
java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
void scheduleDashboardDataUpdate(java.util.function.Consumer<DashboardData> callback) {
// Schedule periodic updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
DashboardData data = generateData();
callback.accept(data);
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS);
}
private DashboardData generateData() {
return new DashboardData(
randomBetween(650, 820), // currentUsers
randomBetween(42000, 62000), // viewEvents
java.time.LocalTime.now().format(timeFormatter), // timestamp
randomBetween(480, 920), // berlinValue
randomBetween(420, 820), // londonValue
randomBetween(220, 520) // newYorkValue
);
}
private int randomBetween(int min, int max) {
return min + random.nextInt(max - min + 1);
}
}
record DashboardData(
int currentUsers,
int viewEvents,
String timestamp,
int berlinValue,
int londonValue,
int newYorkValue
) {}
}
This code demonstrates several important patterns:
Per-Series Effects
Each chart series has its own effect via bindChartData():
Source code
Java
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// The binding method creates an effect per series
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> {
series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new));
});
}Each effect watches one ListSignal and updates one series. This granular approach means only the affected series updates when its signal changes, not the entire chart.
The effect reads the list of signals (signal.get()), then maps each entry signal to its value with .map(Signal::get). This is necessary because ListSignal entries are themselves signals (ValueSignal<Number>).
Multiple Effects for Different Concerns
The chart has separate effects for different update concerns:
-
Data binding effects - One per series, updates series data when its signal changes
-
Category effect - Updates x-axis labels when
timelineCategoriesSignalchanges -
Redraw effect - Watches all timeline signals and triggers chart redraw
This separation of concerns makes the code easier to understand and debug. Each effect has a single responsibility.
Coordinated Redraw Effect
The redraw effect demonstrates how to coordinate multiple signals:
Source code
Java
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});This effect calls get() on each timeline signal to register dependencies, then calls chart.drawChart(). When any of the three signals change, the effect re-runs and redraws the chart.
|
Tip
|
Reading a signal with |
Change Tracking Pattern
The highlight card demonstrates tracking value changes over time using the Change record pattern:
Source code
RealtimeDashboard.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.charts.Chart;
import com.vaadin.flow.component.charts.model.ChartType;
import com.vaadin.flow.component.charts.model.Configuration;
import com.vaadin.flow.component.charts.model.ListSeries;
import com.vaadin.flow.component.charts.model.XAxis;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ListSignal;
import com.vaadin.flow.signals.local.ValueSignal;
import com.vaadin.flow.theme.lumo.LumoUtility.FontSize;
import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import com.vaadin.flow.theme.lumo.LumoUtility.TextColor;
import java.util.function.Function;
import org.springframework.stereotype.Service;
/**
* Real-time dashboard demonstrating signal-based architecture.
*
* Key design principles:
* - Backend service doesn't work with signals directly
* - Service calls a callback with plain data objects
* - View provides callback that only updates signals
* - No state stored in regular fields, only in signals
* - No manual listeners or UI.access() calls needed
* - Charts update via separate effects watching signals
*/
@Route("real-time-dashboard-with-signals")
public class RealtimeDashboard extends VerticalLayout {
private static final int TIMELINE_POINTS = 12;
// All state is stored in signals - no regular instance fields for state
private final ValueSignal<Number> currentUsersSignal = new ValueSignal<>(0);
private final ValueSignal<Number> viewEventsSignal = new ValueSignal<>(0);
private final ListSignal<String> timelineCategoriesSignal = new ListSignal<>();
private final ListSignal<Number> berlinTimelineSignal = new ListSignal<>();
private final ListSignal<Number> londonTimelineSignal = new ListSignal<>();
private final ListSignal<Number> newYorkTimelineSignal = new ListSignal<>();
public RealtimeDashboard(SchedulerService schedulerService) {
// Create UI components bound to signals
add(
createHighlightCard("Current users", currentUsersSignal, this::formatNumber),
createHighlightCard("View events", viewEventsSignal, this::formatCompactNumber),
createViewEventsChart()
);
// Register with scheduler - service will call our callback
// No need for UI.access() - signals handle thread safety
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
}
/**
* Callback invoked by the scheduler service with new dashboard data.
* This method ONLY updates signals - no UI manipulation, no listeners.
* UI updates happen automatically via effects.
*/
private void onDataUpdate(DashboardData data) {
// Update simple value signals
currentUsersSignal.set(data.currentUsers());
viewEventsSignal.set(data.viewEvents());
// Update timeline - maintain sliding window of last N points
updateTimelineSignal(berlinTimelineSignal, data.berlinValue());
updateTimelineSignal(londonTimelineSignal, data.londonValue());
updateTimelineSignal(newYorkTimelineSignal, data.newYorkValue());
// Update categories
if (timelineCategoriesSignal.get().size() >= TIMELINE_POINTS) {
timelineCategoriesSignal.remove(timelineCategoriesSignal.get().getFirst());
}
timelineCategoriesSignal.insertLast(data.timestamp());
}
private void updateTimelineSignal(ListSignal<Number> signal, Number newValue) {
if (signal.get().size() >= TIMELINE_POINTS) {
signal.remove(signal.get().getFirst());
}
signal.insertLast(newValue);
}
/**
* Creates a chart that updates automatically when signals change.
* No manual chart.drawChart() calls needed - effects handle it.
*/
private Component createViewEventsChart() {
Chart chart = new Chart(ChartType.AREASPLINE);
Configuration conf = chart.getConfiguration();
XAxis xAxis = new XAxis();
conf.addxAxis(xAxis);
// Create chart series
ListSeries berlinSeries = new ListSeries("Berlin", new Number[0]);
ListSeries londonSeries = new ListSeries("London", new Number[0]);
ListSeries newYorkSeries = new ListSeries("New York", new Number[0]);
// Each series gets its own effect to update data
bindChartData(chart, berlinSeries, berlinTimelineSignal);
bindChartData(chart, londonSeries, londonTimelineSignal);
bindChartData(chart, newYorkSeries, newYorkTimelineSignal);
// Effect to update x-axis categories
Signal.effect(chart, () ->
xAxis.setCategories(timelineCategoriesSignal.get()
.stream().map(Signal::get).toArray(String[]::new))
);
conf.addSeries(berlinSeries);
conf.addSeries(londonSeries);
conf.addSeries(newYorkSeries);
// Separate effect to trigger chart redraw when any data changes
Signal.effect(chart, () -> {
berlinTimelineSignal.get();
londonTimelineSignal.get();
newYorkTimelineSignal.get();
chart.drawChart();
});
return chart;
}
/**
* Creates an effect that updates chart series data when signal changes.
* Each series has its own independent effect.
*/
private static void bindChartData(Chart chart, ListSeries series,
ListSignal<Number> signal) {
Signal.effect(chart, () -> series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new)));
}
/**
* A highlight card showing a metric value with percentage change indicator.
* Demonstrates: computed signals, signal mapping, and derived state.
*/
private static final class HighlightCard extends VerticalLayout {
// Internal record tracking previous and current values
record Change(double previous, double current) {}
private HighlightCard(String title, ValueSignal<Number> signal,
Function<Number, String> format) {
// Create a signal to track previous-current value pairs
ValueSignal<Change> changeSignal = new ValueSignal<>(
new Change(signal.peek().doubleValue(), signal.peek().doubleValue())
);
// Effect: Update changeSignal when the main signal changes
// This tracks the previous value to calculate percentage change
Signal.unboundEffect(() -> {
double current = signal.get().doubleValue();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});
// Computed signal: Calculate percentage change from Change record
Signal<Double> percentageSignal = changeSignal.map(change ->
calculatePercentageChange(change.current(), change.previous())
);
// Derived signals: Map percentage to display properties
Signal<String> prefixSignal = percentageSignal.map(this::getPrefix);
Signal<VaadinIcon> iconSignal = percentageSignal.map(this::getIcon);
Signal<Boolean> successSignal = percentageSignal.map(percentage -> percentage > 0);
// Build UI components
H2 h2 = new H2(title);
h2.addClassNames(FontWeight.NORMAL, Margin.NONE,
TextColor.SECONDARY, FontSize.XSMALL);
// Bind value display to formatted signal
Span valueSpan = new Span();
valueSpan.addClassNames(FontWeight.SEMIBOLD, FontSize.XXXLARGE);
valueSpan.bindText(signal.map(format::apply));
// Bind percentage text with prefix
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix ->
prefix + percentageSignal.get()
));
// Bind icon to computed signal
Icon icon = new Icon(iconSignal);
icon.setSize("10px");
icon.getStyle().setMarginRight("4px").setMarginLeft("0");
// Create badge with conditional theme binding
Span badge = new Span();
badge.add(icon, percentageSpan);
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));
add(h2, valueSpan, badge);
getStyle().setGap("5px");
}
private String getPrefix(double percentage) {
if (percentage == 0) {
return "±";
} else if (percentage > 0) {
return "+";
} else {
return "";
}
}
private VaadinIcon getIcon(double percentage) {
return percentage < 0 ? VaadinIcon.ARROW_DOWN : VaadinIcon.ARROW_UP;
}
private double calculatePercentageChange(double current, double previous) {
if (previous == 0.0) {
return 0.0;
}
double percent = ((current - previous) / Math.abs(previous)) * 100.0;
return Math.round(percent * 10.0) / 10.0;
}
}
private Component createHighlightCard(String title,
ValueSignal<Number> signal, Function<Number, String> format) {
return new HighlightCard(title, signal, format);
}
private String formatNumber(Number value) {
return String.valueOf(value);
}
private String formatCompactNumber(Number value) {
if (value.doubleValue() >= 1000) {
double rounded = Math.round(value.doubleValue() / 100.0) / 10.0;
return rounded + "k";
}
return String.valueOf(value);
}
// Supporting classes (simplified for documentation)
// Using Spring's DI in this example
@Service
static class SchedulerService {
private final java.util.concurrent.ScheduledExecutorService scheduler =
java.util.concurrent.Executors.newScheduledThreadPool(1);
private final java.util.Random random = new java.util.Random();
private final java.time.format.DateTimeFormatter timeFormatter =
java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
void scheduleDashboardDataUpdate(java.util.function.Consumer<DashboardData> callback) {
// Schedule periodic updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
DashboardData data = generateData();
callback.accept(data);
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS);
}
private DashboardData generateData() {
return new DashboardData(
randomBetween(650, 820), // currentUsers
randomBetween(42000, 62000), // viewEvents
java.time.LocalTime.now().format(timeFormatter), // timestamp
randomBetween(480, 920), // berlinValue
randomBetween(420, 820), // londonValue
randomBetween(220, 520) // newYorkValue
);
}
private int randomBetween(int min, int max) {
return min + random.nextInt(max - min + 1);
}
}
record DashboardData(
int currentUsers,
int viewEvents,
String timestamp,
int berlinValue,
int londonValue,
int newYorkValue
) {}
}
This pattern uses:
-
Change record - Holds both
previousandcurrentvalues -
Change signal - A
ValueSignal<Change>tracking the value pair -
Synchronization effect - Updates
changeSignalwhen main signal changes -
Computed signals - Derive percentage, prefix, icon from the change
The effect uses peek() to read the previous current value without creating a circular dependency:
Source code
Java
Signal.effect(this, () -> {
double current = signal.get(); // Creates dependency
double previous = changeSignal.peek().current(); // No dependency
changeSignal.set(new Change(previous, current));
});If we used changeSignal.get() instead of peek(), the effect would re-run whenever changeSignal changes, creating an infinite loop since the effect itself changes changeSignal.
Using Component Binding Methods
The highlight card also demonstrates using component binding methods instead of manually creating and updating components with setters. Notice how components are bound to signals using bindText() and constructor-based binding:
Source code
Java
// Text binding via bindText()
Span valueSpan = new Span();
valueSpan.bindText(signal.map(format::apply));
Span percentageSpan = new Span();
percentageSpan.bindText(prefixSignal.map(prefix -> prefix + percentageSignal.get()));
// Icon constructor accepts Signal<VaadinIcon>
Icon icon = new Icon(iconSignal);
// Theme list binding via bind()
badge.getElement().getThemeList().bind("success", successSignal);
badge.getElement().getThemeList().bind("error", Signal.not(successSignal));These binding methods are preferred over manually creating and updating components with setters because:
-
Less boilerplate - No need to write
Signal.effect(component, () → …)or manual setter calls in response to state changes -
Clearer intent - The binding method name clearly states what’s being bound
-
Built-in lifecycle - The framework manages effect cleanup when components are detached
-
Type safety - Binding methods are type-checked at compile time
-
Automatic updates - Component state stays in sync automatically; no need to manually call
setText(),setVisible(), etc.
Use bindText(), bindValue(), bindVisible(), and similar methods whenever available. Create manual effects only when you need custom logic that isn’t covered by built-in binding methods.
Key Patterns Summary
Service callback pattern
Source code
Java
// Service calls callback with plain data
schedulerService.scheduleDashboardDataUpdate(this::onDataUpdate);
// Callback only updates signals
private void onDataUpdate(DashboardData data) {
currentUsersSignal.set(data.currentUsers());
// ... more signal updates
}Effect-based chart updates
Source code
Java
// Effect watches signal and updates series
Signal.effect(chart, () -> {
series.setData(signal.get().stream()
.map(Signal::get)
.toArray(Number[]::new));
});Coordinated multi-signal effects
Source code
Java
// Effect watches multiple signals
Signal.effect(chart, () -> {
signal1.get();
signal2.get();
signal3.get();
chart.drawChart();
});Change tracking with peek()
Source code
Java
// Effect tracks previous value without circular dependency
Signal.effect(component, () -> {
double current = signal.get();
double previous = changeSignal.peek().current();
changeSignal.set(new Change(previous, current));
});Best Practices
Store all reactive state in signals - Don’t mix signals with regular fields for state. Make it clear what’s reactive.
Keep callbacks focused on signals - Service callbacks should only update signals, never manipulate UI directly.
Use effects for UI updates - Let effects watch signals and update components. Don’t manually trigger updates.
Separate concerns with multiple effects - Don’t create one giant effect. Split into focused effects with single responsibilities.
Use peek() to avoid circular dependencies - When reading a signal you’re about to update inside an effect, use peek() not get().
Prefer binding methods over manual effects - Use bindText(), bindValue(), bindVisible(), and component constructors that accept signals when available.
Enable Vaadin Push feature for real-time updates - Without Vaadin Push, signal changes only propagate when users interact with the UI.
Summary
This dashboard demonstrates a clean signal-based architecture:
-
Backend services work with plain data objects, not signals
-
A callback layer updates signals from service data
-
Effects watch signals and update UI automatically
-
Component binding methods (
bindText(), constructors) preferred over manual component creation/updates with setters -
No manual state management, listeners, or UI.access() calls needed
-
Charts update via separate effects for different concerns
The result is maintainable, testable code with automatic UI synchronization.