From Listener Spaghetti to Declarative Reactive Composition
When Flutter developers start building applications, ValueNotifier<T> and ValueListenableBuilder often seem like the perfect lightweight solution. Built directly into the Flutter SDK, ValueNotifier holds a single piece of data, notifies listeners when updated, and requires zero third-party dependencies.
For simple isolated stateโlike toggling a switch or incrementing a counterโValueNotifier works fine.
๐ก The Pure Dart Limitation: Because
ValueNotifierandValueListenableare defined insidepackage:flutter/foundation.dart, they are tightly coupled to the Flutter SDK. For pure Dart projects (CLI tools, server backends like Dart Frog, or Jaspr web applications),ValueNotifieris completely unavailable. Signals andbloc_signals, by contrast, are pure Dart primitives that run anywhere Dart runs!
However, as applications grow beyond trivial counter demos, developers inevitably run into a major architectural brick wall: ValueNotifier is fundamentally non-composable.
In this article, weโll analyze why ValueNotifier fails as application complexity scales, how reactive signals solve the non-composability problem at first principles, and how BlocSignal combines signal speed with enterprise BLoC discipline.
๐ Why ValueNotifier Fails at Scale
The moment your UI state depends on more than one piece of data, ValueNotifier starts showing its structural flaws.
1. Callback Spaghetti & Manual Listener Wiring
Suppose you have a user profile form with firstNameNotifier and lastNameNotifier, and you want to compute a derived fullName or isValid property.
Because ValueNotifier cannot observe other notifiers automatically, you must manually wire up listener callbacks:
class ProfileController {
final firstNameNotifier = ValueNotifier<String>('');
final lastNameNotifier = ValueNotifier<String>('');
final fullNameNotifier = ValueNotifier<String>('');
ProfileController() {
// Manual wiring required for every single dependency!
firstNameNotifier.addListener(_updateFullName);
lastNameNotifier.addListener(_updateFullName);
}
void _updateFullName() {
fullNameNotifier.value = '${firstNameNotifier.value} ${lastNameNotifier.value}';
}
}
Notice what happened here:
- You had to manually write helper methods (
_updateFullName) to bridge data updates. - You had to manually attach
addListenercalls for every dependent field. - As dependencies grow (N fields feeding into M derived properties), the boilerplate grows quadratically (O(N ร M)), quickly degrading into fragile callback spaghetti.
2. The Memory Leak Trap
ValueNotifier maintains an internal list of callback listeners using strong references. If you attach a listener callback, you MUST manually remove it when the controller or widget is disposed:
void dispose() {
// Forget any of these, and your objects leak in memory!
firstNameNotifier.removeListener(_updateFullName);
lastNameNotifier.removeListener(_updateFullName);
firstNameNotifier.dispose();
lastNameNotifier.dispose();
fullNameNotifier.dispose();
}
Forgetting even a single removeListener call leaves an active callback reference in memory, preventing garbage collection and creating insidious, hard-to-trace memory leaks in production.
3. Nested ValueListenableBuilder Pyramids of Doom
When consuming multiple ValueNotifiers in the Flutter UI layer, standard widgets force deep nesting:
// โ Nested builder pyramid of doom!
ValueListenableBuilder<String>(
valueListenable: controller.firstNameNotifier,
builder: (context, firstName, _) {
return ValueListenableBuilder<String>(
valueListenable: controller.lastNameNotifier,
builder: (context, lastName, _) {
return Text('User: $firstName $lastName');
},
);
},
)
Combining 3 or 4 ValueNotifier fields leads to 4-level deep widget indentation, harming code readability and making refactoring a headache.
โก๏ธ The Signal Paradigm Shift: Automatic & Declarative Composition
Signals eliminate the root cause of these problems by introducing automatic dynamic dependency tracking and declarative computed state.
In a signals-based architecture:
- Signals track dependencies dynamically when their
.valueis read. - There are no manual
addListenerorremoveListenercalls. - Derived state is expressed declaratively using
computed().
Declarative Derived State with computed
Here is how the exact same derived fullName logic looks with signals:
final firstName = signal('');
final lastName = signal('');
// โจ 1 line of code! Automatically tracks firstName and lastName!
final fullName = computed(() => '${firstName.value} ${lastName.value}');
Look at how much simpler this is:
-
Zero Manual Wiring:
computed()automatically detects thatfirstName.valueandlastName.valuewere read during execution and registers them as dependencies. - Zero Memory Leaks: When subscribers unbind or widgets unmount, signal dependency graphs clean themselves up automatically.
-
Automatic De-duplication:
computed()emits only when the calculated string actually changes (==), preventing redundant widget rebuilds.
๐ Lazy Pull Evaluation vs. Unconditional Push Execution
There is also a profound CPU efficiency difference between ValueNotifier and computed():
-
ValueNotifieris an Eager Push Model: Updating an upstream notifier forces listener callbacks to execute immediately and unconditionallyโeven if the derived value is off-screen or no UI component is currently observing it. This wastes CPU cycles on unneeded calculations. -
computed()is a Lazy Pull Model: Acomputed()signal marks itself dirty when dependencies change, but never executes its derivation closure until an active observer actually reads.value. If no widget or listener is observing the computed signal, zero CPU cycles are wasted!
๐๏ธ Enterprise Rigor with BlocSignal
While raw signals excel at reactive state composition, large-scale enterprise applications also need predictable state transition rules, event-driven debugging, and team discipline.
That's where BlocSignal comes in.
BlocSignal bridges Rody Davisโs signals.dart with the BLoC pattern:
-
bloc.state/cubit.stateis natively aReadonlySignal<S>. - State transitions propagate synchronously in the exact frame emitted.
- Derived signals (
computed) integrate seamlessly alongside event handlers (on<Event>) or Cubit state methods.
๐ป Side-by-Side Code Comparison
Let me compare a complete user profile form controller and UI built with legacy ValueNotifier vs. modern BlocSignal.
โ Before: Legacy ValueNotifier Approach
import 'package:flutter/material.dart';
class LegacyProfileController {
final firstName = ValueNotifier<String>('');
final lastName = ValueNotifier<String>('');
final fullName = ValueNotifier<String>('');
LegacyProfileController() {
firstName.addListener(_updateFullName);
lastName.addListener(_updateFullName);
}
void _updateFullName() {
fullName.value = '${firstName.value} ${lastName.value}'.trim();
}
void dispose() {
firstName.removeListener(_updateFullName);
lastName.removeListener(_updateFullName);
firstName.dispose();
lastName.dispose();
fullName.dispose();
}
}
class LegacyProfileView extends StatefulWidget {
const LegacyProfileView({super.key});
@override
State<LegacyProfileView> createState() => _LegacyProfileViewState();
}
class _LegacyProfileViewState extends State<LegacyProfileView> {
late final LegacyProfileController controller;
@override
void initState() {
super.initState();
controller = LegacyProfileController();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ValueListenableBuilder<String>(
valueListenable: controller.fullName,
builder: (context, fullName, _) {
return Text('Full Name: $fullName');
},
),
);
}
}
โ
After: Modern CubitSignal / BlocSignal Approach
import 'package:flutter/material.dart';
import 'package:bloc_signals/bloc_signals.dart';
import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';
import 'package:signals_core/signals_core.dart';
class ProfileState {
final String firstName;
final String lastName;
const ProfileState({this.firstName = '', this.lastName = ''});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProfileState &&
runtimeType == other.runtimeType &&
firstName == other.firstName &&
lastName == other.lastName;
@override
int get hashCode => firstName.hashCode ^ lastName.hashCode;
}
class ProfileCubit extends CubitSignal<ProfileState> {
ProfileCubit() : super(const ProfileState());
// โจ Declarative derived signal computed directly from state!
late final ReadonlySignal<String> fullName = computed(() {
return '${stateValue.firstName} ${stateValue.lastName}'.trim();
});
void updateFirstName(String first) {
emit(ProfileState(firstName: first, lastName: stateValue.lastName));
}
void updateLastName(String last) {
emit(ProfileState(firstName: stateValue.firstName, lastName: last));
}
}
class ModernProfileView extends StatelessWidget {
const ModernProfileView({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => ProfileCubit(),
child: Scaffold(
body: Builder(
builder: (context) {
final cubit = context.read<ProfileCubit>();
// Watch the computed fullName signal directly!
return SignalBuilder(
builder: (context) => Text('Full Name: ${cubit.fullName.value}'),
);
},
),
),
);
}
}
๐ Architectural Mapping Table
| Feature / Metric | Legacy ValueNotifier
|
Modern Signals / BlocSignal
|
|---|---|---|
| Dependency Tracking | Manual (addListener / removeListener) |
Automatic & Dynamic (zero listener code) |
| Derived State | Manual callback calculation & notifyListeners
|
Declarative computed() (1 line of code) |
| Evaluation Model | Eager Push (executes callbacks unconditionally) |
Lazy Pull (evaluates computed() only when observed) |
| Memory Leak Risk | High (missing removeListener leaks memory) |
Zero (subscribers unbind automatically) |
| UI Nesting | Deep (ValueListenableBuilder pyramids) |
Flat (SignalBuilder, context.select) |
| State Equality | Manual check in setter | Automatic == de-duplication |
| DevTools & Observers | None built-in | BlocSignalObserver & DevTools Telemetry |
๐ฏ Conclusion
ValueNotifier served a valuable purpose in early Flutter versions as a bare-bones primitive, but attempting to scale it across real-world application features leads directly to callback spaghetti, memory leaks, and UI nesting pyramids.
By switching to signal-based reactivity with BlocSignal:
- You gain declarative, leak-free state composition with
computed(). - Your UI code stays clean, flat, and readable.
- Your application retains enterprise BLoC event discipline, observer tracing, and DevTools observability.
๐ Resources & Next Steps
- ๐ฆ
bloc_signalson pub.dev: pub.dev/packages/bloc_signals - ๐ช
signalson pub.dev: pub.dev/packages/signals - ๐ GitHub Repository: github.com/RandalSchwartz/BlocSignal
Top comments (0)