Signals.dart
Reactive state management using fine-grained reactive primitives. Works across Dart VM, WASM, Flutter, and server.
Installation
# Flutter
flutter pub add signals
# Dart
dart pub add signals
// Dart projects
import 'package:signals/signals.dart';
// Flutter projects (includes ValueNotifier compatibility)
import 'package:signals/signals_flutter.dart';
Core Primitives
Signal - Reactive State
final counter = signal(0);
print(counter.value); // Read
counter.value = 1; // Write (notifies dependents)
counter.peek(); // Read without tracking
counter.set(value, force: true); // Force update
Computed - Derived Values
final name = signal("Jane");
final surname = signal("Doe");
final fullName = computed(() => "${name.value} ${surname.value}");
Effect - Side Effects
final dispose = effect(() => print(counter.value));
dispose(); // Stop effect
// With cleanup
effect(() {
print(s.value);
return () => print('Cleanup');
});
Warning: Never mutate signals inside effects without untracked() - causes infinite loops.
Untracked and Batch
// Read without creating dependency
untracked(() => counter.value);
// Batch multiple updates
batch(() {
name.value = "Foo";
surname.value = "Bar";
}); // Single notification
Flutter Integration
Watch Widget (Recommended)
Watch((context) => Text('Count: ${counter.value}'));
// With child optimization
Watch.builder(
builder: (context, child) => Column(children: [
Text('Count: ${counter.value}'),
child!, // Doesn't rebuild
]),
child: ExpensiveWidget(),
);
SignalsMixin (Auto-disposal)
class _MyState extends State<MyWidget> with SignalsMixin {
late final count = createSignal(0);
late final isEven = createComputed(() => count.value.isEven);
@override
void initState() {
super.initState();
createEffect(() => print('Count: ${count.value}')); // Effects in initState!
}
}
Async Signals
FutureSignal
final data = futureSignal(() async => fetchData());
// With dependencies (re-executes on change)
final data = futureSignal(
() async => fetchDataFor(id.value),
dependencies: [id],
);
data.reset(); // Return to initial
data.refresh(); // Keep state, set loading
data.reload(); // Reset to loading with preserved value
AsyncState Pattern Matching
return switch (data.value) {
AsyncLoading() => CircularProgressIndicator(),
AsyncData(:final value) => Text('$value'),
AsyncError(:final error) => Text('Error: $error'),
};
// Or map()
data.value.map(
loading: () => CircularProgressIndicator(),
data: (value) => DataWidget(value),
error: (err, _) => ErrorWidget(err),
);
Collections
final list = listSignal([1, 2, 3]);
final map = mapSignal({'a': 1});
final set = setSignal({1, 2, 3});
list.add(4); // Triggers updates
map['b'] = 2; // Triggers updates
set.remove(1); // Triggers updates
Best Practices
- Use
Watchwidget instead of.watch(context) - Never mutate signals inside effects without
untracked() - Use
batch()for multiple updates - Put effects in
initState(), not as late fields - Access signal values BEFORE await in
computedAsync() - Prefer
computedFrom()overcomputedAsync()to avoid await timing issues - Always add
debugLabelto every signal — use the variable name (no class prefix):final counter = Signal<int>(0, debugLabel: 'counter'); final data = FutureSignal(() => fetch(), debugLabel: 'data'); final items = ListSignal([], debugLabel: 'items'); late final total = Computed(() => items.value.length, debugLabel: 'total');
Reference
For detailed API documentation including Flutter hooks, SignalProvider, ValueNotifier compatibility, async signals detail, collections API, advanced patterns (SignalsContainer, persisted signals, bi-directional flow, SignalsObserver), DI patterns, testing, and migration guides, see references/api_reference.md.