Skip to main content

Introducing Rollback Scopes: transactional state management made easy

ยท 8 min read
Fabio Fognani
random nerd

It's time to announce a powerful new addition to use-less-react that brings easy transactional semantics to your state management! Starting with version 0.13.0, you can now wrap state mutations in logic blocks that automatically rollback on failure, or explicitly rollback via an injected callback.

Meet createRollbackScope โ€” a utility that makes complex state operations safe and predictable.

The Problem: All-or-Nothing State Changesโ€‹

Have you ever needed to perform multiple state mutations that should either all succeed or all fail?

One possible scenario is Optimistic updates: local state (UI) updates immediately, but the API call fails, so you must rollback the change you did optimistically.

Without transactional support, you'd need to manually track the original state and implement rollback logic yourself. Error-prone and tedious, especially if many reactive properties are involved.

Why Rollback Scopes Instead of Transactionsโ€‹

The choice to name this feature createRollbackScope instead of createTransaction is a deliberate one, rooted in the semantics of state management.

While createRollbackScope offers two key features of transactions, being

  1. atomicity of outcome (all changes are either applied or reverted)
  2. recovery on failure

it does not provide the third pillar: isolation.

In traditional database transactions, changes are buffered and applied to the database instance only at the moment of commit.

Conversely, use-less-react applies (partial) state changes to the Originator instance immediately as they occur within the scope.

Therefore, if external code were to read the Originator's state during the execution of the scope, getting around use-less-react's API, it would observe the intermediate, uncommitted values.

The term RollbackScope accurately reflects the primary functionality: defining an execution block where the ability to revert to a previous state is guaranteed, without implying that the ongoing changes are hidden from external observation until the scope concludes.

We'll delve deeper into this specific topic at the end of this post: for now, just know that if you use use-less-react's API correctly this is actually not going to happen.

Now let's discover our brand new createRollbackScope and see how it actually works!

Transactional State with createRollbackScopeโ€‹

createRollbackScope wraps any BaseOriginator or DiffOriginator instance and provides a begin() method that executes your operations within a transactional boundary:

import { createRollbackScope, rollback } from '@dxbox/use-less-react/classes';

const scope = createRollbackScope(myOriginator)
.withBatchedNotifications(); // we'll see this later

await scope.begin(async () => {
myOriginator.value = 100;
myOriginator.status = 'processing';

const result = await apiCall();

if (!result.success) {
rollback(); // Revert all changes! (manual call)
}

if (someCondition) {
throw Error("...") // Revert all changes! (auto)
}
});

If rollback() is called or any exception is thrown, all state changes made within the callback are automatically reverted.

How It Worksโ€‹

Under the hood, createRollbackScope leverages the Memento Pattern infrastructure already present in use-less-react. It just provides syntactic sugar for making it easier and quicker to integrate.

  1. Before execution: A memento (state snapshot) is captured
  2. During execution: Notifications are handled based on the configured mode
    • .withBatchedNotifications(): All notifications are batched (no intermediate UI updates)
    • .withStandardNotifications(): Notifications are sent immediately as changes occur
  3. On success: If using batched notifications, notifications are sent now
  4. On rollback/error: State is restored to the snapshot. If an uncaught exception caused the rollback, the error is propagated

Two Ways to Trigger a Rollbackโ€‹

1. Explicit Rollbackโ€‹

Call the rollback() helper when you detect a condition that should abort:

import { createRollbackScope, rollback } from '@dxbox/use-less-react/classes';

const scope = createRollbackScope(order)
.withBatchedNotifications();

await scope.begin(async () => {
order.status = 'confirmed';
order.confirmedAt = new Date();

const payment = await processPayment(order.total);

if (payment.declined) {
rollback(); // User sees order as it was at the beginning
}
});

2. Automatic Rollback on Exceptionsโ€‹

Any exception thrown within the callback triggers an automatic rollback:

await scope.begin(async () => {
account.balance -= 500;

// If this throws, balance is automatically restored
await externalService.transfer(500);
});

Nested Rollback Scopesโ€‹

createRollbackScope supports nested rollback scopes with intuitive semantics:

// outer scope
await scope.begin(async () => {
parent.value = 'outer change';

// inner scope
await scope.begin(async (rollback) => {
child.value = 'inner change';
rollback(); // Only inner transaction rolls back
});

parent.status = 'completed';
}); // Outer commits successfully

Key behavior:

  • Inner rollbacks don't affect outer scope
  • Outer rollbacks revert everything, including committed inner scopes

Mixed Nested Rollback Scopesโ€‹

Nesting different scopes is possible:

// outer scope
const outerScope = createRollbackScope(originator1)
.withStandardNotifications();
const innerScope = createRollbackScope(originator2)
.withStandardNotifications();

await outerScope.begin(async (outerRollback) => {
originator1.value = 'outer change';

await innerScope.begin(async (innerRollback) => {
originator2.value = 'inner change';
throw new Error("some error"); // unhandled error rollbacks innerScope
});
// unhandled error from "await" rollbacks outerScope
});

Key behavior:

  • calls to innerRollback don't affect outer scope
  • calls to outerRollback don't affect inner scope
  • Inner exceptions rollback outer scope, if uncaught

A Basic Example: UserOriginatorโ€‹

First, you define a class extending BaseOriginator or DiffOriginator. For this basic example we will use the former. This UserOriginator holds data about a user and defines two trivial methods getMemento and restoreMemento:

class UserOriginator extends BaseOriginator<UserState> {
constructor(
public username: string = ""
public birthDate: Date | null,
){
this.makeReactiveProperties("username", "birthDate");
}

override getMemento() {
return {
username: this.username,
birthDate: this.birthDate,
};
}

override restoreMemento(memento: UserState) {
this.username = memento.username;
this.birthDate = memento.birthDate;
}
}

From now on, you can use createRollbackScope on UserOriginator. For example, we will use it in a ViewModel:

class UserViewModel extends PubSub {
public error: string | null = null;
public isLoading: boolean = false;

constructor(public userOriginator: UserOriginator) {
super();
this.makeReactiveProperties("error", "isLoading")
}

async updateUsername(newUsername: string) {
this.isLoading = true;

const scope = createRollbackScope(this.userOriginator)
// standard notification flow (immediate) for optimistic updates
.withStandardNotifications();

await scope.begin(async () => {
// let UI immediately receive updated value
this.userOriginator.username = newUsername;

try {
await this.commandBus.dispatch(
new UpdateUserCommand({
username: newUsername,
})
);
} catch (err) {
// i.e. invalid username
this.error = err instanceof Error ? error.message : String(err);
rollback();
} finally {
this.isLoading = false;
}
});
}
}

Getting Startedโ€‹

  1. Update to v0.13.0:

    npm install @dxbox/use-less-react@0.13.0
  2. Import the utilities:

    import { createRollbackScope } from '@dxbox/use-less-react/classes';
  3. Create a scope and begin rollback scopes:

    const scope = createRollbackScope(myOriginator)
    .withBatchedNotifications();

    await scope.begin(async () => {
    // Your transactional operations here
    });

Summaryโ€‹

createRollbackScope brings database-style transaction semantics to your client-side state management:

  • Atomic operations โ€” all or nothing
  • Automatic rollback โ€” on error or explicit call
  • Nested rollback scopes โ€” compose complex workflows
  • Batched notifications โ€” optimal React performance
  • Type-safe โ€” full TypeScript support

For complete API details, check out the createRollbackScope documentation.


With createRollbackScope, use-less-react continues its mission of bringing robust, enterprise-grade patterns to React applications while keeping the developer experience simple and intuitive!

A Detailed Note on Intermediate State Managementโ€‹

As mentioned above, it is technically possible to access intermediate state during the execution of a Rollback Scope, but this isn't going to happen if you rely on use-less-react's API (the useReactiveInstance hook and the onNotify method, both based on PubSub's subscribe method).

Only two edge cases exist, where this happens:

  1. a component directly reading the Originator's state with a setTimeout or setInterval
  2. a class directly reading the Originator's state with a setTimeout or setInterval

Case #1: Components and Polling (Anti-Pattern)โ€‹

A React Component should never read a class property inside a setTimeout or setInterval, but it should rely only on useReactiveInstance, which guarantees reactivity without inefficient polling or race conditions. This design enforces the standard declarative data flow between the View and the ViewModel. Case closed.

Case #2: Classes and Asynchronous Accessโ€‹

If a plain TypeScript/JavaScript class instance needs to interact with an Originator's state asynchronously, it must follow event-driven or reactive patterns, avoiding direct polling (setInterval).

This scenario can be broken down into three sub-cases, all of which are solved by adhering to the established reactive/event-driven API:

2a. The Need for Reactivity (Real-Time Updates)โ€‹

Problem: The class needs to react every time the Originator's stable state changes.

Solution: Use the PubSub's onNotify method (which underpins subscribe). This approach guarantees that the class is notified only when the Rollback Scope has successfully committed and the state is final and consistent.

2b. The Need for an Action Delay (Scheduled Logic)โ€‹

Problem: The class needs to execute an action (e.g., read the Originator's state) only after a delay (e.g., 5 seconds), and only if the Originator's state is stable.

Solution: This is a scheduling problem, not a synchronization problem.

The class subscribes to a Domain Event (via the EventBus) that is guaranteed to be emitted after the Rollback Scope is complete.

Upon receiving this event, the class uses setTimeout to introduce the delay.

When the setTimeout callback executes, the Originator is guaranteed to be in its final, stable state, making the subsequent read safe.

The key is that setTimeout is used for scheduling, not for polling.

2c. The Idea of Using Locks (Mutex/Semaphore)โ€‹

Problem: A developer considers using a Mutex or Semaphore to enforce mutual exclusion on the Originator during the transaction.

Why to Avoid: While technically possible, implementing a lock mechanism at this level is considered an anti-pattern in a reactive architecture.

Complexity: It introduces low-level imperative synchronization, making the code more complex, prone to deadlocks, and harder to debug.

Contradiction: It defeats the purpose of the reactive model, which is designed to prevent these concurrency issues by signaling state changes only when they are safe to read.

Final Wordsโ€‹

use-less-react's architecture (specifically the combination of Rollback Scopes and reactive notifications) is purposefully built to manage state integrity and provide the necessary isolation. By relying on the provided reactive API (hooks and PubSub), you can safely and correctly handle all asynchronous data needs without resorting to manual polling, synchronization locks, or risking the reading of intermediate state.