
Discover how signals and BlocSignal eliminate ValueNotifier callback spaghetti, memory leaks, and nested ValueListenableBuilder pyramids in Flutter.
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.
ValueNotifier Fails at ScaleThe moment your UI state depends on more than one piece of data, ValueNotifier starts showing its structural flaws.
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:
_updateFullName) to bridge data updates.addListener calls for every dependent field.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.
ValueListenableBuilder Pyramids of DoomWhen 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.
Signals eliminate the root cause of these problems by introducing automatic dynamic dependency tracking and declarative computed state.
In a signals-based architecture:
.value is read.addListener or removeListener calls.computed().computedHere 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:
computed() automatically detects that firstName.value and lastName.value were read during execution and registers them as dependencies.computed() emits only when the calculated string actually changes (==), preventing redundant widget rebuilds.There is also a profound CPU efficiency difference between ValueNotifier and computed():
ValueNotifier is 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: A computed() 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!BlocSignalWhile 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.state is natively a ReadonlySignal<S>.computed) integrate seamlessly alongside event handlers (on<Event>) or Cubit state methods.Let me compare a complete user profile form controller and UI built with legacy ValueNotifier vs. modern BlocSignal.
ValueNotifier Approachimport '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');
},
),
);
}
}
CubitSignal / BlocSignal Approachimport '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}'),
);
},
),
),
);
}
}
| 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 |
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:
computed().bloc_signals on pub.dev: pub.dev/packages/bloc_signalssignals on pub.dev: pub.dev/packages/signals
aiA practical, source-backed guide to building a local AI workspace like PewDiePie's Odysseus, including VRAM tiers, realistic budgets, model runtimes, installation steps, and security advice.
ragYour RAG copilot can't count — stop letting it try A user asked our document-search...
aiSystem Enforces Order, AI Accelerates Logic: Driving Next-Generation R&D Through...
aiPart 4 of **The Answerability Problem, and the one that isn't about abstention. Parts 1–3 argued that...
devchallengeWe are excited to announce the winners of our DEV Weekend Challenge: Passion Edition! The prompt was...
aiEighteen months ago, MCP was the thing. Every demo and chatbot connector was running on MCP under the...