Skip to content

Sagas

Sagas are long-running, cancellable async workflows that orchestrate complex side effects in response to dispatched events. Inspired by Redux-Saga, Reacton's saga system gives you declarative control over concurrency, sequencing, forking, racing, and cancellation -- all while reading and writing reactive state through the store.

If middleware is a gatekeeper that inspects every write, a saga is a director that choreographs multi-step processes: login flows, checkout sequences, polling loops, WebSocket listeners, and any workflow where steps depend on external events or timers.

When to Use

  • You have multi-step async workflows (e.g., login -> fetch profile -> redirect).
  • You need concurrency control: only one request at a time (takeLeading), cancel stale requests (takeLatest), or handle every event independently (takeEvery).
  • You need to fork parallel tasks, race competing futures, or join on child tasks.
  • You want cancellation that cascades from parent to child automatically.
  • You need to coordinate multiple store writes across async boundaries.

When NOT to Use

For simple one-shot async operations (fetch data, update state), use an asyncReacton or a plain effect. Sagas shine when the workflow has multiple steps, branching logic, or concurrency requirements.

Basic Usage

dart
import 'package:reacton/reacton.dart';

// 1. Define your events
class FetchUserRequested {
  final int userId;
  FetchUserRequested(this.userId);
}

class FetchUserSucceeded {
  final User user;
  FetchUserSucceeded(this.user);
}

// 2. Define your state reactons
final userReacton = reacton<User?>(null, name: 'user');
final loadingReacton = reacton(false, name: 'loading');
final errorReacton = reacton<String?>(null, name: 'error');

// 3. Create the saga
final userSaga = saga<FetchUserRequested>(
  name: 'userSaga',
  builder: (on) {
    on.onLatest<FetchUserRequested>((ctx, event) async {
      ctx.put(loadingReacton, true);
      ctx.put(errorReacton, null);

      try {
        final user = await ctx.call(() => api.fetchUser(event.userId));
        ctx.put(userReacton, user);
      } catch (e) {
        ctx.put(errorReacton, e.toString());
      } finally {
        ctx.put(loadingReacton, false);
      }
    });
  },
);

// 4. Run and dispatch
final store = ReactonStore();
store.runSaga(userSaga);
store.dispatch(userSaga, FetchUserRequested(42));

API Reference

saga<E>() -- Creating a Saga

dart
Saga<E> saga<E>({
  String? name,
  required void Function(SagaBuilder<E> on) builder,
})
ParameterTypeDescription
nameString?Debug name for logging (defaults to 'saga<E>')
buildervoid Function(SagaBuilder<E> on)Registration callback that receives a SagaBuilder for declaring event handlers

Returns a Saga<E> object. The saga does not run until attached to a store via store.runSaga().

SagaBuilder<E> -- The Handler DSL

The builder callback receives a SagaBuilder<E> with four registration methods. Each method pairs an event subtype S extends E with a concurrency strategy.

MethodStrategyBehavior
on<S>(handler)takeOnceHandle the first matching event, then stop listening
onEvery<S>(handler)takeEverySpawn a new concurrent handler for every event
onLatest<S>(handler)takeLatestCancel any in-flight handler when a new event arrives
onLeading<S>(handler)takeLeadingIgnore new events while a handler is already running

All handler functions share the same signature:

dart
Future<void> Function(SagaContext ctx, S event)

HandlerStrategy Enum

dart
enum HandlerStrategy {
  takeOnce,     // Handle once, then unregister
  takeEvery,    // Handle every event concurrently
  takeLatest,   // Cancel previous, run latest only
  takeLeading,  // Ignore new events while one is running
}
mermaid
graph TD
    E1["Event 1"] --> H1["Handler 1"]
    E2["Event 2"] --> H2["Handler 2"]
    E3["Event 3"] --> H3["Handler 3"]

    subgraph "takeEvery"
        H1
        H2
        H3
    end

    style H1 fill:#4CAF50,color:#fff
    style H2 fill:#4CAF50,color:#fff
    style H3 fill:#4CAF50,color:#fff
mermaid
graph TD
    E1["Event 1"] --> H1["Handler 1 (cancelled)"]
    E2["Event 2"] --> H2["Handler 2 (cancelled)"]
    E3["Event 3"] --> H3["Handler 3 (active)"]

    subgraph "takeLatest"
        H1
        H2
        H3
    end

    style H1 fill:#f44336,color:#fff
    style H2 fill:#f44336,color:#fff
    style H3 fill:#4CAF50,color:#fff

SagaContext -- The Effect API

Every handler receives a SagaContext that provides an imperative API for issuing effects. All methods are cancellation-aware and throw SagaCancelledException if the enclosing task is cancelled.

MethodSignatureDescription
take<E>()Future<E> take<E>({bool Function(E)? predicate})Wait for the next event of type E
put<T>()void put<T>(WritableReacton<T> reacton, T value)Write a value to the store
call<T>()Future<T> call<T>(Future<T> Function() fn)Execute an async function with cancellation awareness
fork()SagaTask fork(SagaHandler handler, {String? name})Fork a child saga to run concurrently
join()Future<void> join(SagaTask task)Wait for a forked task to complete
cancelTask()void cancelTask(SagaTask task)Cancel a running task (cascades to children)
delay()Future<void> delay(Duration duration)Suspend for a duration (cancellable)
race<T>()Future<Map<String, T>> race<T>(Map<String, Future<T> Function()> effects)Race multiple futures; first to complete wins
all<T>()Future<List<T>> all<T>(List<Future<T> Function()> effects)Run multiple futures in parallel; wait for all
select<T>()T select<T>(ReactonBase<T> reacton)Read current reacton value synchronously (no subscription)

SagaTask -- Running Task Lifecycle

Every runSaga() call and every fork() produces a SagaTask.

dart
class SagaTask {
  int get id;                  // Unique numeric identifier
  String? get name;            // Optional debug name
  bool get isRunning;          // Currently executing
  bool get isCompleted;        // Finished successfully
  bool get isCancelled;        // Was cancelled
  Future<void> get result;     // Completes when task finishes
  void cancel();               // Cancel this task and all children
}
Property / MethodTypeDescription
idintAuto-incrementing unique identifier
nameString?Debug name
isRunningboolWhether the task is currently executing
isCompletedboolWhether the task completed successfully
isCancelledboolWhether the task was cancelled
resultFuture<void>Future that completes when the task finishes
cancel()voidCancel this task, cascading to all child tasks

SagaEffect<T> -- Sealed Effect Hierarchy

The saga system uses a sealed class hierarchy to describe effects declaratively. User code interacts with these through the SagaContext methods above, but the types are listed here for completeness:

EffectResult TypeDescription
Take<E>EWait for a matching event
Put<T>voidWrite to a reacton
Call<T>TExecute an async function
ForkSagaTaskFork a child handler
JoindynamicWait for a task to finish
CancelvoidCancel a task
DelayvoidSuspend for a duration
Race<T>Map<String, T>Race multiple futures
All<T>List<T>Run futures in parallel
Select<T>TRead a reacton value

Store Extensions

MethodSignatureDescription
runSaga<E>()SagaTask runSaga<E>(Saga<E> saga)Start a saga and begin listening for events. Throws StateError if already running.
dispatch<E>()void dispatch<E>(Saga<E> saga, E event)Dispatch an event to a running saga. Throws StateError if saga is not running.
cancelSaga<E>()void cancelSaga<E>(Saga<E> saga)Cancel a running saga and clean up all resources.
cancelAllSagas()void cancelAllSagas()Cancel all running sagas on this store.

How It Works

mermaid
sequenceDiagram
    participant App as Application
    participant Store as ReactonStore
    participant Runtime as Saga Runtime
    participant Bus as Event Bus
    participant Handler as Handler Task

    App->>Store: store.runSaga(mySaga)
    Store->>Runtime: Create runtime, register handlers
    Runtime->>Bus: Subscribe to event bus

    App->>Store: store.dispatch(mySaga, event)
    Store->>Bus: eventBus.add(event)
    Bus->>Runtime: event received
    Runtime->>Runtime: Match event type to handler slot
    Runtime->>Runtime: Apply concurrency strategy

    alt takeLatest
        Runtime->>Handler: Cancel previous task
    end

    Runtime->>Handler: Spawn new handler task
    Handler->>Store: ctx.put(loadingReacton, true)
    Handler->>Handler: ctx.call(() => api.fetch(...))
    Handler->>Store: ctx.put(dataReacton, result)
    Handler->>Runtime: Task completed

    App->>Store: store.cancelSaga(mySaga)
    Store->>Runtime: Cancel root task (cascades to children)
    Runtime->>Bus: Unsubscribe from event bus

Internal Architecture

  1. Saga Runtime: An Expando-based runtime is lazily created per store. It manages a shared event bus (broadcast StreamController) and tracks all running saga entries.

  2. Event Bus: All events dispatched via store.dispatch() flow through a single broadcast stream. Each saga entry subscribes and routes matching events to handler slots.

  3. Handler Slots: Each SagaRegistration gets a _HandlerSlot that manages concurrency state. The slot tracks the active task and decides whether to spawn, cancel, or ignore based on the HandlerStrategy.

  4. Task Tree: Every fork() creates a child SagaTask linked to its parent. Cancellation cascades down the tree automatically.

  5. Cancellation: All SagaContext methods check task.isCancelled before and after work. When a task is cancelled, pending take(), call(), delay(), and join() calls throw SagaCancelledException, which the runtime catches and uses to mark the task as cancelled.

Complete Example: Checkout Flow Saga

dart
import 'package:reacton/reacton.dart';

// ── Events ────────────────────────────────────────────────────────
sealed class CheckoutEvent {}

class CheckoutStarted extends CheckoutEvent {
  final List<CartItem> items;
  final PaymentMethod payment;
  CheckoutStarted({required this.items, required this.payment});
}

class CheckoutCancelled extends CheckoutEvent {}

// ── State ─────────────────────────────────────────────────────────
enum CheckoutStatus { idle, validating, processing, confirming, success, failed }

final checkoutStatusReacton = reacton(CheckoutStatus.idle, name: 'checkoutStatus');
final checkoutErrorReacton = reacton<String?>(null, name: 'checkoutError');
final orderIdReacton = reacton<String?>(null, name: 'orderId');

// ── Saga ──────────────────────────────────────────────────────────
final checkoutSaga = saga<CheckoutEvent>(
  name: 'checkout',
  builder: (on) {
    // Use onLeading to prevent duplicate submissions
    on.onLeading<CheckoutStarted>((ctx, event) async {
      ctx.put(checkoutStatusReacton, CheckoutStatus.validating);
      ctx.put(checkoutErrorReacton, null);

      try {
        // Step 1: Validate inventory
        final available = await ctx.call(
          () => inventoryApi.validateItems(event.items),
        );
        if (!available) {
          ctx.put(checkoutStatusReacton, CheckoutStatus.failed);
          ctx.put(checkoutErrorReacton, 'Some items are out of stock');
          return;
        }

        // Step 2: Process payment (with timeout via race)
        ctx.put(checkoutStatusReacton, CheckoutStatus.processing);

        final result = await ctx.race<dynamic>({
          'payment': () => paymentApi.charge(event.payment, event.items),
          'timeout': () => Future.delayed(const Duration(seconds: 30)),
        });

        if (result.containsKey('timeout')) {
          ctx.put(checkoutStatusReacton, CheckoutStatus.failed);
          ctx.put(checkoutErrorReacton, 'Payment timed out');
          return;
        }

        final paymentId = result['payment'] as String;

        // Step 3: Create order (fork email confirmation in parallel)
        ctx.put(checkoutStatusReacton, CheckoutStatus.confirming);

        final orderId = await ctx.call(
          () => orderApi.createOrder(event.items, paymentId),
        );
        ctx.put(orderIdReacton, orderId);

        // Fork: send confirmation email without blocking
        ctx.fork((emailCtx, [_]) async {
          await emailCtx.call(
            () => emailApi.sendConfirmation(orderId),
          );
        }, name: 'sendConfirmationEmail');

        ctx.put(checkoutStatusReacton, CheckoutStatus.success);
      } on SagaCancelledException {
        // User cancelled the checkout
        ctx.put(checkoutStatusReacton, CheckoutStatus.idle);
        rethrow; // let the runtime handle task cleanup
      } catch (e) {
        ctx.put(checkoutStatusReacton, CheckoutStatus.failed);
        ctx.put(checkoutErrorReacton, e.toString());
      }
    });

    // Handle cancellation events
    on.onEvery<CheckoutCancelled>((ctx, event) async {
      ctx.put(checkoutStatusReacton, CheckoutStatus.idle);
      ctx.put(checkoutErrorReacton, null);
    });
  },
);

// ── Run ───────────────────────────────────────────────────────────
void main() async {
  final store = ReactonStore();

  // Start the saga
  final task = store.runSaga(checkoutSaga);

  // Subscribe to status changes
  store.subscribe(checkoutStatusReacton, (status) {
    print('Checkout status: $status');
  });

  // Dispatch checkout
  store.dispatch(
    checkoutSaga,
    CheckoutStarted(
      items: [CartItem('widget', 2)],
      payment: PaymentMethod.card('4242...'),
    ),
  );

  // Later: cancel if needed
  // store.dispatch(checkoutSaga, CheckoutCancelled());

  // Teardown
  // store.cancelSaga(checkoutSaga);
}

Advanced Patterns

Polling with Cancellation

dart
final pollingSaga = saga<PollEvent>(
  name: 'polling',
  builder: (on) {
    on.on<StartPolling>((ctx, event) async {
      while (true) {
        try {
          final data = await ctx.call(() => api.fetchLatest());
          ctx.put(dataReacton, data);
          await ctx.delay(const Duration(seconds: 5));
        } on SagaCancelledException {
          rethrow; // exit the loop cleanly
        }
      }
    });
  },
);

Fork and Join

dart
on.onEvery<BatchProcess>((ctx, event) async {
  // Fork multiple tasks
  final tasks = <SagaTask>[];
  for (final item in event.items) {
    tasks.add(ctx.fork((forkCtx, [_]) async {
      await forkCtx.call(() => processItem(item));
    }, name: 'process_${item.id}'));
  }

  // Wait for all to complete
  for (final task in tasks) {
    await ctx.join(task);
  }

  ctx.put(statusReacton, 'All items processed');
});

Race: Timeout or Result

dart
final result = await ctx.race<String>({
  'data': () => api.fetchSlowEndpoint(),
  'timeout': () => Future.delayed(
    const Duration(seconds: 10),
    () => 'timed_out',
  ),
});

if (result.containsKey('timeout')) {
  ctx.put(errorReacton, 'Request timed out');
} else {
  ctx.put(dataReacton, result['data']!);
}

Common Mistakes

Running the Same Saga Twice

runSaga() throws a StateError if the saga is already running. Always cancel first if you need to restart.

dart
// BAD: throws StateError
store.runSaga(mySaga);
store.runSaga(mySaga); // StateError!

// GOOD: cancel first
store.cancelSaga(mySaga);
store.runSaga(mySaga);

Catching SagaCancelledException

Do not swallow SagaCancelledException inside your handler. The runtime uses it for flow control. If you catch it, always rethrow.

dart
// BAD: swallows cancellation
try {
  await ctx.call(() => api.fetch());
} catch (e) {
  // This catches SagaCancelledException too!
  ctx.put(errorReacton, e.toString());
}

// GOOD: handle cancellation separately
try {
  await ctx.call(() => api.fetch());
} on SagaCancelledException {
  rethrow; // let the runtime handle it
} catch (e) {
  ctx.put(errorReacton, e.toString());
}

Dispatching to a Stopped Saga

dispatch() throws a StateError if the saga is not running. Always start the saga with runSaga() before dispatching events.

dart
// BAD: saga not started
store.dispatch(mySaga, MyEvent()); // StateError!

// GOOD: start first
store.runSaga(mySaga);
store.dispatch(mySaga, MyEvent());

Remember to Clean Up

Always cancel sagas when the store is being disposed. Use cancelAllSagas() in your teardown logic.

dart
// In your app's dispose/shutdown:
store.cancelAllSagas();
store.dispose();

What's Next

Released under the MIT License.