Migrating from Collaboration Kit to Signals
- Before Migrating
- Concept Mapping
- Step 1: Replace Topics with a Signal Registry
- Step 2: Replace UserInfo
- Step 3: Presence and Avatars
- Step 4: Collaborative Forms
- Step 5: Field Highlighting
- Step 6: Chat and Messages
- Step 7: The Low-Level Topic API
- Step 8: Background Threads
- Gaps and Cases That Can’t Be Migrated
- Feature Checklist
- Learn More
Collaboration Kit and shared signals solve the same underlying problem: keeping a piece of server-side state consistent across several users and pushing the changes to every browser that’s watching. They arrive at it from different directions.
Collaboration Kit is a library of ready-made, use-case-specific features — a collaborative binder, an avatar group, a chat — built on a topic abstraction. Shared signals are a general-purpose reactive primitive built into Vaadin Flow. They don’t know anything about forms or chats, but everything built on them is reactive by default, requires no extra dependency, and composes with the rest of the signals API.
This guide maps each Collaboration Kit concept to its signals equivalent, shows the code for the four high-level use cases, and is explicit about the pieces you have to build yourself.
The direction of travel is toward signals. Vaadin tracks bringing the remaining Collaboration Kit features into Flow, and deprecating Collaboration Kit once they’re there, in collaboration-kit#138. Most of the gaps below are open work rather than deliberate omissions, and each one links to its tracking issue where one exists.
Before Migrating
Read this section first. It describes what changes conceptually, and it lists the cases where migrating isn’t yet the right move.
What Changes Conceptually
Topics become objects you own. Collaboration Kit resolves a topic from a string identifier, and any two connections that pass the same string share data. Shared signals have no such registry: two users share state when they hold a reference to the same signal instance. Replacing topics therefore means introducing an application-scoped object that maps identifiers to signal instances. See Replace Topics with a Signal Registry.
Connections become bindings. Collaboration Kit activates a TopicConnection when a component is attached and deactivates it on detach. Signals do the same thing implicitly: Signal.effect() and every bind*() method are active only while their owner component is attached. There’s nothing left to open or close.
Subscribers become effects. Instead of registering a MapSubscriber or ListSubscriber and reacting to change events, you read signal values inside an effect or a binding, and the framework re-runs it when a value changes.
The engine disappears. There’s no CollaborationEngine singleton, no service init listener, and no ConnectionContext. Signal writes are thread-safe and dispatch UI updates themselves, so background threads write to a signal directly instead of going through a SystemConnectionContext.
When Not to Migrate Yet
Two Collaboration Kit capabilities have no signals equivalent at all, and both are properties of the deployment rather than of a feature. Stop here if the application depends on either:
- Clustering
-
Collaboration Kit has an experimental
BackendAPI that shares topic data between nodes. Shared signals are single-JVM only, and clustering is not yet implemented. - Session serialization
-
Collaboration Kit documents which of its classes are safe to keep in the HTTP session, which is what makes session replication work. Serializing a shared signal isn’t supported yet, and currently fails fast rather than producing broken state.
Both are covered in detail, with the reasoning and the observable symptoms, in Gaps and Cases That Can’t Be Migrated. That section also lists the features that can be migrated but only by rebuilding behavior Collaboration Kit provides out of the box, and the smaller API-level differences worth knowing before starting.
Enable Push Explicitly
Cross-user updates only reach the browser immediately if server push is enabled. Both products need it, but only one of them arranges it: Collaboration Kit turns push on by itself. When a topic connection activates in a UI that has neither push nor polling, it sets PushMode.AUTOMATIC and logs a warning. Signals never touch the push configuration.
An application that relied on that default has no @Push annotation anywhere, and migrating removes the thing that was compensating. The result is easy to miss in testing: everything still works for the user making a change, and other users see it only the next time they interact with the page. Add @Push before migrating, while Collaboration Kit is still there to make the two behave the same.
To confirm which case you’re in, look for the Collaboration Kit warning in the server log at startup, or set setAutomaticallyActivatePush(false) and check that real-time updates still arrive.
Migrate Incrementally
Collaboration Kit and signals can run side by side in the same application, and even in the same view: they’re independent libraries with no shared state. Migrating one view, or one feature within a view, at a time is safe. A practical order is chat first (self-contained), then avatars, then forms, and the low-level topic API last.
Concept Mapping
| Collaboration Kit | Signals | Notes |
|---|---|---|
| Your own record | Signals have no user model. Define an immutable record and assign color indexes yourself. |
Topic identifier | A signal instance from an application-scoped registry | Sharing is by object identity, not by string. |
| Nothing | Reading a shared signal is enough. |
|
| Both are active only while the owner is attached. |
| Nothing | Signal writes are thread-safe from any thread. |
|
| Both use |
|
| Entries are child signals instead of values behind a key. |
| The child |
|
|
| Dependencies are tracked automatically. |
|
| |
| Explicit removal in a detach listener | No automatic cleanup. |
| Cleanup in your registry | No automatic cleanup. |
|
| Validation stays in |
| A shared signal for values, a list signal of editors | The highlight component is driven directly. |
|
| Presence tracking is manual. |
|
| Add on attach, remove on detach. |
|
| Renders any list signal of messages. |
|
| A submit listener that inserts into the list signal. |
| The list signal itself | Any code holding the signal can submit. |
| Your own repository call | Write to the database, then insert into the signal. |
| Not available | Shared signals are single-JVM. |
Step 1: Replace Topics with a Signal Registry
A topic identifier in Collaboration Kit is a lookup key into a global namespace. Reproduce that with an application-scoped bean that owns the signals for each identifier.
Group the signals that belong to one topic in a record, so that a view resolves everything it needs in a single lookup:
Source code
Java
public record DocumentState(
SharedValueSignal<PersonForm> form,
SharedListSignal<FieldEditor> editors,
SharedListSignal<Collaborator> collaborators,
SharedListSignal<ChatMessage> messages) {
static DocumentState create(PersonForm initialValue) {
return new DocumentState(new SharedValueSignal<>(initialValue),
new SharedListSignal<>(FieldEditor.class),
new SharedListSignal<>(Collaborator.class),
new SharedListSignal<>(ChatMessage.class));
}
}The registry itself is a singleton bean holding a concurrent map. Because the state is created lazily, the first user to open a document seeds it from the backend — the same job the bean supplier callback does in CollaborationBinder::setTopic:
Source code
Java
@Component
public class DocumentStateRegistry {
private final PersonService personService;
private final Map<String, DocumentState> states = new ConcurrentHashMap<>();
public DocumentStateRegistry(PersonService personService) {
this.personService = personService;
}
public DocumentState state(String documentId) {
return states.computeIfAbsent(documentId, id -> DocumentState
.create(PersonForm.of(personService.findById(id))));
}
}|
Note
|
Keep the Registry Out of the Session
Look up the state through the bean whenever it’s needed, and store the resulting signals in fields of the view only. Holding the registry in a session attribute has the same drawbacks that storing CollaborationEngine in the session has.
|
Discarding Unused State
Collaboration Kit’s expiration timeout drops topic data after a quiet period. The registry needs to do this explicitly, otherwise every document ever opened stays in memory for the lifetime of the application.
Count the views currently using a state and discard it when the count reaches zero. Expose the two halves as a symmetric pair:
Source code
Java
private record Ref(DocumentState state, AtomicInteger users) {
}
private final Map<String, Ref> refs = new ConcurrentHashMap<>();
public DocumentState retain(String documentId) {
return refs.compute(documentId, (id, existing) -> {
Ref ref = existing != null ? existing
: new Ref(DocumentState.create(
PersonForm.of(personService.findById(id))),
new AtomicInteger());
ref.users().incrementAndGet();
return ref;
}).state();
}
public void release(String documentId) {
refs.computeIfPresent(documentId,
(id, ref) -> ref.users().decrementAndGet() > 0 ? ref : null);
}Call the pair from attach and detach, the same way trackPresence() does, and guard against two calls in a row on the same side:
Source code
Java
public static DocumentState hold(Component owner, String documentId,
DocumentStateRegistry registry) {
AtomicBoolean held = new AtomicBoolean(true);
owner.addAttachListener(event -> {
if (held.compareAndSet(false, true)) {
registry.retain(documentId);
}
});
owner.addDetachListener(event -> {
if (held.compareAndSet(true, false)) {
registry.release(documentId);
}
});
return registry.retain(documentId);
}Registering only the detach listener is a mistake that’s easy to make and hard to see. A view that’s detached and attached again — navigating back to a retained view, a @PreserveOnRefresh view surviving a reload, a component moved between layouts, a dialog reopened — then releases more times than it retains. The count reaches zero while the view is still open, the state is dropped, and the next user to open the same document gets a fresh DocumentState and silently stops sharing anything with them.
|
Important
|
Give Release a Grace Period
Discarding at zero immediately is the equivalent of Duration.ZERO, and it has the same drawback: a view that reattaches a moment later gets a different instance than the one its bindings were built against. Schedule the removal instead of performing it directly, and cancel the scheduled task if the count rises again. For a view that can stay detached for longer than that window, resolve the state inside the attach listener and rebuild the bindings from it, rather than caching the instance from the constructor.
|
Step 2: Replace UserInfo
UserInfo carries an identifier, a display name, an abbreviation, an image URL, and a color index. Signals have no user model, so define a record that carries exactly what the UI needs. Values stored in shared signals are converted to JSON with Jackson, and records serialize cleanly:
Source code
Java
public record Collaborator(String id, String name, String image,
int colorIndex) {
private static final int COLOR_COUNT = 7;
public static Collaborator of(User user) {
return new Collaborator(user.getId(), user.getName(),
user.getImageUrl(),
Math.floorMod(user.getId().hashCode(), COLOR_COUNT));
}
}Collaboration Kit assigns color indexes automatically, cycling through seven values. Deriving the index from a hash of the user identifier, as above, gives a stable color per user without any shared bookkeeping. Two users in the same topic can end up with the same color; if that matters, allocate indexes from the collaborator list instead when the user joins.
|
Tip
|
Store Identifiers, Not Entities
Keep the record small and free of framework types. A DownloadHandler can’t be stored in a signal, for the same reason it can’t be stored in UserInfo. Store the user identifier and resolve the handler when the avatar is created.
|
Step 3: Presence and Avatars
CollaborationAvatarGroup combines two things: tracking who’s present, and rendering them. With signals, these are separate.
Tracking Presence
PresenceManager writes the local user into the topic with EntryScope.CONNECTION, so the entry vanishes when the connection deactivates. Signals need the two halves written explicitly, on attach and on detach:
Source code
Java
public static void trackPresence(Component owner,
SharedListSignal<Collaborator> collaborators, Collaborator localUser) {
AtomicReference<SharedValueSignal<Collaborator>> entry =
new AtomicReference<>();
owner.addAttachListener(event -> entry
.set(collaborators.insertLast(localUser).signal()));
owner.addDetachListener(event -> {
SharedValueSignal<Collaborator> signal = entry.getAndSet(null);
if (signal != null) {
collaborators.remove(signal);
}
});
}insertLast() returns an InsertOperation whose signal() is available immediately, before the insert is confirmed. That signal is the handle used to remove the entry later, in the same way a ListKey is in Collaboration Kit.
|
Important
|
Detach Isn’t Guaranteed
A detach listener runs on navigation and on an orderly tab close, but not when a session expires or a server dies. Collaboration Kit handles those cases with connection scoping. To avoid stale avatars, remove the user’s entries from a SessionDestroyListener as well, and treat presence as advisory rather than authoritative.
|
Rendering Avatars
AvatarGroup binds directly to a list signal. Map each collaborator entry to an AvatarGroupItem:
Source code
Java
AvatarGroup avatars = new AvatarGroup();
avatars.bindItems(collaborators.map(entries -> entries.stream()
.map(entry -> entry.map(DocumentView::toAvatarItem)).toList()));
private static AvatarGroupItem toAvatarItem(Collaborator collaborator) {
AvatarGroupItem item = new AvatarGroupItem(collaborator.name(),
collaborator.image());
item.setColorIndex(collaborator.colorIndex());
return item;
}The outer map() turns the list of entry signals into a list of mapped signals, and bindItems() reads each one. An entry changing its own value re-renders that avatar; the list changing shape re-renders the group.
To exclude the local user’s own avatar — what setOwnAvatarVisible(false) does — filter the stream on the collaborator identifier and create a separate Avatar component for the local user.
Step 4: Collaborative Forms
CollaborationBinder does three separate things: it synchronizes field values between users, it highlights fields that someone else is editing, and it validates and writes to a bean. Only the first two move to signals. Validation and bean binding stay in the regular Binder, which has its own signals integration.
Synchronizing Field Values
Model the shared form state as an immutable record and hold it in a single SharedValueSignal. Each field binds to one property through map() for reading and updater() for writing:
Source code
Java
public record PersonForm(String firstName, String lastName, String email) {
PersonForm withFirstName(String firstName) {
return new PersonForm(firstName, lastName, email);
}
// Remaining "with" methods omitted
}Source code
Java
SharedValueSignal<PersonForm> form = state.form();
TextField firstName = new TextField("First name");
firstName.bindValue(form.map(PersonForm::firstName),
form.updater(PersonForm::withFirstName));
TextField lastName = new TextField("Last name");
lastName.bindValue(form.map(PersonForm::lastName),
form.updater(PersonForm::withLastName));This is the whole of the value-synchronization half of CollaborationBinder. updater() performs a compare-and-set update that retries on conflict, so two users editing different properties concurrently both keep their edits.
Compared with the Collaboration Kit version, several restrictions disappear:
-
readBean()andsetBean()are usable again, because the shared value lives in the registry rather than in the binder. The registry seeds it once, so a new user joining doesn’t reset anybody’s fields. -
Binding with getter and setter callbacks works, because nothing needs a property name as a storage key.
-
reset()becomesform.set(PersonForm.of(person)).
The type restrictions change shape rather than disappearing. Collaboration Kit needs an explicit serializer for values it can’t convert to JSON; a shared signal needs the same values to be Jackson-serializable. Instead of registering a serializer, store the JSON-friendly representation in the record — typically an entity identifier — and resolve it when populating the field:
Source code
Java
// The shared record carries the supervisor identifier, not the entity
public record PersonForm(String firstName, String lastName, Long supervisorId) {
}
ComboBox<Person> supervisor = new ComboBox<>("Supervisor");
supervisor.setItems(personService.findSupervisors());
// Cached so that the chain is cut off when the identifier is unchanged
Signal<Long> supervisorId = Signal.cached(() -> form.get().supervisorId());
Signal<Person> supervisorValue = Signal.cached(() -> {
Long id = supervisorId.get();
return id != null ? personService.findById(id) : null;
});
supervisor.bindValue(supervisorValue, form.updater((value, person) -> value
.withSupervisorId(person != null ? person.getId() : null)));Two details matter here. The identifier is nullable, because the write callback stores null whenever the field is cleared, so the lookup needs a guard. And a plain form.map(value → personService.findById(value.supervisorId())) is derived from the whole record, which means the backend call runs again on every change to any property, including each keystroke in an unrelated text field. Caching the identifier first cuts the chain: the outer cached signal isn’t invalidated while the identifier produces the same value, so the lookup runs only when the supervisor actually changes.
Per-Property State
A single record for the whole form is the simplest option and the one to reach for first. Use a SharedMapSignal keyed by property name — the structure Collaboration Kit uses internally — when properties are added dynamically, or when a form is large enough that per-property change granularity matters:
Source code
Java
// A per-property alternative to the single form signal in the registry
SharedMapSignal<String> values = state.values();
TextField firstName = new TextField("First name");
firstName.bindValue(propertySignal(values, "firstName"),
value -> values.put("firstName", value));
private static Signal<String> propertySignal(SharedMapSignal<String> values,
String property) {
return values.map(entries -> {
SharedValueSignal<String> entry = entries.get(property);
return entry != null ? entry.get() : "";
});
}Read the entry defensively as above: a key that no user has written yet has no entry signal.
Combining with Binder Validation
Keep Binder for validation and for writing to the entity. The fields are bound to signals for synchronization and to the binder for validation at the same time:
Source code
Java
Binder<Person> binder = new Binder<>(Person.class);
binder.forField(email)
.withValidator(new EmailValidator("Enter a valid email address"))
.bind("email");
Button save = new Button("Save");
save.bindEnabled(
binder.validationStatusSignal().map(BinderValidationStatus::isOk));
save.addClickListener(event -> personService.save(form.peek()));Step 5: Field Highlighting
Collaboration Kit shows a colored outline around a field another user has focused, with that user’s name on a tag. The outline is the @vaadin/field-highlighter web component, and Collaboration Kit drives it entirely through Element::executeJs. Application code can drive it the same way and get an identical result. What CollaborationBinder supplies isn’t the component — it’s the wiring around it, and that wiring is what you write.
There are three parts: shared state describing who is editing what, reporting the local user’s focus into that state, and pushing the remote editors to each field.
|
Tip
|
Highlighting Without a Binder
FormManager exists so that custom components can participate in highlighting without a CollaborationBinder. With signals, there’s nothing to participate in — any code holding the editors signal can read and write it.
|
Shared Editor State
Collaboration Kit stores one entry per user, property, and sub-field. Model it the same way, in a list rather than a map, so that each user only ever adds and removes their own entry and no user can clear another user’s entry:
Source code
Java
public record FieldEditor(String property, String userId, String name,
int colorIndex, int fieldIndex) {
}
SharedListSignal<FieldEditor> editors = state.editors();Enabling the Component
The Java artifact, vaadin-field-highlighter-flow, is already on the classpath: vaadin-core depends on it. Collaboration Kit declares it as provided, so removing Collaboration Kit doesn’t take it away.
What removing Collaboration Kit does take away is the frontend module. The @NpmPackage and @JsModule annotations for @vaadin/field-highlighter sit on FieldHighlighterInitializer, and Flow’s production build only includes frontend resources declared on classes your code actually reaches. Collaboration Kit reached that class; once it’s gone, nothing does. A call to executeJs referring to the custom element by name isn’t a reference Flow can see, so the module is left out of the production bundle. The failure is delayed and confusing — development mode works, because the module is in the default bundle, and customElements.get('vaadin-field-highlighter') is undefined only in production.
Reach the class the same way Collaboration Kit does, by extending it:
Source code
Java
public class FieldHighlighting extends FieldHighlighterInitializer {
public static Registration enable(HasValue<?, ?> field) {
return init(((HasElement) field).getElement());
}
}init() is protected static, so a subclass can call it. Using it in place of a hand-written executeJs call matters for a second reason: it runs the initialization on every attach, not once. A field that’s detached and re-attached — a @PreserveOnRefresh view surviving a reload, a cached view, a field moved between layouts — comes back as a fresh client-side element without the focus observer, and a one-shot call would leave that user’s focus silently unreported from then on.
Source code
Java
FieldHighlighting.enable(firstName);Reporting Local Focus
Once initialized, the field fires vaadin-highlight-show and vaadin-highlight-hide. Both carry a fieldIndex in the event detail: 0 for a simple field, and the index of the focused sub-field for a composite such as DateTimePicker. Add and remove the local user’s entry from those events rather than from focus and blur listeners, so that composite fields are handled correctly:
Source code
Java
Element element = firstName.getElement();
element.addEventListener("vaadin-highlight-show", event -> {
int fieldIndex = event.getEventData().at("/event.detail/fieldIndex")
.asInt(0);
editors.insertLast(new FieldEditor("firstName", localUser.id(),
localUser.name(), localUser.colorIndex(), fieldIndex));
}).addEventData("event.detail");
element.addEventListener("vaadin-highlight-hide",
event -> clearEditor(editors, "firstName", localUser.id()));Remove by matching the property and the user rather than by remembering the signal returned when the entry was inserted. Two vaadin-highlight-show events can arrive without a hide between them — moving between the date and time parts of a DateTimePicker is exactly that case — and a single remembered handle would lose the earlier entry, leaving a highlight on the field that nobody can clear. Collaboration Kit sweeps every entry matching the user and the property for the same reason:
Source code
Java
static void clearEditor(SharedListSignal<FieldEditor> editors, String property,
String userId) {
Signal.runInTransaction(() -> editors.get().stream().filter(entry -> {
FieldEditor editor = entry.get();
return editor.property().equals(property)
&& editor.userId().equals(userId);
}).toList().forEach(editors::remove));
}Pushing Remote Editors
An effect sends the current editors of the field to the component whenever the shared list changes. Filter out the local user, the same way CollaborationBinder does — you highlight other people’s focus, not your own:
Source code
Java
private static final ObjectMapper MAPPER = new ObjectMapper();
record HighlightUser(String id, String name, int colorIndex, int fieldIndex) {
}
Signal.effect(firstName, () -> {
ArrayNode users = MAPPER.valueToTree(editors.getValues()
.filter(editor -> editor.property().equals("firstName"))
.filter(editor -> !editor.userId().equals(localUser.id()))
.map(editor -> new HighlightUser(editor.userId(), editor.name(),
editor.colorIndex(), editor.fieldIndex()))
.toList());
element.executeJs(
"customElements.get('vaadin-field-highlighter').setUsers(this, $0)",
users);
});The ObjectMapper is tools.jackson.databind.ObjectMapper, the Jackson 3 mapper the framework uses, and Element::executeJs accepts the resulting node directly. The four properties are what the component expects. colorIndex selects the outline color from the same --vaadin-user-color-* palette the avatars use, so highlights and avatars agree on who is who.
That’s the whole mechanism. It handles several simultaneous editors on one field and sub-field indexes, because the component does, and it looks the same as Collaboration Kit because it is the same component.
|
Important
|
Clean Up on Detach
Editor entries need the same cleanup as presence entries. A user who navigates away while a field is focused gets no vaadin-highlight-hide, so call clearEditor() for the local user in a detach listener.
|
For a form with more than a couple of fields, wrap the three parts in one helper that takes the field, the property name, and the shared list, and call it per binding.
Without the Component
If you’d rather not add the dependency, the same shared state drives a plain CSS outline. This loses the name tags and the sub-field precision, but needs no JavaScript:
Source code
Java
Signal<FieldEditor> otherEditor = Signal.cached(() -> editors.getValues()
.filter(editor -> editor.property().equals("firstName"))
.filter(editor -> !editor.userId().equals(localUser.id()))
.findFirst().orElse(null));
firstName.bindClassName("being-edited", otherEditor.map(Objects::nonNull));
firstName.bindHelperText(otherEditor.map(
editor -> editor != null ? editor.name() + " is editing" : ""));
firstName.getStyle().bind("--editor-color", otherEditor.map(
editor -> editor != null
? "var(--vaadin-user-color-" + editor.colorIndex() + ")"
: "transparent"));Source code
CSS
vaadin-text-field.being-edited {
outline: 2px solid var(--editor-color);
outline-offset: 2px;
}Step 6: Chat and Messages
A chat is a list signal of message records plus two component bindings. Note that MessageListItem isn’t stored in the signal; it’s created when rendering, so the shared value stays a plain record:
Source code
Java
public record ChatMessage(String userId, String userName, int colorIndex,
String text, Instant time) {
}Source code
Java
SharedListSignal<ChatMessage> messages = state.messages();
MessageList list = new MessageList();
list.bindItems(messages.map(entries -> entries.stream()
.map(entry -> entry.map(DocumentView::toMessageItem)).toList()));
MessageInput input = new MessageInput();
input.addSubmitListener(event -> messages.insertLast(
new ChatMessage(localUser.id(), localUser.name(),
localUser.colorIndex(), event.getValue(), Instant.now())));
private static MessageListItem toMessageItem(ChatMessage message) {
MessageListItem item = new MessageListItem(message.text(), message.time(),
message.userName());
item.setUserColorIndex(message.colorIndex());
return item;
}This covers CollaborationMessageList, CollaborationMessageInput, and MessageManager at once. A CollaborationMessageSubmitter isn’t needed either: a custom input component calls insertLast() directly.
setMessageConfigurator() becomes ordinary code in the mapping function — that’s where a censoring rule or a per-user style is applied. setMarkdown() and setAnnounceMessages() are properties of MessageList itself and carry over unchanged.
Persisting Messages
CollaborationMessagePersister exists because Collaboration Kit owns the message store and needs a hook into yours. With signals, your code owns both sides, so persistence is a plain write-through: save first, then insert what the backend returned.
Source code
Java
input.addSubmitListener(event -> {
ChatMessage saved = messageService.save(documentId, localUser.id(),
event.getValue());
messages.insertLast(saved);
});Load the history where the state is created, in the registry:
Source code
Java
SharedListSignal<ChatMessage> messages = new SharedListSignal<>(
ChatMessage.class);
messages.insertAllLast(messageService.findByDocument(documentId));insertAllLast() inserts the whole history in a single transaction, so other users see one atomic change instead of one per message. The timestamp-based FetchQuery protocol has no equivalent and isn’t needed: nothing polls the backend, because the signal is the shared copy.
Step 7: The Low-Level Topic API
Views that use CollaborationMap and CollaborationList directly map onto SharedMapSignal and SharedListSignal operation by operation.
Maps
CollaborationMap |
SharedMapSignal |
|---|---|
|
|
|
|
|
|
|
|
|
|
| An effect that reads |
A key that no user has written yet has no entry signal, so guard peek().get(key) against null. Where Collaboration Kit uses a conditional replace() to avoid overwriting another user’s initialization, SharedMapSignal offers putIfAbsent(), which CollaborationMap does not have.
SharedMapSignal has its own verifyKey(), but it isn’t the counterpart of CollaborationMap::replace: it checks that a key maps to a particular child signal, not that the entry holds a particular value. Compare values with replace() or verifyValue() on the entry signal itself. verifyHasKey() and verifyKeyAbsent() cover the presence of a key.
Use get() inside effects, computed signals, and transactions, where it registers a reactive dependency. Use peek() everywhere else — click listeners, initialization code, background jobs. Calling get() outside a reactive context throws IllegalStateException.
Lists
CollaborationList |
SharedListSignal |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| An effect, |
The important difference is the handle. Collaboration Kit identifies an entry by ListKey and asks the list to operate on it; shared signals give you the child SharedValueSignal, which is both the handle and the way to read and write the value.
Rendering a list is where the difference pays off. A subscriber that adds, removes, and reorders components by hand collapses into one binding:
Source code
Java
VerticalLayout container = new VerticalLayout();
container.bindChildren(items, itemSignal -> {
Span itemView = new Span();
itemView.bindText(itemSignal.map(Item::title));
return itemView;
});Components aren’t recreated when an item value changes, only the bindings inside them are updated.
Conditional Operations
ListOperation conditions become verifications inside a transaction. The transaction is rejected as a whole if a verification fails:
Source code
Java
Signal.runInTransaction(() -> {
list.verifyPosition(entry, ListPosition.first());
entry.set(newValue);
});-
ifFirst(key)andifLast(key)becomeverifyPosition()withListPosition.first()orListPosition.last(). -
ifPrev(key, prev)andifNext(key, next)becomeverifyPosition()withListPosition.after()orListPosition.before(). -
A conditional map replace becomes
replace()on the entry signal, orverifyValue()on the entry inside a transaction. It doesn’t becomeverifyKey(), which compares child signals rather than values. -
ifEmpty()andifNotEmpty()have no direct equivalent. Where they guard against duplicate initialization,putIfAbsent()on a map signal expresses the intent better.
Use verifyChild() before updating an entry that another user might have removed in the meantime. Collaboration Kit’s conditions are per-operation; a signals transaction can verify several conditions and apply several changes atomically, which is more expressive.
Step 8: Background Threads
Collaboration Kit requires a SystemConnectionContext to write to a topic from outside a request, because CollaborationEngine.getInstance() throws in a background thread. Signals have no such constraint. Write to the signal from any thread, with no ui.access() and no context:
Source code
Java
@Async
public void notifyUsers(SharedListSignal<ChatMessage> messages, String text) {
messages.insertLast(new ChatMessage("system", "System", 0, text,
Instant.now()));
}Every effect and binding that depends on the signal runs on the correct UI, and push delivers the change.
Gaps and Cases That Can’t Be Migrated
The mapping in the previous sections covers the common cases. This section is the inventory of what doesn’t map: what makes migration impossible today, what migrates only at the cost of rebuilding something, and where the two APIs differ in ways that are easy to trip over.
Most of these are "not yet" rather than "never". Where a Vaadin issue tracks the work, it’s linked from the relevant heading, so you can check the current status rather than trusting a snapshot.
Blockers
Neither of these has a workaround that keeps the benefits of migrating.
Clustered Deployments
Collaboration Kit’s Backend SPI, enabled through the collaborationEngineBackend feature flag, replicates an ordered event log between nodes; the documentation walks through a Hazelcast implementation. Shared signals have no such SPI. Every shared signal created through a public constructor owns a local tree, and the constructor documentation states outright that the signal doesn’t support clustering. peekConfirmed() exists for the distributed case but currently resolves against local confirmation only.
The signal API is designed with clustering in mind — the tree, the command log, and the confirmation model are all in place — but the distributed implementation is not written yet. flow#23413 records that state; it is closed because the agreed outcome was to fail clearly for now, not because clustering landed.
The symptom is quiet rather than loud: nothing fails, but two users routed to different nodes each see a consistent view of their own node’s state and never see each other. Sticky sessions don’t fix it either, because the point of a topic is that users on different sessions share it.
There’s no partial workaround worth recommending. Writing every change through a shared database and polling it back reproduces neither the latency nor the transactional guarantees, and it gives up the reason to use signals in the first place. Keep Collaboration Kit for clustered deployments.
Session Serialization and Kubernetes Kit
Collaboration Kit is explicit about session serialization: its own documentation lists the classes that must not be stored in the session (CollaborationEngine, TopicConnection, CollaborationMap, CollaborationList) and the ones that are serializable and safe to keep there, including CollaborationBinder, CollaborationAvatarGroup, CollaborationMessageList, and all three managers. That split is what lets Kubernetes Kit replicate sessions.
Shared signals offer no such split. Serializing one throws NotSerializableException with the message "Shared Signal is a shared object that cannot be serialized: it is tied to a specific runtime environment and would leak other sessions if included in session serialization." It applies to every shared signal type, because all the public constructors create the asynchronous tree that rejects serialization.
The exception is a placeholder rather than a final design decision. flow#23413 asks for exactly this behavior — fail with a clear message — on the grounds that sharing signals across a cluster is not yet implemented. Serialization is expected to arrive with the distributed implementation, since the two problems are the same problem: moving signal state out of one JVM.
This reaches further than it first appears. A signal held in a view field is reachable from the session, and so is a signal captured by the lambda behind any bind*() call, because the binding is stored on the component. Both make the session graph unserializable.
- Affected
-
Kubernetes Kit session replication, container-managed session persistence, and any serialization-based hand-off of a session between nodes.
- Not affected
-
The registry bean itself. An application-scoped Spring bean isn’t session state, so holding the signals there is correct regardless.
- Not affected
-
Local signals, which are per-user and don’t carry the same restriction.
If the application serializes sessions, keep Collaboration Kit for now.
Behavior You Have to Rebuild
These migrate, and the guide shows how, but Collaboration Kit does the work for you and signals don’t. Nothing here is blocked; each one is code you write instead of code you configure. Budget for them.
Cleanup When a User Disconnects
EntryScope.CONNECTION removes an entry the moment the connection that wrote it deactivates. Collaboration Kit makes that prompt even for a closed tab by installing a beacon request handler, so the browser reports the unload and the avatar disappears within moments.
Signals have neither the scope nor the beacon. A detach listener covers navigation and an orderly close of the view, and that’s what trackPresence() uses, but nothing fires when the tab is killed, the network drops, or the server is replaced. Those entries survive until the session expires, which is minutes rather than moments.
Treat presence as advisory. Clear entries from a SessionDestroyListener in addition to the detach listener, and if stale avatars are unacceptable, store a timestamp alongside each collaborator and filter out entries that haven’t been refreshed recently.
Topic and Entry Expiration
setExpirationTimeout() is available on CollaborationBinder, FormManager, CollaborationMap, and CollaborationList, and it does two jobs: it frees memory for topics nobody is using, and it repopulates a form from the backend once the last editor has left, so the next user starts from stored data rather than from unsaved edits left by the previous user.
Signals have no lifecycle of their own, so both jobs move to the registry. Discarding Unused State covers the memory half. Reloading is a consequence of it: discarding the state means the next lookup recreates it from the backend. Getting the timing right — long enough that a network blip doesn’t wipe an in-progress edit, short enough that stale edits don’t greet the next user — is now your decision rather than a single Duration.
Automatic User Colors
Collaboration Kit assigns each user a color index on first sight, from a registry kept in CollaborationEngine. On the default local backend it hands out the seven available values in order of first appearance, which spreads colors better than hashing does for the first users it sees. The guarantee is weaker than it looks, though: the registry never shrinks, so the eighth distinct user to appear since startup collides with the first even if both are online, and on a non-local backend the index falls back to a hash of the user identifier.
Nothing equivalent ships with signals. Hashing the identifier, as Step 2 does, matches what Collaboration Kit itself falls back to, and it’s stable and needs no coordination — but two users in the same topic can collide. Allocating indexes from the current collaborator list when a user joins is the only approach that guarantees distinct colors among the users actually present, and neither product does it for you.
The Collaborative Binder Wiring
Tracked in flow#23868.
This is the largest single piece of code a migration has to write, and it’s worth being precise about what’s missing. The @vaadin/field-highlighter web component is not missing: it ships as a normal npm package, it has a documented static API, and Collaboration Kit drives it through Element::executeJs like any other component. Application code can do exactly the same, which is what Step 5 shows — including several editors on one field and sub-field indexes, with the same appearance.
What CollaborationBinder provides on top is the wiring: initializing the highlighter per field, translating focus events into shared state, filtering the local user out, pushing the remainder back to each field, and cleaning up on detach. Reproducing that is perhaps thirty lines shared across a form, and the guide gives them, but it’s thirty lines per application rather than a constructor argument.
flow#23868 proposes bringing a collaborative binder into Flow, built on signals rather than on Collaboration Kit data structures. Until it lands, collaborative form editing is a matter of writing more code, not of waiting.
The Message Persistence Protocol
CollaborationMessagePersister is a small protocol rather than a single save hook. The first manager to connect to a topic fetches the history with a FetchQuery, the result is cached in the topic so later managers don’t re-query, each submit is written to the backend and then re-fetched from the last known timestamp, and duplicates from the timestamp overlap are filtered out.
With signals the shared list is the cache, so most of that protocol becomes unnecessary — Persisting Messages is a save call followed by an insert. What you lose is the framework’s handling of the edge cases the protocol existed for: a write that succeeds in the database but fails before the insert leaves the list short until the state is discarded and reloaded, and messages written to the database by another part of the system don’t appear until then either. If either matters, reconcile the list against the backend when the state is created and after a failed write.
API-Level Differences
Smaller gaps, but each one is a place where a direct translation compiles and then behaves differently.
Parameterized Value Types
Collaboration Kit has two ways to name a parameterized type. The topic API takes a Jackson TypeReference wherever it takes a Class, so CollaborationMap::get can read a Set<String>. CollaborationBinder instead takes the two classes separately — forField(field, Set.class, String.class) — to bind a multi-select field such as a CheckboxGroup.
Shared signals have neither. Every constructor and conversion takes a plain Class, so a parameterized value type can’t be named at all: new SharedValueSignal<>(Set.class) has nowhere to put the element type, and reading the value back loses it.
Wrap the collection in a record, which is typed all the way down and serializes as an object rather than as a bare array:
Source code
Java
public record Selection(Set<String> values) {
}
SharedValueSignal<Selection> selection = new SharedValueSignal<>(
new Selection(Set.of()));
CheckboxGroup<String> group = new CheckboxGroup<>("Options");
group.setItems("a", "b", "c");
group.bindValue(selection.map(Selection::values),
values -> selection.set(new Selection(values)));No Previous Value in Effects
A Collaboration Kit subscriber receives an event, and the event describes the change rather than only the outcome. MapChangeEvent carries the old value next to the new one, and ListChangeEvent adds both to the surrounding keys, exposing the previous and next entry as they were before and after the change. Code that animates a delta or logs an edit history reads those fields.
An effect receives nothing. It re-runs and observes the current state, and the framework doesn’t tell it what changed or what the value was before. EffectContext reports only whether this is the initial run and whether the change came from another session.
Keep the previous value yourself, in a second signal updated from the effect. The real-time dashboard example shows the pattern: a Change record holding the previous and current values, written with peek() so the effect doesn’t depend on its own output. Classifying a list change as an insert, a move, or a value change means diffing two snapshots by hand; if the code needs that, an append-only SharedListSignal of change records is a better fit than reconstructing the change after the fact.
Collaboration Kit doesn’t help here either: ListChangeEvent tracks a change type internally, but neither the accessor nor the enum is public, so a subscriber can’t read it. The gap is the previous value and the surrounding keys, not the classification.
No Emptiness Conditions on Lists
ListOperation offers ifEmpty() and ifNotEmpty(). SharedListSignal verifies only verifyPosition() and verifyChild(), both of which need an existing entry to point at, so neither expresses "the list is empty". SharedMapSignal is better served: verifyHasKey() and verifyKeyAbsent() cover key presence, and putIfAbsent() covers first-writer-wins initialization directly.
Where ifEmpty() guards a one-time seeding of a list, seed it in the registry when the state is created instead. That happens once by construction, so no condition is needed.
Rendering Shared Data in a Data Component
Tracked in flow#23659.
A collaborative list displayed in a layout maps cleanly onto bindChildren(), which adds, removes, and moves only the affected children. A collaborative list displayed in a Grid, a ComboBox, or another component that manages its own rendering has no such binding yet. The current approach is an effect that calls setItems() with a fresh list:
Source code
Java
Signal.effect(grid, () -> grid.setItems(items.getValues().toList()));Every change to the list, including a change to a single entry, refreshes the whole data set. For the list sizes a Collaboration Kit topic typically holds that’s acceptable, but it rules out lazy loading, and it costs more than the subscribe() callback it replaces, which reported one change at a time. Granular item updates and lazy-loaded bindings are planned.
No Cluster Membership Events
MembershipListener and MembershipEvent report nodes joining and leaving the cluster, which a custom Backend uses to clean up after a node that disappeared. As shared signals have no cluster, they have no membership model either. This only matters if you implemented a custom backend.
What Isn’t a Gap
Some Collaboration Kit features look framework-specific but carry over unchanged, and it’s worth not budgeting time for them:
-
Avatar images from a backend.
AvatarGroupItem::setImageHandlertakes aDownloadHandlerdirectly. Build the item in the mapping function and set the handler there; only the handler can’t live inside the signal, exactly as it can’t live insideUserInfo. -
Custom message submitters.
CollaborationMessageSubmitterexists so a custom component can reach the list’s topic. Any code holding the list signal can callinsertLast(), so the interface has nothing left to do. -
Message configurators, Markdown, and announcements. The first becomes ordinary code in the mapping function; the other two are
MessageListproperties and are unaffected by the migration. -
Writing from background threads. Signals need no
SystemConnectionContextand noui.access(). -
Conditional updates. Transactions with
verify*()are strictly more capable than per-operation conditions, since one transaction can carry several conditions and several changes. -
The field highlight component.
@vaadin/field-highlighteris a published npm package with a static JavaScript API. Collaboration Kit has no privileged access to it; only the wiring around it has to be rewritten. -
Read-only views of shared state.
asReadonly()has no Collaboration Kit counterpart at all.
Feature Checklist
Use this to confirm the migration covers everything before removing the Collaboration Kit dependency. Direct means the signals API does the same job; Build it means the behavior is reachable but you write it; Missing means there’s no equivalent. Gaps and Cases That Can’t Be Migrated explains each of the last two.
| Collaboration Kit feature | Status | Replacement |
|---|---|---|
Value synchronization | Direct |
|
Chat and messaging | Direct |
|
Ordered shared data | Direct |
|
Keyed shared data | Direct |
|
Conditional operations | Direct | Transactions with |
Background updates | Direct | Write to the signal from any thread |
Automatic push activation | Missing | Add |
Topic lookup by identifier | Build it | An application-scoped registry |
User model and colors | Build it | Your own record |
Presence tracking | Build it | Attach and detach listeners on a list signal |
Field highlighting | Build it | The same |
Message persistence | Build it | Write through to your repository |
Topic expiration | Build it | Cleanup in the registry |
Read-only shared state | Direct |
|
Automatic disconnect cleanup | Missing | Detach and session-destroy listeners, best effort |
Previous value in change events | Missing | Track the previous value in a second signal |
Parameterized value types | Missing | Wrap the collection in a record |
List emptiness conditions | Missing | Seed in the registry instead |
Cluster membership events | Missing | No equivalent |
Shared data in a | Build it | An effect calling |
Session serialization | Missing | Not yet implemented; blocks Kubernetes Kit session replication |
Clustering | Missing | Not yet implemented |
Learn More
-
Shared Signals — the full shared signal API.
-
Component Bindings — every
bind*()method. -
Transactions — atomicity and verification.
-
Effects and Computed Signals — reactive logic beyond bindings.
-
Signals in Multi-User Tests — testing collaborative features.
For the current status of the gaps described above:
-
collaboration-kit#138 — bringing the remaining features into Flow and deprecating Collaboration Kit.
-
flow#23868 — a collaborative binder in Flow, built on signals.
-
flow#23413 — serialization and clustering of shared signals.
-
flow#23659 — binding items of a data component to a list signal.