Transactions
- Basic Transactions
- Transaction Scope
- Transaction Benefits
- Verification Methods
- Operation Results
- Bypassing Transactions
- Best Practices
Transactions allow grouping multiple signal operations into a single atomic unit. All operations in a transaction either succeed or fail together, ensuring data consistency. A transaction covers a single shared signal and its child signals, as described in Transaction Scope.
Basic Transactions
Use Signal.runInTransaction() to execute multiple signal operations atomically:
Source code
Java
SharedMapSignal<String> person = new SharedMapSignal<>(String.class);
Signal.runInTransaction(() -> {
// All operations here will be committed atomically
person.put("firstName", "John");
person.put("lastName", "Doe");
person.put("email", "john.doe@example.com");
});If any operation fails, none of the changes are applied. Observers see either the complete update or nothing at all - never a partial update.
Transaction Scope
A transaction can read and update one shared signal and its child signals: the entries of a SharedListSignal or SharedMapSignal, or the children of a SharedNodeSignal. Each shared signal created with a public constructor is an independent signal tree. It’s committed independently, and in a cluster it may be owned by a different node than another shared signal. Reading or updating a second independent shared signal in the same transaction throws an IllegalStateException:
Source code
Java
SharedValueSignal<String> firstName = new SharedValueSignal<>("");
SharedValueSignal<String> lastName = new SharedValueSignal<>("");
// Throws: firstName and lastName are independent shared signals
Signal.runInTransaction(() -> {
firstName.set("John");
lastName.set("Doe");
});Values that must change together need to live in a single shared signal. Use one of these:
-
entries of one
SharedMapSignalorSharedListSignal, when the values have the same type, as in the examples on this page; -
a
SharedValueSignalholding a record, updated as a whole withupdate(), when the values always change together; -
a
SharedNodeSignalroot with a child for each part, when the values have different shapes. See SharedNodeSignal.
Reading a computed signal counts as reading the signals it depends on, so those also need to be within the shared signal that the transaction covers. Local signals, such as ValueSignal and ListSignal, can’t be read or updated inside runInTransaction(). See Local Signals.
Transaction Benefits
Transactions provide several guarantees:
- Atomicity
-
All changes in a transaction succeed or fail together
- Consistency
-
No observer sees partial updates
- Isolation
-
Changes are not visible until the transaction commits
Verification Methods
Transactions support verification methods that ensure certain conditions are met before committing changes.
Verifying Expected Values
The verifyValue() method verifies that a signal has a specific expected value:
Source code
Java
SharedMapSignal<String> order = new SharedMapSignal<>(String.class);
order.put("status", "pending");
Signal.runInTransaction(() -> {
SharedValueSignal<String> status = order.get().get("status");
// Transaction fails if current status is not "pending"
status.verifyValue("pending");
// Only executed if verification passes
status.set("processing");
order.put("processedBy", currentUser);
});Verifying List Item Position
The verifyPosition() method verifies that an item is at a specific position in a list signal:
Source code
Java
Signal.runInTransaction(() -> {
SharedValueSignal<Todo> firstItem = todoList.get().get(0);
// Verify the item we want to update is still first
todoList.verifyPosition(firstItem, ListPosition.first());
// Update only if verification passes
firstItem.set(new Todo("Updated first item", false));
});Verifying Child Exists
The verifyChild() method on SharedListSignal verifies that a child signal is still part of the list:
Source code
Java
Signal.runInTransaction(() -> {
SharedValueSignal<Todo> itemSignal = todoList.get().get(0);
// Verify the child signal still exists in the list before updating
todoList.verifyChild(itemSignal);
itemSignal.set(updatedValue);
});Operation Results
Signal operations return result objects that provide information about the operation and allow chaining.
SignalOperation
The base interface for all signal operations. Operations return a CompletableFuture that resolves when the operation completes:
Source code
Java
SignalOperation<String> op = stringSignal.set("new value");
// Get the result as a CompletableFuture
CompletableFuture<SignalOperation.ResultOrError<String>> result = op.result();
// Check if the operation has completed
if (result.isDone()) {
// Get the result (blocks if not yet complete)
SignalOperation.ResultOrError<String> resultOrError = result.get();
if (resultOrError.successful()) {
System.out.println("Operation completed successfully");
}
}CancelableOperation
Some operations that may retry (like update()) return a CancelableOperation that can be canceled:
Source code
Java
CancelableOperation<Integer> updateOp = counter.update(v -> v + 1);
// If needed, cancel the retry loop
updateOp.cancel();The update() operation uses a compare-and-set approach. If another change occurs concurrently, the operation retries with the new value. Calling cancel() stops this retry loop.
|
Note
|
A call to cancel() may not always be effective, as a succeeding operation might already be on its way to the server.
|
InsertOperation
List insert operations return an InsertOperation that provides immediate access to the inserted signal:
Source code
Java
SharedListSignal<Todo> todoList = new SharedListSignal<>(Todo.class);
InsertOperation<SharedValueSignal<Todo>> op = todoList.insertLast(new Todo("New task", false));
// Get the signal for the inserted item immediately
SharedValueSignal<Todo> insertedSignal = op.signal();
// You can start working with the signal right away
insertedSignal.update(todo -> new Todo(todo.text(), true));This is useful when you need to reference the newly inserted item immediately after insertion.
TransactionOperation
When running transactions, Signal.runInTransaction() returns a TransactionOperation that tracks the entire transaction. TransactionOperation extends SignalOperation<Void>, so you can check the transaction status via the inherited result() method:
Source code
Java
TransactionOperation<Void> txOp = Signal.runInTransaction(() -> {
person.put("firstName", "John");
person.put("lastName", "Doe");
});
// Check transaction status via the inherited result()
CompletableFuture<SignalOperation.ResultOrError<Void>> result = txOp.result();
if (result.isDone()) {
System.out.println("Transaction committed successfully");
}Transactions can also return a value. The returnValue() method provides direct access to the value returned by the transaction callback:
Source code
Java
TransactionOperation<String> txOp = Signal.runInTransaction(() -> {
statusSignal.verifyValue("pending");
statusSignal.set("confirmed");
return "Order confirmed";
});
String returnValue = txOp.returnValue(); // "Order confirmed"Bypassing Transactions
Effect and computed signal callbacks run inside a read-only transaction to prevent accidental changes. If you need to modify a signal from within an effect (and you’re certain there’s no risk of infinite loops), use runWithoutTransaction():
Source code
Java
Signal.effect(component, () -> {
String value = sourceSignal.get();
// WARNING: This might lead to infinite loops.
// Do this only if absolutely necessary.
Signal.runWithoutTransaction(() -> {
derivedSignal.set(transformValue(value));
});
});|
Warning
|
Using runWithoutTransaction() bypasses safety checks. Only use it when you’re certain the update won’t cause an infinite loop.
|
Best Practices
Keep Related Values in One Shared Signal
Values that should be updated together belong in the same shared signal, so they can be updated in one transaction. See Transaction Scope.
Source code
Java
// Good: Related values in one shared signal, updated in a transaction
Signal.runInTransaction(() -> {
order.put("status", "shipped");
order.put("shippedAt", Instant.now().toString());
order.put("trackingNumber", tracking);
});
// Avoid: Separate updates that others can observe one at a time
order.put("status", "shipped");
order.put("shippedAt", Instant.now().toString());
order.put("trackingNumber", tracking);Each separate update is committed on its own, so other users can see the order as shipped before it has a tracking number.
Use Verification for Conditional Updates
When updates depend on current state, use verification:
Source code
Java
// Assumes order already has "status" and "assignee" entries
Signal.runInTransaction(() -> {
SharedValueSignal<String> status = order.get().get("status");
SharedValueSignal<String> assignee = order.get().get("assignee");
// Ensure we're updating the expected state
status.verifyValue("pending");
assignee.verifyValue(expectedAssignee);
status.set("confirmed");
order.put("confirmedBy", currentUser);
});Prefer update() for Single-Signal Atomic Updates
For atomic updates based on current value, use update() instead of a transaction:
Source code
Java
// Preferred for single-signal updates
counter.update(v -> v + 1);
// Transaction is overkill for single updates
Signal.runInTransaction(() -> {
counter.set(counter.get() + 1);
});Keep Transactions Focused
Include only the operations that must succeed or fail together. If any operation in a transaction fails, all of them are rejected, so an unrelated update in the same transaction is lost along with the rest:
Source code
Java
// Good: Only the updates that belong together
Signal.runInTransaction(() -> {
SharedValueSignal<String> status = order.get().get("status");
status.verifyValue("pending");
status.set("confirmed");
order.put("confirmedAt", Instant.now().toString());
});
// Avoid: An unrelated update that is lost if the verification fails
Signal.runInTransaction(() -> {
SharedValueSignal<String> status = order.get().get("status");
status.verifyValue("pending");
status.set("confirmed");
order.put("confirmedAt", Instant.now().toString());
// Doesn't depend on the status, so don't make it conditional
order.put("lastViewedBy", currentUser);
});