-
Notifications
You must be signed in to change notification settings - Fork 83
Description
Description
Complete the Signals API and make it production-ready way of building reactive UI with Java and Vaadin by:
- providing a high-level API in Components and core for common UI building cases and support specific and less often cases with the low-level API and helper classes,
- updating Vaadin online documentation to use Signals in "Building Apps" articles at the first place and having components articles updated with the new methods, but still referencing the old way of managing components state.
- updating start.vaadin.com, all the starter projects to use Signals.
Tier
Free
License
Apache 2.0
Motivation
Background
Vaadin 24.8, 24.9 have shipped the base Signals class hierarchy and helpers in ComponentEffect.
Vaadin 25.0 has added Element-level binding methods and helpers in ElementEffect.
Vaadin 25.1 now aims to fully integrate Signal API into Vaadin Flow Components, complete the core API, make Signal classes serializable and conclude the overall design for the reactive UI state management, so that developers can use this way of building UI as a primary.
Problem
Overall problem: It is common in Vaadin to explicitly handle UI state change by users in the event listeners, maintain clean ups and set the UI state explicitly through the component's methods. This may lead to a boiler-plate code that makes the view classes be unreadable and hard-maintained. Signals on the other hand may simplify the UI development by implicitly handling the state change no matter what kind of source did this - single user event, multi-user events or asynchronous background event, and implicitly handling the component UI attached/detached state.
Another frequent pattern is to make components be the Java class fields in "view" Java classes, where they are accessed along the class in different places, e.g. from the listeners of another component, form binding codes or even from outer layout Java class. Signals can help separating the "state" from the components, leaving only static initialization (e.g. constant column settings) from the dynamically changed content. This may relief the need of component class fields, but making them state fields instead, whereas the component declaration can be local method-scoped.
The need of using UI.access() may bloat the code and requires a knowledge that this method exists and how to use it. Signals incapsulate this detail and basically use it under the hood, so the client code can just update the signal value and async updates work (of course the app needs to enable Push async updates).
Finally, whereas the Signal and related classes are available, it's not yet possible to code the reactive UIs using signals and without using existing low-level Element API, explicit effect functions and defining addValueChangeListener(), nor there is a way of binding signals to a form.
Solution
Signals integration to Vaadin Components would include:
- Signal binding methods to
Componentclass and mix-in interfaces for commonly used component properties (visible, enabled, value) - Component-specific signal binding methods (e.g.
setHelperText) - Two-way signal binding for standalone field components and forms.
- Ensure Signal API is serializable.
This in the end means the component provide bind methods for the component features for the following kind of bindings
- one way binding (e.g. text)
- two-way binding (e.g. value)
- callbacks that use signal as a state (e.g. item label generator),
and theBinderAPI is adjusted/extended so it's features can useSignaland benefit from two-way bindings.
As an example of how the code may look like when using signals, guess the products price calculator add VAT to the base price and updates all product cards:
Example with the value change listeners:
NumberField vatField = new NumberField("VAT %");
// 1. Value change listener explicitly added
Registration vatChangeRegistration = vatField.addValueChangeListener(event -> {
productCards.forEach(productCard -> {
String newPriceLine = getUpdatedPrice(event.getValue());
// 2. Product component explicitly updated with the new price
productCard.setPriceLine(newPriceLine);
});
});
// 3. Duplicated code that repeats the listener code for component initialization
productCards.forEach(productCard -> {
// initialize the UI nearly the same way
});
// 4. Manual cleanup on detaching
vatChangeRegistration.remove();Same example with signals:
// 3. State initialized once a signal is instantiated
ReferenceSignal<Double> vatSignal = new ReferenceSignal<>(24.0);
// 1. Two-way binding, no explicit listeners
// 4. No registration, signal is deactivated on detach
vatField.bindValue(vatSignal);
// Computed signal
Signal<String> priceWithVatText = vatSignal.map(this::getUpdatedPrice);
// 2. Bind computed signal to the product
productCards.forEach(productCard -> productCard.bindText(priceWithVatText));Requirements
-
Common signal binding methods for components:
-
ComponentandElementclasses shall allow visibility binding to a boolean signal by usingvoid bindVisible(Signal<Boolean> visibleSignal)new methods.Componentdelegates toElementand has the same contract -
HasEnabledmix-in andElementclass shall allow enabling/disabling them with a boolean signal by usingvoid bindEnabled(Signal<Boolean> enabledSignal)new methods.HasEnableddelegates toElementand has the same contract - Components implementing
HasTextmix-in interface shall allow binding their text to a signal by usingvoid bindText(Signal<String> textSignal)new method. This should delegate to the same existing method inElement - Vaadin Flow components (including Flow basic HTML components) shall allow passing the string signal to the constructor to establish the one-way text binding to the signal value, where a text value is already supported by exiting constructors, e.g.
new Span(textSignal) - Components implementing
HasValue<T>mix-in interface andElementshall allow a signal binding to their value by usingvoid bindValue(WritableSignal<T> valueSignal)new methods. Note that this is a two-way binding that also updates the signal value when aValueChangeEventis fired.
-
-
Component-specific signal bindings:
In addition to the above common cases in components, there are plenty of other more specific methods that components share thanks to mix-in interfaces or in the standalone flow component classes. For those cases the components shall allow the signal binding through the newly added methods:
-
One-way binding:
A regular one-way bindings for a commonly updated configurations, that is not covered by text, enabled and visibility bindings, some examples:HasTheme::bindThemeName(String, Signal<Boolean>),HasHelper::bindHelperText(Signal<String>),CheckboxGroup::bindRequired(Signal<Boolean>).See the full list for all one-way binding cases.
-
Two-way binding:
Two-way bindings are most common forsetValuefor components that implementHasValue(see next section forHasValue). The pattern is also applicable for some other cases that can be directly updated by the user such as whether aDetailspanel is opened, some examples:Details::setOpened(boolean),AppLayout::setDrawerOpened(boolean),Checkbox::setIndeterminate(boolean),MenuItemBase::setChecked(boolean),Crud::setDirty(boolean).See the full list for all two-way binding cases.
-
Other binding cases:
Other binding methods for component-specific state less common than visible, enable, text and value, e.g.UI::setLocale(Locale),new AvatarGroup(AvatarGroupItem[]),new VerticalLayout(Component[])etc. See the sub-issue for the full list.
-
-
Form binding and HasValue:
Small adjustments should be done to the current
BinderandHasValueAPIs, that would make the most common cases possible for reactive form binding and input fields binding with signals. Designing a new signal-native form binding solution to replaceBinderis out of scope of this PRD, if even decided.-
Signal with the bean/record to edit:
It should be possible to bind a bean signal to the form with a help ofComponentEffectand this should be documented, no new API is added:ComponentEffect.effect(this, () -> binder.readBean(beanToEditSignal.value()); saveButton.addClickListener(event -> { Person person = new Person(); binder.writeBean(person); service.save(person); beanToEditSignal.value(person); });
-
Cross-field validation:
Field-level validator callbacks should be run inside a signal effect so that changes to any signal used by the validator will be detected and trigger running the validator again. Practical cross-field validation furthermore requires that the value of other bindings are available as signals, by adding a signal-aware value getter toBinding:var passwordBinding = binder.bind(passwordField, "password"); binder.forField(confirmPasswordField) .withValidator(value -> !useStrictValidationSignal.value() || Objects.equals(value, passwordBinding.value()), "Must match the password") .bind("confirmPassword");
-
Reacting to validation status changes:
Bindershould provide a readonly signal that contains the latestBinderValidationStatusstate. The signal value should be updated whenever aBinderValidationStatusHandlerwould have been notified. This should work the same regardless of whether a status handler is explicitly configured or if the default that delegates tohandleBinderValidationStatusis in use.Binder<MyBean> binder = ...; Signal<BinderValidationStatus<MyBean>> validationStatusSignal = binder.getValidationStatus(); submitButton.bindEnabled(() -> validationStatusSignal.value().isOk());
-
Updating other parts of the UI based on the bound bean:
We don't add any API toBinderto simplify non-field cases that might now be handled usingReadOnlyHasValue. Instead, we assume there's already a signal containing the currently edited bean and recommend binding directly to that signal. If such a signal doesn't exist, then the first step is to add one.var editedPerson = new ValueSignal<>(Person.class); add(new Span(() -> "Editing: " + editedPerson.value().getName()));
-
Two-way field bindings:
Binderis overkill for simple cases such as a standalone search field. Instead, the natural approach would be to bind a signal directly to the value of an input field. This kind of binding would be most natural as a two-way binding so that the developer doesn't have to do a React-style value change listener that only updates the signal value.var text = new ValueSignal<>(""); add(new TextField() {{ bindValue(text); }}); add(new Span() {{ bindText(() -> "You typed: " text.value()); }}); add(new Button("Clear", click -> text.value(""));
-
-
Iteration:
- The general assumption for iteration is that there's a container component that should have one component child of some specific type for each child signal in a
ListSignal(or a computed signal with the same value type). To help implement this pattern,HasComponentsinterface shall provide avoid bindComponentChildrenmethod along with a similarbindElementChildrenmethod inElement, that takes the bound list signal and the mapping function between signal value and rendered component instance. It's under question what signal type and what generic type is better to use for the method signature, it should be decided during implementation and API validation, but the general considerations are:- method shall have few overrides that allow using
ReferenceSignalandValueSignaldepending on the need of concurrent modification - method shall allow using
ListSignalas well asSignal<List<S extends WritableSignal>>orSignal<List<ValueSignal<T>>becauseListSignaluses JSON representation for the value and that is not necessary in non-concurrent context.
- method shall have few overrides that allow using
- These methods and
bindTextare mutually exclusive so that each one throws aBindingActiveExceptionif any binding is present. While bound, methods for appending or removing children also throw, as doesremoveFromParentfor any child that is attached through a binding - Helper method
ComponentEffect::bindChildrenis for use with components that don't provide appropriate high-level methods with signal support. Like with usingComponentEffectfor component properties, the use ofComponentEffectin this case does not make other methods throwBindingActiveException
- The general assumption for iteration is that there's a container component that should have one component child of some specific type for each child signal in a
-
Reactive callbacks:
-
Some components have configuration in the form of a callback that is used to provide some configuration on demand. This pattern is most common in components that implement
HasItemsbut it can also be seen e.g. with the highlight condition inRouterLink. If any such callback reads a signal value, then it the callback should automatically run again and the outcome applied whenever the signal value changes:grid.setItemSelectableProvider(item -> { if (item.isSensitive()) { return sensitiveSelectionAllowedSignal.value(); } else { return true; } });
-
Simple cases like the router link highlight condition can be handled with some additional API in
SignalPropertySupport. While out of scope for this PRD, extra care is needed for cases with lazy loaded items to ensure listeners are cleared when an item is no longer in the active range and also to avoid inflating memory use for the most common case with callbacks that don't read any signal value. -
This mechanism should not be used for callbacks that are used to handle events – it's not expected that an event handler runs again unless the actual event is triggered again.
-
Other examples of methods in components that follow this pattern and should support signal binding:
Grid::setDropFilter(SerializablePredicate<T> dropFilter)GridContextMenu::setDynamicContentHandler(SerializablePredicate<T> dynamicContentHandler)GridPro.EditColumn::setCellEditableProvider(SerializablePredicate<T> cellEditableProvider)Select::setItemEnabledProvider(SerializablePredicate<T> itemEnabledProvider)
-
See the "Reactive Callback" column in the full list of methods to be modified.
-
-
Selective rendering:
-
It shall be possible to dynamically render a single component out of a components option list based on a signal's value. A value can be of arbitrary type depending to a toggle condition, e.g. window resize, user permissions, edit mode, loading state etc.
-
The binding shall be done through the new helper
ComponentTogglecomponent as shown below (window width case, lazy-created component instances):
// widthSignal value can listen for resize event in Page::addBrowserWindowResizeListener ComponentToggle<Integer> toggle = new ComponentToggle<>(widthSignal); layout.add(toggle); toggle.addExclusive(BigComponent::new, width -> width > 1000); toggle.addExclusive(MediumComponent::new, width -> width > 500); toggle.addFallback(SmallComponent::new);
-
ComponentTogglepicks a single component out of lazy-creating component instances (as above) or a set of existing instances:
ComponentToggle toggle = new ComponentToggle(smallComponent); toggle.addExclusive(bigComponent, () -> widthSignal.value() > 1000); toggle.addExclusive(mediumComponent, () -> widthSignal.value() > 500);
-
ComponentToggledoes not have a client part, semantically similar toCompositeand thus shall be placed into the Flow core module. It has no tag in the DOM. Due to these complexities, it's better to add the base feature into framework first that then makes addingComponentToggleeasier, see Create a ConditionalComponent flow#22031.
-
-
Multi-User Collaboration
- Signals API is collaborative-ready by design within same JVM, but an extra validation is needed to test various scenarios like chat/message list, concurrent state modification, collaborative form editing.
Validation, DX and use cases under consideration
Greenfield validation of the new API should be run. For this, a list of use cases has been defined and we shall review and improve the generated code then answer ourselves the following:
- are Signals best way to implement the use case and shall we keep considering that use case,
- is the use case doable with the API that we plan to complete within a project, discuss and create new API requests otherwise,
- does the code look useful, simpler than the classical approach and performant, make changes to the design or create bugs otherwise.
In addition to that, we shall aim to run DX tests (internal and, if possible, external) to validate how much Signals are appropriate for the new development and converting existing app.
Risks, limitations and breaking changes
Risks
Besides the requirements listed above, the goal of the project is to validate the new API against real-world use cases. Thus, the scope and design may change as we advance with development and validation. We should start testing the API as soon as possible to have time to change/supplement API accordingly. The testing plan/validation guidelines are given as a separate section.
Limitations
We recommend to use Signals for cases where a state change finally ends up on the UI. We are not considering cases where Signals are used outside a UI context, i.e. in the pure backend tasks, though such cases may work fine and give some benefits.
Breaking changes
Breaking changes in Signal and related classes in core are possible as the API is under the feature flag. Breaking changes in existing core or components API should be avoided.
Out of scope of 25.1, but must be considered later
- Helpers for debugging Signals
- Binding to items in
HasItems/DataProvidercomponents - Router integration
- Clustering
Materials
No response
Metrics
No response
Pre-implementation checklist
- Estimated (estimate entered into Estimate custom field)
- Product Manager sign-off (Artur)
- Engineering Manager sign-off (Mikhail)
Pre-release checklist
- Documented (link to documentation provided in sub-issue or comment)
- UX/DX tests conducted and blockers addressed
- Approved for release by Product Manager
Security review
None