DEV Community

Cover image for Why ValueNotifier Fails at Scale: The Non-Composability Problem (and How Signals Fix It)

Why ValueNotifier Fails at Scale: The Non-Composability Problem (and How Signals Fix It)

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 ValueNotifier and ValueListenable are defined inside package: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), ValueNotifier is completely unavailable. Signals and bloc_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}';
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what happened here:

  • You had to manually write helper methods (_updateFullName) to bridge data updates.
  • You had to manually attach addListener calls 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();
}
Enter fullscreen mode Exit fullscreen mode

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');
      },
    );
  },
)
Enter fullscreen mode Exit fullscreen mode

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 .value is read.
  • There are no manual addListener or removeListener calls.
  • 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}');
Enter fullscreen mode Exit fullscreen mode

Look at how much simpler this is:

  1. Zero Manual Wiring: computed() automatically detects that firstName.value and lastName.value were read during execution and registers them as dependencies.
  2. Zero Memory Leaks: When subscribers unbind or widgets unmount, signal dependency graphs clean themselves up automatically.
  3. 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():

  • 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!

๐Ÿ—๏ธ 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.state is natively a ReadonlySignal<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');
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

โœ… 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}'),
            );
          },
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“Š 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

Top comments (0)