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