Like Tatu pointed out, we’re definitely still learning what those practices are so any insights about what worked and what didn’t work for you could be very valuable! I don’t have any definite answers yet but I can offer some hunches.
I think they key concept is to think in terms of how you share state in general and only viewing signals as the mechanism. You have exactly the same concepts also if you share that state without signals, but it’s just a bit more convenient when you don’t need to propagate changes manually.
The easy part is state that is local to a component or a single component hierarchy. I think those should be owned by the root of that hierarchy and be defined as instance fields in the component class. The simple cases just pass the reference to child components in the constructor or with dedicated setter methods (typically named bindFoo rather than setFoo).
public class ClickCounter extends VerticalLayout {
private final NumberSignal count = new NumberSignal(0);
public ClickCountButton() {
add(new Span(() -> "Click count: " + count.valueAsInt()));
add(new Button("Increment", click -> count.incrementBy(1));
}
}
(note that the component constructor doesn’t exist yet)
It gets one step more tricky when you need inversion of control because the parent doesn’t know about its children. The typical case here is state that is owned the application’s main layout but used by different views. In that case, I would suggest defining a single @UIScope class that acts as a holder for all such signals. One alternative might be to define each such signal as a separate UI-scoped bean but I suspect it will be beneficial to have a single “inventory” of all the UI-scoped UI-state in the application.
@UIScope
public record UIState(
ReferenceSignal<User> user,
ReferenceSignal<ShoppingCart> cart) {
public UIState() {
this(
new ReferenceSignal<>(User.ANONOMYOUS),
new ReferenceSignal<>(new ShoppingCart())
);
}
}
I think signals that are shared between UI and backend can be further split into two separate categories. The first category is state used for coordination between a single user and an asynchronous backend task. This kind of state is owned by the UI that triggered the task whereas the task itself knows nothing about signals but instead just uses callbacks (e.g. Consumer<Report> onComplete).
add(new Button("Generate report", click -> {
var progress = new ReferenceSignal<Double>();
var report = new ReferenceSignal<Report>();
add(new ProgressBarBar(progress));
var downloadLink = new Anchor("Download report");
downloadLink.bindVisible(report.map(x -> x != null));
downloadLink.bindHref(report.mapOrNull(Report::getUrl));
add(downloadLink);
startReportGenerationTask(progress::value, report::value);
}));
(again using some API that doesn’t yet exist)
The last case is state that is used for collaboration between multiple users. In that case, the backend acts as a lookup mechanism and maybe also applies access control by returning a restricted signal. In that case, the signal instances are owned by something like a singleton @Service bean.
@Service
public class ChatService {
private final Map<String, ListSignal<ChatMessage>> rooms = new ConcurrentHashMap<>();
public ListSignal<ChatMessage> getRoom(String name) {
return rooms.computeIfAbsent(name, _ -> new ListSignal<>(ChatMessage.class)).asReadonly();
}
public void postMessage(String room, String message) {
rooms.get(name).insertLast(new ChatMessage(message));
}
}
I don’t think there’s any room for SignalFactory.IN_MEMORY_SHARED in any case. I actually regret introducing that API and we might even remove it before we declare the API stable. If you want a single shared instance, then a global variable named foo is clearer than passing "foo" to a global method. If you have multiple instances such as the chat example, then you need to think about namespaces and then it’s preferable with a dedicated map and rooms.get(name) rather than coming up with a naming convention for a generic map such as signals.get("rooms/" + name") (which is exactly what IN_MEMORY_SHARED is).