davianspace_reactive 1.0.0 copy "davianspace_reactive: ^1.0.0" to clipboard
davianspace_reactive: ^1.0.0 copied to clipboard

High-performance reactive state management for Dart and Flutter using signals, computed values, effects, batching, async state, and dependency injection.

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.

0
likes
160
points
119
downloads

Documentation

API reference

Publisher

verified publisherdavian.space

Weekly Downloads

High-performance reactive state management for Dart and Flutter using signals, computed values, effects, batching, async state, and dependency injection.

Repository (GitHub)
View/report issues
Contributing

Topics

#state-management #reactive #signals #flutter #architecture

License

MIT (license)

Dependencies

davianspace_dependencyinjection, flutter

More

Packages that depend on davianspace_reactive