solid_signals 1.0.4
solid_signals: ^1.0.4 copied to clipboard
A high-performance, ultra-lightweight, and fine-grained reactive state management solution for Flutter.
Solid Signals #
A small, fine-grained reactive state-management library for Dart and Flutter. It provides signals, lazy computed values, effects, async state, persistence, scoped overrides, and focused widget rebuilds.
Features #
- Read and update state without
BuildContext. - Rebuild only widgets that consume a changed signal.
- Derive cached state with dynamic dependency tracking.
- Batch multiple updates without repeatedly running dependent effects.
- Model futures and streams with loading, data, error, retry, and refresh states.
- Persist signal values through a pluggable storage interface.
- Observe signal creation, changes, and disposal centrally.
Installation #
Add the package to pubspec.yaml:
dependencies:
solid_signals: ^1.0.4
Use the core library in Dart code:
import 'package:solid_signals/reactive.dart';
For Flutter widgets and extensions, import:
import 'package:solid_signals/reactive_flutter.dart';
Signals #
final counter = Signal<int>(0);
final compactCounter = 0.signal;
final namedCounter = 0.toSignal(name: 'counter');
print(counter.value);
counter.value = 1;
Listen outside a reactive effect and cancel the subscription when it is no longer needed:
final subscription = counter.listen((previous, current) {
print('$previous -> $current');
});
subscription.cancel();
Computed values and effects #
Computed values are lazy and cached until one of their dependencies changes:
final count = 10.signal;
final doubled = Computed(() => count.value * 2);
final tripled = (() => count.value * 3).computed;
Effects run immediately and rerun when a value read by the callback changes:
final logEffect = effect(() {
print('Count: ${count.value}');
});
logEffect.dispose();
Use cleanup-aware effects for timers, subscriptions, or other per-run resources:
final timerEffect = effectWithCleanup((onCleanup) {
final timer = Timer.periodic(
const Duration(seconds: 1),
(_) => print(count.value),
);
onCleanup(timer.cancel);
});
Multiple writes can be batched so dependent effects run once:
batch(() {
firstName.value = 'Ada';
lastName.value = 'Lovelace';
});
Async signals #
Synchronous signal reads in a source factory are tracked. When one changes, the operation reloads automatically.
final userId = 1.signal;
final user = AsyncSignal.fromFuture(
() => api.loadUser(userId.value),
onCancel: api.cancelCurrentRequest,
);
await user.refresh();
Streams are supported as well:
final messages = AsyncSignal.fromStream(
() => api.messages(userId.value),
);
Render async state with when:
final widget = user.when(
data: (value) => Text(value.name),
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('Failed: $error'),
);
Previous data remains accessible through data while an operation refreshes.
Call retry, reload, or invalidate to start it again. Dart futures are not
intrinsically cancellable, so use onCancel to cancel the underlying work.
Flutter integration #
Wrap the smallest reactive subtree in Observe:
Observe(
builder: (context) => Text('Count: ${counter.value}'),
)
Alternatively, watch a signal from a widget's build method:
@override
Widget build(BuildContext context) {
final value = counter.watch(context);
return Text('Count: $value');
}
Use SignalListener for side effects that should not rebuild its child:
SignalListener<int>(
select: () => counter.value,
listener: (value) => print('Changed to $value'),
child: const CounterView(),
)
Scoped overrides #
SignalScope can replace signals or computed values in a subtree. Nested scopes
fall back to matching overrides in their ancestors.
final currentUser = Signal(User.guest());
final testUser = Signal(User(name: 'Test User'));
SignalScope(
overrides: {currentUser: testUser},
child: const ProfileView(),
)
The .watch(context) extension resolves the active override automatically. For
an explicit lookup, use SignalScope.get(context, currentUser).
Persistence #
Implement SignalStorage with your preferred synchronous storage adapter, set
globalSignalStorage, or pass storage directly to hydrate:
final themeMode = 'dark'.toSignal(name: 'theme_mode').hydrate(
key: 'app_theme',
fromJson: (value) => value,
toJson: (value) => value,
storage: myStorage,
);
When no storage is supplied, the package uses a shared in-memory fallback. It is useful for tests but does not survive process restarts.
Families #
Families cache a signal per argument:
final product = SignalFamily<Product?, String>(
(id) => Signal<Product?>(null, autoDispose: true),
);
final selectedProduct = product('product-42');
AsyncSignalFamily provides the same behavior for async signals. Auto-disposed
members are removed from the family cache.
Diagnostics #
Enable the built-in console observer during development:
void main() {
SignalObserver.enableLogging();
runApp(const MyApp());
}
For custom reporting, extend SignalObserver and assign an instance to the
global signalObserver variable.
Example and tests #
The example/main.dart file contains a complete Flutter demo. Run verification
with:
flutter analyze
flutter test