davianspace_reactive

Production-grade reactive state management for Dart and Flutter. Signal-based reactivity with automatic dependency tracking, computed values, effects, batching, async state, reactive collections, deep DI integration, and Flutter widgets — conceptually inspired by SolidJS signals and MobX observables, expressed idiomatically in Dart.

Dart Flutter License: MIT pub package


Table of contents


Features

Feature Description
Reactive signals Mutable, read-only, freezable signals with custom equality
Computed values Lazy, cached derived state with automatic dependency tracking
Effects Side-effect runners with debounce, throttle, and auto-re-execution
Batching Coalesced notifications for bulk updates with nested batch support
Async state AsyncReactive and Resource with loading/data/error states
Reactive collections ReactiveList, ReactiveMap, ReactiveSet with full Dart collection APIs
Flutter widgets ReactiveBuilder, ReactiveSelector, ReactiveScope for granular UI rebuilds
DI integration Deep integration with davianspace_dependencyinjection
DevTools Debug logging, graph inspection, leak detection, performance reporting
Lifecycle hooks onDispose, onChange, lifecycle state tracking
Stream interop Bidirectional Stream ↔ Reactive conversion
Zero reflection No dart:mirrors, no code generation — full AOT and tree-shaking support

Installation

dependencies:
  davianspace_reactive: ^1.0.0
import 'package:davianspace_reactive/davianspace_reactive.dart';

Quick start

import 'package:davianspace_reactive/davianspace_reactive.dart';

void main() {
  // 1. Create reactive state
  final counter = Reactive<int>(0);

  // 2. Derive computed values
  final doubled = Computed<int>(() => counter.value * 2);

  // 3. Run side effects
  final fx = Effect(() => print('Doubled: ${doubled.value}'));
  // prints: Doubled: 0

  // 4. Update state — effect re-runs automatically
  counter.value = 5;
  // prints: Doubled: 10

  // 5. Batch updates — notifications fire once
  ReactiveBatch.run(() {
    counter.value = 10;
    counter.value = 20;
  });
  // prints: Doubled: 40 (only once)

  // 6. Clean up
  fx.dispose();
  doubled.dispose();
  counter.dispose();
}

Architecture

Core signals

Reactive<T> is the fundamental building block — a mutable container that notifies listeners when its value changes.

final name = Reactive<String>('Alice');

// Read (tracked in computed/effect contexts)
print(name.value); // 'Alice'

// Write
name.value = 'Bob'; // notifies listeners

// Peek without tracking
print(name.peek()); // 'Bob'

// Update with transform
name.update((current) => current.toUpperCase()); // 'BOB'

// Custom equality
final point = Reactive<Point>(
  Point(0, 0),
  equals: (a, b) => a.x == b.x && a.y == b.y,
);

// Freeze (no further mutations)
name.freeze();
name.value = 'Charlie'; // throws FrozenReactiveException

// Read-only view
final readOnly = name.readOnly;
// readOnly.value = 'X'; // compile error — no setter

// Snapshot
final snap = name.snapshot();
print(snap.value);     // current value
print(snap.timestamp); // when captured

Computed values

Computed<T> derives a value from other reactives. It is lazy (evaluated on first read), cached, and automatically re-evaluates when dependencies change.

final firstName = Reactive<String>('John');
final lastName = Reactive<String>('Doe');

final fullName = Computed<String>(
  () => '${firstName.value} ${lastName.value}',
);

print(fullName.value); // 'John Doe'

firstName.value = 'Jane';
print(fullName.value); // 'Jane Doe'

// Custom equality prevents unnecessary downstream updates
final rounded = Computed<int>(
  () => (rawValue.value * 100).round(),
  equals: (a, b) => a == b,
);

// Chain computed values
final greeting = Computed<String>(
  () => 'Hello, ${fullName.value}!',
);

Effects

Effect runs a side effect whenever its tracked dependencies change. It supports debounce and throttle.

// Auto-tracking effect
final counter = Reactive<int>(0);
final fx = Effect(() {
  print('Counter: ${counter.value}');
});

counter.value = 1; // prints: Counter: 1

// Debounced effect (waits for quiet period)
final search = Reactive<String>('');
final searchFx = Effect(
  () => fetchResults(search.value),
  debounce: Duration(milliseconds: 300),
);

// Throttled effect (max once per interval)
final scroll = Reactive<double>(0.0);
final scrollFx = Effect(
  () => updateHeader(scroll.value),
  throttle: Duration(milliseconds: 100),
);

// watch() convenience
final unsub = watch<int>(
  counter,
  (value) => print('Watched: $value'),
);

Batching

ReactiveBatch coalesces multiple updates into a single notification pass.

final a = Reactive<int>(1);
final b = Reactive<int>(2);
final sum = Computed<int>(() => a.value + b.value);

var notifyCount = 0;
sum.subscribe(() => notifyCount++);

// Without batching: 2 notifications
a.value = 10; // notifies
b.value = 20; // notifies

// With batching: 1 notification
ReactiveBatch.run(() {
  a.value = 100;
  b.value = 200;
});

// Nested batches coalesce to the outermost
ReactiveBatch.run(() {
  a.value = 1;
  ReactiveBatch.run(() {
    b.value = 2;
  }); // inner batch completes, but outermost holds
}); // all notifications fire once here

Dependency tracking

ReactiveContext provides stack-based dependency tracking used internally by Computed and Effect. You can also use it directly:

// Reading inside untracked() prevents dependency registration
final val = untracked(() => reactive.value);

Lifecycle

All reactive nodes support lifecycle hooks:

final counter = Reactive<int>(0);

// Fire when value changes
counter.onChange((newValue) => print('Changed: $newValue'));

// Fire when disposed
counter.onDispose(() => print('Disposed!'));

// Check state
print(counter.lifecycleState); // ReactiveLifecycleState.created
counter.value; // reading transitions to .active
counter.dispose(); // transitions to .disposed

Async state

AsyncReactive

AsyncReactive<T> wraps an async operation with reactive state tracking.

final user = AsyncReactive<User>(
  () => api.fetchUser(userId.value),
);

// Access state reactively
Effect(() {
  user.state.when(
    loading: () => print('Loading...'),
    data: (u) => print('User: ${u.name}'),
    error: (e, _) => print('Error: $e'),
  );
});

// Refresh
await user.refresh();

Resource

Resource<T> adds caching, invalidation, and stale-while-revalidate.

final profile = Resource<Profile>(
  fetcher: () => api.fetchProfile(),
  cacheDuration: Duration(minutes: 5),
  staleWhileRevalidate: true,
  key: 'user-profile',
);

// Check staleness
print(profile.isStale);

// Force refetch
await profile.fetch(force: true);

// Invalidate cache
profile.invalidate();

// Optimistic update
profile.setData(updatedProfile);

AsyncState

AsyncState<T> is a sealed class representing loading, data, or error:

final state = AsyncState<String>.data('Hello');

// Pattern matching
final result = state.when(
  loading: () => 'Loading...',
  data: (value) => 'Got: $value',
  error: (e, s) => 'Error: $e',
);

// Safe access
print(state.dataOrNull);  // 'Hello'
print(state.errorOrNull);  // null
print(state.isLoading);    // false

// Map
final mapped = state.map((v) => v.length); // AsyncState<int>.data(5)

Collections

All reactive collections implement the standard Dart collection interfaces and support batching, freezing, snapshots, and dependency tracking.

ReactiveList

final items = ReactiveList<String>(items: ['a', 'b', 'c']);

// Standard List operations (all trigger notifications)
items.add('d');
items.removeAt(0);
items[1] = 'z';

// Batch mutations
items.batch(() {
  items.add('x');
  items.add('y');
  items.add('z');
}); // single notification

// Freeze
items.freeze();
items.add('q'); // throws FrozenReactiveException

// Immutable snapshot
final snap = items.snapshot(); // List<String>

ReactiveMap

final settings = ReactiveMap<String, int>(entries: {'volume': 80});

settings['brightness'] = 100;
settings.remove('volume');

settings.batch(() {
  settings['a'] = 1;
  settings['b'] = 2;
});

ReactiveSet

final tags = ReactiveSet<String>(items: {'dart', 'flutter'});

tags.add('reactive');
tags.remove('dart');

tags.batch(() {
  tags.addAll(['a', 'b', 'c']);
});

Flutter widgets

ReactiveBuilder

Auto-tracking widget that rebuilds when any accessed reactive changes.

ReactiveBuilder(
  builder: (context) {
    // All reactives read here are auto-tracked
    return Text('${counter.value}: ${label.value}');
  },
)

// With explicit dependencies (optimization — skips auto-tracking)
ReactiveBuilder(
  dependencies: [counter, label],
  builder: (context) => Text('${counter.value}: ${label.value}'),
)

ReactiveSelector

Optimized widget that only rebuilds when the selected portion of a reactive changes.

ReactiveSelector<User, String>(
  reactive: userReactive,
  selector: (user) => user.name,
  builder: (context, name) => Text(name),
  equals: (a, b) => a == b, // optional custom equality
)

ReactiveScope

Auto-disposing scope that cleans up reactives and effects when the widget is removed from the tree.

ReactiveScope<Reactive<int>>(
  create: (scope) {
    final counter = Reactive<int>(0);
    scope.own(counter); // auto-disposed when widget is removed

    final doubled = Computed<int>(() => counter.value * 2);
    scope.own(doubled);

    final fx = Effect(() => print(counter.value));
    scope.ownEffect(fx);

    return counter;
  },
  builder: (context, counter) {
    return ReactiveBuilder(
      builder: (_) => Text('Count: ${counter.value}'),
    );
  },
)

DI integration

Deep integration with davianspace_dependencyinjection.

Registration extensions

final services = ServiceCollection()
  // Register reactive signals
  ..addReactiveSingleton<int>(() => Reactive<int>(0))
  ..addReactiveScoped<String>(() => Reactive<String>(''))
  ..addReactiveTransient<double>(() => Reactive<double>(0.0))

  // With ServiceProvider access
  ..addReactiveSingletonFactory<String>((sp) {
    final count = sp.getReactive<int>();
    return Reactive<String>('Count: ${count.peek()}');
  })

  // Computed values
  ..addComputedSingleton<String>((sp) {
    final count = sp.getReactive<int>();
    return Computed<String>(() => 'Value: ${count.value}');
  })

  // Effects
  ..addEffect((sp) {
    final count = sp.getReactive<int>();
    return Effect(() => print('Count changed: ${count.value}'));
  })

  // Async reactives
  ..addAsyncReactiveSingleton<User>(
    (sp) => AsyncReactive<User>(() => api.fetchUser()),
  )

  // Resources
  ..addResourceSingleton<Config>(
    (sp) => Resource<Config>(
      fetcher: () => api.fetchConfig(),
      cacheDuration: Duration(minutes: 10),
    ),
  )

  // Collections
  ..addReactiveListSingleton<String>(
    () => ReactiveList<String>(items: ['default']),
  )
  ..addReactiveMapSingleton<String, int>(
    () => ReactiveMap<String, int>(),
  )
  ..addReactiveSetSingleton<int>(
    () => ReactiveSet<int>(),
  );

final provider = services.buildServiceProvider();

Resolution extensions

// Typed resolution
final count = provider.getReactive<int>();
final computed = provider.getComputed<String>();
final asyncUser = provider.getAsyncReactive<User>();
final resource = provider.getResource<Config>();

// Null-safe resolution
final maybeCount = provider.tryGetReactive<int>(); // Reactive<int>?

DevTools

ReactiveDebugLogger

Ring-buffered event logger that integrates with ReactiveConfig:

final logger = ReactiveDebugLogger(maxEvents: 500);

// Install as debug callback
logger.install();

// Query events
final writes = logger.eventsOfType('write');
final counterEvents = logger.eventsForReactive('counter');

// Human-readable dump
final output = logger.dump();

// Clear
logger.clear();

GraphInspector

Inspects the live reactive dependency graph:

final inspector = GraphInspector();

// Register nodes
inspector.registerNode(counter);
inspector.registerNode(doubled);

// Inspect
final info = inspector.getNodeInfo(counter);
print(info?.listenerCount);

// Detect leaks (nodes with no listeners and no computed dependents)
final leaks = inspector.detectLeaks();

// Find disposed-but-still-registered nodes
final zombies = inspector.disposedNodes();

// Performance report
final report = inspector.getPerformanceReport();
print(report.totalNodes);
print(report.totalListenerCount);
print(report.potentialLeaks);

Extensions

Iterable/Collection conversion

// Convert iterables to reactive collections
final list = [1, 2, 3].toReactiveList();
final map = {'a': 1}.toReactiveMap();
final set = {1, 2, 3}.toReactiveSet();

// Bulk dispose
[counter1, counter2, counter3].disposeAll();

// Filter active (non-disposed) items
final active = reactives.active;

Stream interop

// Stream → Reactive
final reactive = myStream.toReactive(initialValue: 0);

// Stream → AsyncReactive
final asyncReactive = myStream.toAsyncReactive();

// Reactive → Stream
final stream = counter.asStream();
final broadcast = counter.asBroadcastStream();

Error handling

All error types extend ReactiveException:

Exception Trigger
DisposedAccessException Accessing a disposed reactive
FrozenReactiveException Mutating a frozen reactive
ReadOnlyReactiveException Attempting to write to a read-only reactive
CircularDependencyException Computed value references itself
BatchException Errors thrown during a batch operation
try {
  disposedReactive.value;
} on DisposedAccessException catch (e) {
  print('Accessed disposed: ${e.reactiveName}');
}

try {
  frozenReactive.value = 42;
} on FrozenReactiveException catch (e) {
  print('Frozen: ${e.reactiveName}');
}

Performance

Scenario Behavior
Computed reads Cached — O(1) unless dirty
Dependency tracking Stack-based set collection — O(n deps)
Batch notifications Deduplicated — each callback fires once per batch
ReactiveSelector Only rebuilds when selected portion changes
ReactiveBuilder Skips rebuild if cached widget is still valid
Collection mutations Single notification per operation (batch for multiple)

Best practices

  1. Dispose reactives — Always dispose signals, computed values, effects, and subscriptions when no longer needed. Use ReactiveScope in Flutter for automatic cleanup.

  2. Use Computed for derived state — Never store derived values in separate Reactive nodes and manually sync them. Let Computed handle dependency tracking.

  3. Batch bulk updates — When updating multiple reactives at once, wrap them in ReactiveBatch.run() to coalesce notifications.

  4. Prefer ReactiveSelector for targeted rebuilds — When a widget only needs a small part of a reactive's value, use ReactiveSelector to skip unnecessary rebuilds.

  5. Use peek() for non-tracking reads — When you need the current value without registering a dependency (e.g., in event handlers), use peek().

  6. Freeze immutable state — Call freeze() on reactives that should never change after initialization.

  7. Register with DI for testability — Use the provided ServiceCollection extensions to register reactives in the DI container for easy mocking and testing.


Anti-patterns

Anti-pattern Problem Solution
Nested Reactive<Reactive<T>> Breaks tracking, hard to reason about Use a single Reactive<T> or restructure into a model class
Manual sync between reactives Fragile, misses updates Use Computed<T>
Reading .value in constructors without tracking context Silent dependency miss Read in Computed or Effect
Forgetting to dispose effects Memory leaks Use ReactiveScope, SubscriptionGroup, or ScopeOwner
Mutating state inside Computed Side effects in pure derivation Move mutations to Effect
Deep object mutation without reassignment Listeners not notified Reassign or use notifyForce()

Testing

import 'package:test/test.dart';
import 'package:davianspace_reactive/davianspace_reactive.dart';

void main() {
  test('computed tracks dependency changes', () {
    final counter = Reactive<int>(0);
    final doubled = Computed<int>(() => counter.value * 2);

    expect(doubled.value, equals(0));

    counter.value = 5;
    expect(doubled.value, equals(10));

    counter.dispose();
    doubled.dispose();
  });

  test('batch coalesces notifications', () {
    final counter = Reactive<int>(0);
    var notifyCount = 0;
    counter.subscribe(() => notifyCount++);

    ReactiveBatch.run(() {
      counter.value = 1;
      counter.value = 2;
      counter.value = 3;
    });

    expect(notifyCount, equals(1));
    counter.dispose();
  });
}

Run tests with:

flutter test

API reference

Core

Type Role
Reactive<T> Mutable reactive signal
ReadOnlyReactive<T> Read-only view of a reactive
ReactiveSnapshot<T> Immutable value snapshot with timestamp
Computed<T> Lazy, cached derived value
Effect Auto-tracking side effect
ReactiveBatch Batch notification manager
ReactiveContext Stack-based dependency tracker
ReactiveSubscription Disposable listener subscription
SubscriptionGroup Manages multiple subscriptions
ReactiveLifecycle Lifecycle mixin with hooks
ReactiveConfig Global debug configuration

Async

Type Role
AsyncState<T> Sealed: AsyncLoading, AsyncData, AsyncError
AsyncReactive<T> Reactive wrapper for async operations
Resource<T> Async with caching and invalidation

Collections

Type Role
ReactiveList<E> Reactive List with full API
ReactiveMap<K, V> Reactive Map with full API
ReactiveSet<E> Reactive Set with full API

Flutter

Type Role
ReactiveBuilder Auto-tracking rebuild widget
ReactiveSelector<S, T> Selective rebuild widget
ReactiveScope<T> Auto-disposing scope widget
ScopeOwner Owns reactives/effects for disposal

DI

Extension Target
addReactiveSingleton<T> ServiceCollection
addReactiveScoped<T> ServiceCollection
addReactiveTransient<T> ServiceCollection
addReactiveSingletonFactory<T> ServiceCollection
addComputedSingleton<T> ServiceCollection
addEffect ServiceCollection
addAsyncReactiveSingleton<T> ServiceCollection
addResourceSingleton<T> ServiceCollection
addReactiveListSingleton<T> ServiceCollection
addReactiveMapSingleton<K, V> ServiceCollection
addReactiveSetSingleton<T> ServiceCollection
getReactive<T> ServiceProviderBase
getComputed<T> ServiceProviderBase
getAsyncReactive<T> ServiceProviderBase
getResource<T> ServiceProviderBase
tryGetReactive<T> ServiceProviderBase
tryGetComputed<T> ServiceProviderBase
tryGetAsyncReactive<T> ServiceProviderBase
tryGetResource<T> ServiceProviderBase

Errors

Type Condition
ReactiveException Base exception class
DisposedAccessException Accessing disposed reactive
FrozenReactiveException Writing to frozen reactive
ReadOnlyReactiveException Writing to read-only reactive
CircularDependencyException Self-referencing computed
BatchException Errors in batch operations

Contributing

See CONTRIBUTING.md.


Security

See SECURITY.md.


License

MIT — see LICENSE.

Libraries

davianspace_reactive
Production-grade reactive state management engine for Dart and Flutter.