Best Practices for Signal Ownership and Propagation

I’m exploring Vaadin Signals in a Flow application and I want to make sure I follow best practices regarding ownership, lifecycle, and propagation. I understand that Signals can be used to inform the UI about backend state changes, but I’m unsure about the most appropriate way to manage and share them between UI classes and “Service” classes.

  1. Should I create global application-wide signals via SignalFactory.IN_MEMORY_SHARED in the UI and the Backend service, or is it better to manage signals as Spring beans? Or maybe member variables in Service classes with a getter for the UI?
  2. For signals that are UI/session-specific, is it recommended to use @UIScope Spring beans, or pass them via constructors to the views/components?
  3. Who should “own” a signal — the backend service that updates it, or the frontend/UI component that binds to it?
  4. Are there recommended patterns for when a signal represents shared state vs local component state?

I’d love to hear how others structure their Signals in a Vaadin Flow project.

Thanks in advance!

The Signals feature is still in formation, so that naturally means that what ever is considered the “best practice” is in formation too. Any new API we add, may tilt it to some direction.

But shortly, if you use Spring, yes then I would use Spring scopes to control the scope of the Signal. The system is designed so that singleton i.e. application wide signals are possible and they support Push updating of the UI. This means that you can have differently scoped Signals in your application.

I would still consider Signal part of the UI state logic. Say if I have backend module, I would not add Vaadin depenency to it. So Signals would sit between service and UI presentation layer. One way to put is to use “view model”.

We would be interested also to hear comments on these questions from those who have implemented prototype apps and PoCs using Signals and how they feel about this, and how they fit their preferred architectures.

1 Like

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).

2 Likes