Skip to main content

Overview

Transition observers let you react to state changes in the Presentum engine. Unlike event handlers (which respond to user actions like shown/dismissed/converted), transition observers respond to internal state changes. Each transition includes a comprehensive diff showing exactly what changed between states.
Use transition observers for: - Integrating with BLoC, Provider, Cubit - Conditional data fetching based on active items - Custom analytics for state flow - Debug logging of state changes

Basic observer

class StateChangeObserver
    implements IPresentumTransitionObserver<CampaignItem, AppSurface, CampaignVariant> {

  @override
  FutureOr<void> call(PresentumStateTransition transition) {
    final diff = transition.diff;

    log('State changed at ${transition.timestamp}');
    log('Activated: ${diff.itemsActivated.length}');
    log('Deactivated: ${diff.itemsDeactivated.length}');
  }
}

Transition structure

Every transition includes:
oldState
PresentumState$Immutable
required
State before the transition
newState
PresentumState$Immutable
required
State after the transition
timestamp
DateTime
required
When the transition occurred
diff
PresentumStateDiff
Lazily computed diff between old and new states

Diff information

The diff property provides convenient access to what changed:
final diff = transition.diff;

// Items that became active
for (final item in diff.itemsActivated) {
  print('${item.id} activated');
}

// Items that became inactive
for (final item in diff.itemsDeactivated) {
  print('${item.id} deactivated');
}

// Items added to queues
for (final item in diff.itemsQueued) {
  print('${item.id} queued');
}

// Items removed from queues
for (final item in diff.itemsDequeued) {
  print('${item.id} dequeued');
}

// New surfaces that appeared
for (final surface in diff.surfacesAdded) {
  print('Surface added: $surface');
}

// Surfaces that were removed
for (final surface in diff.surfacesRemoved) {
  print('Surface removed: $surface');
}
For detailed change information including which surface each change occurred on, use pattern matching:
// Pattern matching for granular changes
for (final change in diff.changes) {
  change.map(
    activated: (c) => print('${c.item.id} activated on ${c.surface}'),
    deactivated: (c) => print('${c.item.id} deactivated from ${c.surface}'),
    queued: (c) => print('${c.item.id} queued on ${c.surface}'),
    dequeued: (c) => print('${c.item.id} dequeued from ${c.surface}'),
  );
}

// Or handle only specific change types
for (final change in diff.changes) {
  change.maybeMap(
    orElse: () {},
    activated: (c) {
      print('Activated: ${c.item.id} on ${c.surface}');
      if (c.previousActive != null) {
        print('  (replaced ${c.previousActive!.id})');
      }
    },
  );
}

// Quick type checks
if (change.isActivated) {
  print('This is an activation change');
}

Integrating with BLoC

Fire events to your business logic layer using pattern matching:
class BlocTransitionObserver
    implements IPresentumTransitionObserver<CampaignItem, AppSurface, CampaignVariant> {
  BlocTransitionObserver(this.campaignBloc);

  final CampaignBloc campaignBloc;

  @override
  FutureOr<void> call(PresentumStateTransition transition) {
    // Use pattern matching for clean, exhaustive handling
    for (final change in transition.diff.changes) {
      change.map(
        activated: (c) => campaignBloc.add(
          PresentationActivated(
            itemId: c.item.id,
            surface: c.surface,
            previousId: c.previousActive?.id,
          ),
        ),
        deactivated: (c) => campaignBloc.add(
          PresentationDeactivated(
            itemId: c.item.id,
            surface: c.surface,
          ),
        ),
        queued: (c) => campaignBloc.add(
          PresentationQueued(
            itemId: c.item.id,
            surface: c.surface,
          ),
        ),
        dequeued: (c) => campaignBloc.add(
          PresentationDequeued(
            itemId: c.item.id,
            surface: c.surface,
          ),
        ),
      );
    }
  }
}
Or handle only specific change types with maybeMap:
@override
FutureOr<void> call(PresentumStateTransition transition) {
  for (final change in transition.diff.changes) {
    change.maybeMap(
      orElse: () {}, // Ignore other change types
      activated: (c) => campaignBloc.add(
        PresentationActivated(
          itemId: c.item.id,
          surface: c.surface,
        ),
      ),
      deactivated: (c) => campaignBloc.add(
        PresentationDeactivated(
          itemId: c.item.id,
          surface: c.surface,
        ),
      ),
    );
  }
}

Register observers

Add transition observers when creating Presentum:
presentum = Presentum(
  storage: storage,
  guards: guards,
  transitionObservers: [
    BlocTransitionObserver(campaignBloc),
    LoggingObserver(),
  ],
);

Conditional data fetching

Fetch additional data when specific presentations become active:
class DataFetchObserver
    implements IPresentumTransitionObserver<CampaignItem, AppSurface, CampaignVariant> {
  DataFetchObserver(this.dataService);

  final DataService dataService;

  @override
  FutureOr<void> call(PresentumStateTransition transition) async {
    // Simple case: just check activated items
    for (final item in transition.diff.itemsActivated) {
      if (item.metadata['type'] == 'product_promo') {
        final productId = item.metadata['product_id'] as String;
        await dataService.prefetchProductDetails(productId);
      }
    }
  }
}

Production example: Maintenance mode observer

This real-world observer from the example app manages app update checks based on maintenance mode:
class MaintenanceTransitionObserver
    implements IPresentumTransitionObserver<
      MaintenanceItem,
      AppSurface,
      AppVariant
    > {

  MaintenanceTransitionObserver(this.updatesStore);

  final ShorebirdUpdatesStore updatesStore;
  Timer? _updatesCheckTimer;
  int _checkInterval = 5; // Start with 5 seconds

  @override
  FutureOr<void> call(PresentumStateTransition transition) {
    final diff = transition.diff;

    // Check if maintenance surface was deactivated
    final maintenanceDeactivated = diff.itemsDeactivated.any(
      (change) => change.surface == AppSurface.maintenanceView,
    );

    if (maintenanceDeactivated) {
      _startUpdateChecks();
      return;
    }

    // Check if maintenance surface was activated
    final maintenanceActivated = diff.itemsActivated.any(
      (change) => change.surface == AppSurface.maintenanceView,
    );

    if (maintenanceActivated) {
      _stopUpdateChecks();
    }
  }

  ... callbacks implementations
}
Register it when creating the Presentum instance:
maintenancePresentum = Presentum(
  storage: storage,
  guards: guards,
  transitionObservers: [
    MaintenanceTransitionObserver(updatesStore),
  ],
);
See full source →
This observer implements complex business logic (progressive timer backoff, update status management) without any widget overhead. It’s pure side effects
  • exactly what transition observers are designed for.

Debug logging

Track state flow in development with pattern matching:
class DebugLoggingObserver
    implements IPresentumTransitionObserver<CampaignItem, AppSurface, CampaignVariant> {

  @override
  FutureOr<void> call(PresentumStateTransition transition) {
    final diff = transition.diff;

    if (diff.isEmpty) return;

    log('═' * 50);
    log('State transition at ${transition.timestamp}');
    log('─' * 50);

    // Use pattern matching for detailed logging
    for (final change in diff.changes) {
      final emoji = change.map(
        activated: (_) => '✓',
        deactivated: (_) => '✗',
        queued: (_) => '⋯',
        dequeued: (_) => '↺',
      );

      final action = change.map(
        activated: (c) => 'activated${c.previousActive != null ? " (replaced ${c.previousActive!.id})" : ""}',
        deactivated: (_) => 'deactivated',
        queued: (_) => 'queued',
        dequeued: (_) => 'dequeued',
      );

      log('$emoji [${change.surface}] ${change.item.id} $action');
    }

    log('─' * 50);
    log('Summary: ${diff.itemsActivated.length} activated, '
        '${diff.itemsDeactivated.length} deactivated, '
        '${diff.itemsQueued.length} queued, '
        '${diff.itemsDequeued.length} dequeued');
    log('═' * 50);
  }
}
Simpler version using convenience getters:
@override
FutureOr<void> call(PresentumStateTransition transition) {
  for (final change in transition.diff.changes) {
    if (change.isActivated) {
      log('✓ Activated: ${change.item.id} on ${change.surface}');
    } else if (change.isDeactivated) {
      log('✗ Deactivated: ${change.item.id} from ${change.surface}');
    }
  }
}

Transition observers vs Surface observers

Use transition observers for side effects and business logic: - Analytics tracking - API calls or data fetching - BLoC/Provider/Cubit integration - Logging and monitoring - Starting/stopping timers - Any logic that doesn’t render UI Key indicator: No widget rendering, pure logic
Use surface observers for UI rendering: - Showing/hiding snackbars or banners - Triggering widget animations - Rendering overlays or floating UI - Any visual element that responds to state Key indicator: Your code renders or manages widgets
If your observer would be a widget with build() returning just widget.child, use a transition observer instead. You’re adding unnecessary widget overhead for non-UI concerns.

Important notes

Do NOT call setState or transaction inside transition observers.This creates circular dependencies and infinite loops. Instead, dispatch events to your business logic layer (BLoC, Provider) which can then coordinate state changes through the public API.
// ❌ Wrong - causes infinite loop
@override
FutureOr<void> call(PresentumStateTransition transition) {
  presentum.setState((state) => ...); // Don't do this!
}

// ✅ Correct - dispatch to business logic
@override
FutureOr<void> call(PresentumStateTransition transition) {
  bloc.add(StateChanged(transition)); // Business logic decides what to do
}
Transition observers run after guards approve but before listeners are notified. They’re synchronous in the state change flow.If an observer throws, the state transition continues. Other observers still run.

Next steps