Docs

Documentation versions (currently viewingVaadin 24)

Full Stack Signals

A full-stack signal is designed to share and synchronize states between the server and clients in real time.

When building a modern web application, you may often need to synchronize the state between the server and clients. You might want to notify all connected clients in a chat application when a new message is posted, or maybe visualize multiple users interacting while editing the same record in a form, or perhaps update clients when the status of an order changes in an e-commerce application. It becomes trickier when you need these updates to be propagated to clients in real time. Fortunately, full-stack signals can help.

A full-stack signal can be seen as a special data type that holds the shared state. It enables clients to subscribe to it for receiving real-time updates when the state changes. The state can be updated by any client, and the changes are automatically propagated to all other clients which are subscribed to the signal.

This documentation page describes how to create and use full-stack signals in Vaadin applications.

Note

Full-stack signals are still under active development and are not yet suitable for production. Therefore, to use them in Vaadin projects, you’ll need to enable explicitly the experimental feature in Copilot, or add com.vaadin.experimental.fullstackSignals=true to the src/main/resources/vaadin-featureflags.properties file.

Also, the implementation of full-stack signals is currently only available for Vaadin Hilla applications which use the React library to render user interfaces.

Server-Side Full-Stack Signal Instance

The purpose of a full-stack signal is to share and synchronize the state between the server and clients in real time. In Vaadin Hilla applications, you can do that by creating an instance of one of the available full-stack signal types and returning it from your @BrowserCallable annotated services.

In the following example, an instance of a ValueSignal is created and returned from a server-side, browser-callable service:

package com.example.application;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;

import com.vaadin.hilla.signal.ValueSignal;

@AnonymousAllowed
@BrowserCallable
public class VoteService {
    private final ValueSignal<Boolean> started =
                    new ValueSignal<>(false, Boolean.class); 1

    public ValueSignal<Boolean> startedSignal() {
        return started; 2
    }
}
  1. Create an instance of a ValueSignal with an initial value of false.

  2. Return the instance of the ValueSignal from the startedSignal method.

Caution
The server-side instance of a full-stack signal is meant to be created once and shared across all clients. Therefore, create the instance as a field in a service class, and return it from the methods. This way, all clients are able to subscribe to the same signal instance and receive real-time updates when the state changes.

The above example demonstrates a simple polling service that uses a ValueSignal to share a Boolean state of whether the voting has started. Clients can subscribe to this signal and receive real-time updates when the state changes. Based on this state, clients can then start or stop the voting process.

For a complete list of available full-stack signal types, see Available Full-Stack Signal Types.

Subscription to a Full-Stack Signal

For a client to receive real-time updates when the state of a full-stack signal changes, it needs to subscribe to the signal. This can be done by calling any normal Vaadin browser-callable service.

The following example demonstrates how to subscribe to the started signal created in the previous section:

import { VoteService } from 'Frontend/generated/endpoints.js';
import { Button } from '@vaadin/react-components/Button.js';

const votingStarted = VoteService.startedSignal({ defaultValue: false }); 1

export default function VoteView() {
  return (
    <>
      <span>Is voting in progress: {votingStarted.value ? 'Yes' : 'No' 2 }</span>
      <Button
        onClick={() => (votingStarted.value = !votingStarted.value) 3 }
        theme={votingStarted.value ? 'error' : ''}
      >
        {votingStarted.value ? 'Stop' : 'Start'} Voting
      </Button>
    </>
  );
}
  1. Subscribe to the started signal and set the default value to false. This client-side default value is used when rendering the component before the first update from the server-side signal is received. It has no effect on the value of server-side signal.

  2. Render the current state of the signal by accessing its value property.

  3. Toggle the state by setting the value property of the signal.

Available Full-Stack Signal Types

The full-stack signals are designed to be used in various scenarios. Based on the requirements, different types of full-stack signals are used. The server-side signal types are available in the com.vaadin.hilla.signals package. Their client-side counterparts are available in @vaadin/hilla-react-signals.

As this is currently under active development, more signal types are added with each new release. The currently available ones are ValueSignal, NumberSignal, and ListSignal. These are described in the following sub-sections.

ValueSignal

The ValueSignal<T> is a full-stack signal that holds a single value of an arbitrary type. It has to be a JSON-serializable type that’s supported by the Hilla framework.

The following example demonstrates how to create and use a ValueSignal in a server-side service:

package com.example.application;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;

import com.vaadin.hilla.signals.ValueSignal;

@AnonymousAllowed
@BrowserCallable
public class SomeService {
    private final ValueSignal<Boolean> sharedBoolean =
                    new ValueSignal<>(true, Boolean.class);

    private final ValueSignal<Integer> sharedInteger =
                    new ValueSignal<>(42, Integer.class);

    private final ValueSignal<String> sharedString =
                    new ValueSignal<>("Hello World", String.class);

    public ValueSignal<Boolean> sharedBoolean() {
        return sharedBoolean;
    }

    public ValueSignal<Integer> sharedInteger() {
        return sharedInteger;
    }

    public ValueSignal<String> sharedString() {
        return sharedString;
    }
}

The above example demonstrates a simple service that uses three ValueSignal instances to share a boolean, an integer, and a string value. The possibilities aren’t limited, though, to primitive types. Any custom type can be used as long as it’s JSON-serializable. Here’s an example using a custom type:

package com.example.application;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import com.vaadin.hilla.Nonnull;
import com.vaadin.hilla.signals.ValueSignal;

@AnonymousAllowed
@BrowserCallable
public class PersonService {
    record Person(String name, int age) {} 1

    private final Person initialValue = new Person("John Doe", 42); 2

    private final ValueSignal<Person> sharedPerson =
                    new ValueSignal<>(initialValue, Person.class); 3

    @Nonnull
    public ValueSignal<@Nonnull Person> sharedPerson() { 4
        return sharedPerson;
    }
}
  1. A record type that is JSON-serializable, in this case a person with their name and age.

  2. The initial value of the signal. This remains the same until an update is submitted.

  3. The signal instance that holds the shared state of the person.

  4. The service method that returns the signal instance. The @Nonnull annotations are used to indicate that both the returned signal and its value may never be null. However, if the signal instance or its value might be null, you can remove the @Nonnull annotations.

Although the above example shows the usage of a record, you can also use classes with mutable properties. There aren’t any technical limitations on this, as the wrapped value of the signal is always replaced with a new instance whenever an update is applied to the signals. However, the usage of immutable types is always preferred when dealing with share values. It helps to reduce confusion and potential bugs that might arise from the shared mutable state.

Having a @BrowserCallable-annotated service with a method that returns a ValueSignal instance similar to the above example, enables the client-side code to subscribe to it by calling the service method:

import { Button, VerticalLayout } from '@vaadin/react-components';

import { ValueSignal } from '@vaadin/hilla-react-signals';
import { PersonService } from 'Frontend/generated/endpoints.js';
import type Person from 'Frontend/generated/com/example/application/services/PersonService/Person.js';

const sharedPerson: ValueSignal<Person> =
          PersonService.sharedPerson({ defaultValue: { name: '', age: 0 } }); 1

export default function PersonView() {
  return (
    <VerticalLayout theme="padding">
      <span>Name: {sharedPerson.value.name 2 }</span>
      <span>Age: {sharedPerson.value.age}</span>
      <Button onClick={() =>
         sharedPerson.value = { 3
            name: sharedPerson.value.name,
            age: sharedPerson.value.age + 1
         }}>Increase age</Button>
    </VerticalLayout>
  );
}
  1. Subscribing to the sharedPerson signal and setting the default value to an empty person.

  2. Rendering the name of the person. The value of the signal is the type, Person with a name property.

  3. Increasing the age of the person by creating a new Person object containing an increased age and assigning this new object as the signal’s value. This triggers an update to the server-side signal. All other clients that are subscribed to the signal also receive the updated value.

Given the nature of the signals, only changing the value of the signal causes the signal’s subscribers to be notified. Changing the internal properties of the value object doesn’t trigger an update.

Setting the Value

All signals have a value property that can be used to both set and read the value of the signal. However, setting concurrently a shared value among multiple clients can cause them to overwrite each other’s changes. Thus, ValueSignal provides extra methods to set the value in different situations:

set(value: T): void

This sets the given value as the signal’s value. It’s the same as assigning the value property, directly. The value change event that is propagated to the server as the result of this operation doesn’t take the last seen value into account. Instead, it overwrites the shared value on the server unconditionally — a policy known as, "Last Write Wins".

replace(expected: T, newValue: T): void

This atomically replaces the value with a new one only if the current value is equal to the expected one. This means that a state change request is sent to the server asking it to "compare and set". At the time of processing this requested change on the server, if the current value is not equal to the expected value, the update is rejected by the server.

update(updater: (current: T) ⇒ T): OperationSubscription

This tries to update the value by applying the callback function to the current value on the client side. When the new value is calculated, a "compare and set" operation is sent to the server. In case of a concurrent change, the update is rejected, and the callback is run again with an updated current value on the client side. This is repeated until the result can be applied without concurrent changes, or the operation is canceled by calling the cancel() function of the returned OperationSubscription. This operation is atomic at the time of the server-side processing, meaning that the server only accepts the update if the value is still the same as when the operation was initiated.

A call to cancel() is not guaranteed always to be effective, as a succeeding operation might already be on its way to the server.

Operations such as replace and update perform a "compare and set" on the server using the equals method of the value type to compare the values. Thus, it’s important to make sure the value type has a proper implementation of the equals method.

NumberSignal

The NumberSignal is a full-stack signal that holds a numeric value. This value is the Double type in Java, and a number type in client-side code. The NumberSignal can be considered a special case of the ValueSignal that is optimized for numeric values by introducing built-in support for atomic increment and decrement operations.

The following example demonstrates how to create and use a NumberSignal in a service class:

package com.example.application;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;

import com.vaadin.hilla.signals.NumberSignal;

@AnonymousAllowed
@BrowserCallable
public class CounterService {
    private final NumberSignal counter = new NumberSignal(1.0); 1

    public NumberSignal counter() { 2
        return counter;
    }
}
  1. Create an instance of a NumberSignal with initial client-side value of 1. If no value is provided to the constructor, it defaults to 0.

  2. Return the instance of the NumberSignal from the counter method.

The above example demonstrates a simple counter service that uses a NumberSignal to share a numeric value. The client can subscribe to this signal, and apart from receiving real-time updates, it can initiate atomic increment and decrement operations, as well:

import { Button, VerticalLayout } from '@vaadin/react-components';
import { CounterService } from 'Frontend/generated/endpoints.js';

const counter = CounterService.counter(); 1

export default function() {
  return (
    <VerticalLayout>
      <span>Counter: {counter 2 }</span>
      <Button onClick={() => counter.incrementBy(5) 3 }>Increase by 5</Button>
      <Button onClick={() => counter.incrementBy(-3) 4 }>Decrease by 3</Button>
      <Button onClick={() => counter.value = 0 5 }>Reset</Button>
    </VerticalLayout>
  );
}
  1. Subscribe to the counter signal. The subscription is done outside the render function to avoid creating a new subscription on each render.

  2. Render the current value of the signal.

  3. Increase the value of the signal using the atomic incrementBy operation.

  4. Decrease the value of the signal using the atomic incrementBy operation and providing a negative value.

  5. Reset the value of the signal to 0 by assigning a new value to it.

The incrementBy operation is incrementally atomic, meaning it guarantees success by reading the current value and applying the increment on the value, atomically. Each operation builds on the previously accepted one, ensuring that n increments or decrements are always applied correctly — even if there are multiple clients trying to update the value, concurrently.

Since NumberSignal is a ValueSignal with the additional atomic operation of incrementBy, it inherits all methods, such as replace and update, making those operations available when using a NumberSignal.

ListSignal

The ListSignal<T> is a full-stack signal that holds a list of values of an arbitrary type. It has to be a JSON-serializable type that’s supported by the Hilla framework.

The following example demonstrates how to create and use a ListSignal in a server-side service:

package com.example.application;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import com.vaadin.hilla.signals.ListSignal;

@AnonymousAllowed
@BrowserCallable
public class TodoService {
    record TodoItem(String text, boolean done) {}

    private final ListSignal<TodoItem> todoItems =
                        new ListSignal<>(TodoItem.class); 1

    @Nonnull
    public ListSignal<@Nonnull TodoItem> todoItems() { 2
        return todoItems;
    }
}
  1. Create an instance of a ListSignal. The initial state of a ListSignal is an empty list.

  2. Return the instance of the ListSignal from the todoItems method.

On the client-side code, subscribing to the shared list signal instance is done in a similar way as with the ValueSignal.

The following example demonstrates how to create a to-do list view that enables concurrent users to add tasks to a shared list:

import { TodoService } from "Frontend/generated/endpoints.js";
import {
  Button,
  TextField,
  HorizontalLayout,
  VerticalLayout
} from "@vaadin/react-components";
import { effect, useSignal } from "@vaadin/hilla-react-signals";

const todoItems = TodoService.todoItems(); 1

export default function TodoView(){
  const newTodoValue = useSignal<string>('');
  return (
    <>
      <VerticalLayout theme="padding">
        <span style={{padding: '10px'}}><h2>Tasks</h2></span>
        {todoItems.value.length === 0 2
          ? <span style={{padding: '10px'}}>No tasks yet...</span>
          : todoItems.value.map((item, index) => 3
              <li key={index}>{item.value.text}</li>
            )
        }
        <HorizontalLayout theme='padding spacing'>
          <TextField placeholder="What's on your mind?"
                     value={newTodoValue.value}
                     onValueChanged={(e) => newTodoValue.value = e.detail.value}/>
          <Button onClick={() => {
            todoItems.insertLast({text: newTodoValue.value, done: false}); 4
            newTodoValue.value = '';
          }}>Add task</Button>
        </HorizontalLayout>
      </VerticalLayout>
    </>
  );
}
  1. Subscribe to the todoItems list signal.

  2. The value property of the ListSignal holds the list of tasks. The length of the list is checked to display a message when there are no tasks.

  3. The map function is used to render the list of tasks.

  4. Add a new task to the list by calling the insertLast method of the ListSignal.

Since the todoItems signal holds the shared list of tasks, any subscribed client to this signal receives real-time updates when the list changes. As a result, when a client adds a new task to the list, all other clients receive the update and the list is re-rendered to reflect the changes. The above example, however, doesn’t demonstrate how to remove or update tasks in the list. This is covered in the next section.

ListSignal API

The client-side API of the ListSignal provides methods to insert and remove items. The ListSignal is a sequence of ValueSignal entries. Therefore, its API is about how the entries are added to the list or removed, and how the concurrent operations regarding the structure of the entries is handled.

As this is currently under active development, more methods and functionalities are added with each new release. The currently available ones are inserLast and remove. These are described below:

insertLast(value: T): Operation

Inserts a new value at the end of the list. The returned Operation object can be used to chain further operations via the result property, which is a Promise. The chained operations are resolved after the current operation is completed and confirmed by the server.

remove(item: ValueSignal<T>): Operation

Removes the given item from the list. The returned Operation object can be used to chain further operations via the result property, which is a Promise. The chained operations are resolved after the current operation is completed and confirmed by the server.

The following example demonstrates how to create a to-do list view that enables concurrent users to add, remove, and update tasks in a shared list, with no changes needed on the server-side:

import { TodoService } from "Frontend/generated/endpoints.js";
import {
  Button,
  Checkbox,
  Icon,
  TextField,
  TextArea,
  HorizontalLayout,
  VerticalLayout
} from "@vaadin/react-components";
import { effect, useSignal, type ValueSignal} from "@vaadin/hilla-react-signals";

const todoItems = TodoService.todoItems();

function TodoComponent({todoItem, onRemove}: {
  todoItem: ValueSignal<{text: string, done: boolean}>,
  onRemove: (signal: ValueSignal<{text: string, done: boolean}>) => void,
}) {
  const editing = useSignal(false);
  const todoText = useSignal('');
  return (
    <HorizontalLayout theme='spacing'
                      style={{ alignItems: 'BASELINE', paddingLeft: '10px' }} >
      {editing.value
        ? <TextArea value={todoText.value}
                     onValueChanged={(e) => todoText.value = e.detail.value}/>
        : <Checkbox label={todoItem.value.text}
                checked={todoItem.value.done}
                onCheckedChanged={(e) => {
                  todoItem.value = {
                    text: todoItem.value.text,
                    done: e.detail.value
                  };
                }}/>
      }
      <Button theme="icon"
              hidden={editing.value}
              onClick={() => {
                editing.value = true;
                todoText.value = todoItem.value.text;
              }}>
        <Icon icon="vaadin:pencil" />
      </Button>
      <Button theme="icon error"
              hidden={editing.value}
              onClick={() => onRemove(todoItem)}>
        <Icon icon="vaadin:trash" />
      </Button>
      <Button theme="icon"
              hidden={!editing.value}
              onClick={() => {
                todoItem.value = {
                  text: todoText.value,
                  done: todoItem.value.done
                };
                editing.value = false;
              }}>
        <Icon icon="vaadin:check" />
      </Button>
      <Button theme="icon error"
              hidden={!editing.value}
              onClick={() => {
                todoText.value = '';
                editing.value = false;
              }}>
        <Icon icon="vaadin:close-small" />
      </Button>
    </HorizontalLayout>
  );
}

export default function TodoView(){
  const newTodoValue = useSignal<string>('');
  return (
    <>
      <VerticalLayout theme="padding">
        <span style={{padding: '10px'}}><h2>Tasks</h2></span>
        {todoItems.value.length === 0
          ? <span style={{padding: '10px'}}>No tasks yet...</span>
          : todoItems.value.map((item, index) =>
            <TodoComponent todoItem={item}
                           key={index}
                           onRemove={() => todoItems.remove(item)}/>)
        }
        <HorizontalLayout theme='padding spacing'>
          <TextField placeholder="What's on your mind?"
                     value={newTodoValue.value}
                     onValueChanged={(e) => newTodoValue.value = e.detail.value}/>
          <Button onClick={() => {
            todoItems.insertLast({text: newTodoValue.value, done: false});
            newTodoValue.value = '';
          }}>Add task</Button>
        </HorizontalLayout>
      </VerticalLayout>
    </>
  );
}

As demonstrated in the above example, each entry in the ListSignal<T> is a ValueSignal<T> itself. Each value can be updated individually using the available API of the ValueSignal. The changes to each individual entry are automatically propagated to all other clients that are subscribed to each entry of the ListSignal. This enables the React rendering process to render only the updated entry, instead of re-rendering the whole list.

Service Method Parameters

When creating the service methods that return full-stack signals, you can accept parameters as well — similar to any other browser-callable services. This makes available a wide range of possibilities for returning dynamically different signals instances.

The following example demonstrates how to create a service method that returns different signal instances based on the passed argument:

package com.example.application;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;

import com.vaadin.hilla.signal.ValueSignal;
import com.vaadin.hilla.signals.NumberSignal;

@AnonymousAllowed
@BrowserCallable
public class VoteService {
    private static final List<String> VOTE_OPTIONS = List.of(
                "option1", "option2", "option3");

    private final Map<String, NumberSignal> voteOptions = new HashMap<>();

    public VoteService() {
        VOTE_OPTIONS.forEach(option ->
                voteOptions.put(option, new NumberSignal()));
    }

    public List<String> voteOptions() {
        return VOTE_OPTIONS;
    }

    public NumberSignal voteOptionSignal(String option) { 1
        return voteOptions.get(option.toLowerCase());
    }
}
  1. The service method returns the associated NumberSignal instance based on the passed argument.

The above example demonstrates a simple voting service that returns different NumberSignal instances based on the name of the voting option. The client-side code can first ask for the available options, and then subscribe to each individual signal instance to send updates and to receive real-time updates when voting happens.

Important
It’s vitally important to make sure that the behaviour of the service method returning a signal instance is deterministic. The same input parameters should always produce the same output. This is necessary to ensure that the state is consistently shared across all of the clients.

Security with Full-Stack Signals

Security with full-Stack signals has a few nuances of which you should be aware. They’re covered below.

Controlling Browser-Callable Service Access

Full-stack signals are exposed by the services that are annotated with @BrowserCallable — or the synonym, @Endpoint. This means the services that expose the signals are secured by the same security rules as any other service using the @AnonymousAllowed, @PermitAll, @RolesAllowed, or @DenyAll on the class or the individual methods.

For more information on how to secure the services, see the security documentation.

Fine-Grained Signal Access Control

Browser-callable access control can be considered basic security for signals, since it allows only limited control over the access to signals. However, there are situations that require finer control over signals. For example, you might want to allow anyone to subscribe to a signal, but only certain logged-in users with a specific role to update the value of that signal. This level of control is realized by adding operation validators to the signals.

Operation Validators

Fine-grained access control rules can include a list such as this:

  • Allow anonymous users to have read-only access to a technical-support chat;

  • Allow logged-in users only to post in the chat and remove their own messages; and

  • Allow support users to post messages, as well as edit or remove their messages.

This scenario shows that business logic and access-control logic are very intertwined. Sometimes, though, there is no clear distinction between them. That’s why in the full-stack signals library these fine-grained controls are implemented by adding an operation validator to the signal instances via calling withOperationValidator and providing an implementation of the OperationValidator functional interface. The operation validator logic is called whenever an operation is submitted to be applied to the signal. The operation is only applied if the operation validator allows it.

Here is the definition of the OperationValidator functional interface:

@FunctionalInterface
public interface OperationValidator<T> {
    ValidationResult validate(SignalOperation<T> operation);
}

The validate method takes a SignalOperation instance as a parameter and returns a ValidationResult. The SignalOperation is a common interface for all operations that can be applied to a signal. The ValidationResult is used to communicate the result of the validation, containing:

  • A status with two possible enumerated values, ALLOWED and REJECTED; and

  • An optional message that can be used to provide a reason for the rejection.

The ValidationResult class provides two convenient static methods to create instances of the class:

allow()

Creates an instance with the status set to ALLOWED.

reject(String message)

Creates an instance with the status set to REJECTED and a message.

The following example demonstrates how to add an operation validator to a signal instance:

NumberSignal voteSignal = new NumberSignal() 1
    .withOperationValidator(operation -> { 2
        if (operation instanceof ValueOperation<Double> valueOp) { 3
            if (Math.abs(valueOp.value()) != 1.0) { 4
                return ValidationResult.reject("Only up / down vote by 1 is allowed");
            }
        }
        return ValidationResult.allow(); 5
    });
  1. Creates an instance of a NumberSignal that defaults to zero.

  2. Defines an operation validator on the signal instance, which results in a new signal instance with the operation validator attached. The original instance remains unchanged (i.e., no validators).

  3. Checks the operation type and the value of the operation.

  4. Rejects the operation if the value is not 1.0 or -1.0.

  5. Allows the operation otherwise.

In the above example, the operation parameter is an instance of SignalOperation. At runtime, the operation can be one of the following types:

ValueOperation<T>

Represents an operation that contains a value of type T.

IncrementOpertaion

Specific to NumberSignal. Represents an operation that increments the value of the signal by a given value. Value is of Type Double. This operation is a special case of ValueOperation<Double>.

SetValueOperation<T>

Specific to ValueSignal and NumberSignal. Represents an operation that sets the value of the signal to a new value of type T. This operation is a special case of ValueOperation<T>.

ReplaceValueOperation<T>

Specific to ValueSignal and NumberSignal. Represents an operation that replaces the value of the signal with a new value of type T. This operation is a special case of ValueOperation<T>.

ListInsertOperation<T>

Specific to ListSignal. Represents an operation that inserts a new value to the list signal at a given position. The value is of type T. This operation is a special case of ValueOperation<T>.

ListRemoveOperation<T>

Specific to ListSignal. Represents an operation that removes a value from the list signal at a given position. The value is of type T.

All of the above operations are implementations of SignalOperation interface.

Note
When a validator is defined for a signal instance, it’s called for all operations submitted to that signal. The validator should be able to handle all operations that the signal supports. In practice, this means that the SignalOperation parameter of the OperationValidator#validate method should be checked for the operation type, and cannot be cast to one of operations that a signal supports with checking.

Another important aspect that should be considered when calling the withOperationValidator method is that with each call, a new signal instance is created with the operation validator attached. The original signal instance remains unchanged. Therefore, the signal instance with the operation validator should be stored and returned from the service method that creates the signal instance.

The following example demonstrates this:

@BrowserCallable
@AnonymousAllowed
public class TodoService {

    public record TodoItem(String text, boolean done) {}

    private final ListSignal<TodoItem> notValidatedTodoItems = 1
                                            new ListSignal<>(TodoItem.class);

    private final ListSignal<TodoItem> validatedTodoItems = 2
         notValidatedTodoItems.withOperationValidator(operation -> {
            if (operation instanceof ListInsertOperation<TodoItem> insertOp) {
                return insertOp.value().text().length() <= 50 ?
                        ValidationResult.allow() :
                        ValidationResult.reject("No Long todo items!");
            }
            return ValidationResult.allow();
        });

    private final ListSignal<TodoItem> userTodoItems = 3
        validatedTodoItems.withOperationValidator(operation -> {
            if (operation instanceof ListRemoveOperation<TodoItem> removeOp) {
                return ValidationResult.reject("No Removal for users!");
            }
            return ValidationResult.allow();
        });

    private final ListSignal<TodoItem> guestTodoItems = 4
                    notValidatedTodoItems.asReadOnly();

    public ListSignal<TodoItem> todoItems() { 5
        return switch (currentUserRole()) {
            case ADMIN -> validatedTodoItems;
            case USER -> userTodoItems;
            case GUEST -> guestTodoItems;
        };
    }

    private enum Role {
        ADMIN, USER, GUEST
    }
    private Role currentUserRole() {
        // Implementation of the method that returns the current user's role
        // is skipped for brevity.
    }
}
  1. Create a list signal instance without an operation validator.

  2. Create a list signal instance with an operation validator that allows only todo items with a text length of 50 characters or fewer. The original signal instance remains unchanged. The new signal instance is stored in the validatedTodoItems field.

  3. Create a list signal instance with an operation validator that rejects all removal operations. The other two signal instances remain unchanged. The new signal instance is stored in the userTodoItems field.

  4. Create a read-only list signal instance from the original list signal instance. The other signal instances remain unchanged. The new signal instance is stored in the guestTodoItems field.

  5. Return the appropriate signal instance based on the current user’s role.

Important
Defining the operation validators for the signal instances in the service methods is highly discouraged, as it leads to the creation of new signal instances with each call. This can lead to memory leaks and unexpected behaviour. Instead, define the signal instances with the operation validators in the service class as fields, and return the appropriate signal instance based on the current user’s role in the service method.

Though it’s not demonstrated in the above example, a mixture of method security and operation validators can be used to achieve the desired behavior. For example, you can use method security to allow only certain roles to access the service method, and then use operation validators to allow only certain roles to update the signal instances.