davianspace_reactive 1.0.0
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.
Table of contents #
- Features
- Installation
- Quick start
- Architecture
- Async state
- Collections
- Flutter widgets
- DI integration
- DevTools
- Extensions
- Error handling
- Performance
- Best practices
- Anti-patterns
- Testing
- API reference
- Contributing
- Security
- License
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 #
-
Dispose reactives — Always dispose signals, computed values, effects, and subscriptions when no longer needed. Use
ReactiveScopein Flutter for automatic cleanup. -
Use
Computedfor derived state — Never store derived values in separateReactivenodes and manually sync them. LetComputedhandle dependency tracking. -
Batch bulk updates — When updating multiple reactives at once, wrap them in
ReactiveBatch.run()to coalesce notifications. -
Prefer
ReactiveSelectorfor targeted rebuilds — When a widget only needs a small part of a reactive's value, useReactiveSelectorto skip unnecessary rebuilds. -
Use
peek()for non-tracking reads — When you need the current value without registering a dependency (e.g., in event handlers), usepeek(). -
Freeze immutable state — Call
freeze()on reactives that should never change after initialization. -
Register with DI for testability — Use the provided
ServiceCollectionextensions 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.