DEV Community

Cover image for Mechanically Eliminating FutureBuilder & StreamBuilder: Universal Signal, Future, and Stream Adapters in BlocSignal

Mechanically Eliminating FutureBuilder & StreamBuilder: Universal Signal, Future, and Stream Adapters in BlocSignal

Making the Migration from In-View Asynchrony to Synchronous State Management Truly Mechanical

After our recent discussions on why FutureBuilder and StreamBuilder are architectural anti-patterns when placed inside Flutter widget trees, I started thinking: how can we make it even easier—even completely mechanical—to convert from a FutureBuilder or StreamBuilder to a BlocSignalBuilder?

Every Flutter developer knows the history. Years ago, I recorded a video breaking down the hidden traps of placing asynchronous builders in UI views: Why you shouldn't put FutureBuilder in your build method. Even the original official Flutter video on FutureBuilder initially instantiated the network future directly inside the build() method, until I filed an issue to get it corrected (which is why the official Flutter YouTube video still proudly bears "Take 2" on its clapperboard!).

The fundamental issue has never been that developers want bad architecture. The issue was friction.

FutureBuilder was simply the path of least resistance. To do it "properly" in traditional state management, developers had to create an entire BLoC or Cubit, declare separate Event and State classes (or union types), write boilerplate event handlers, wire asynchronous repository methods, manage subscription lifecycles, and inject everything into the widget tree.

With bloc_signals 1.1.0, that friction disappears completely.

We have introduced universal, symmetrical adapter extensions that allow any Dart Future, Stream, ReadonlySignal, or lifted primitive (value.$) to adapt into a synchronous BlocSignalBase container with a single method call.


🧭 The Universal Dual-Track Mental Model

When bridging asynchronous sources into synchronous state management, developers typically have one of two distinct intents:

  1. Raw Domain Values (T): You want raw domain objects (for example int, UserProfile, ThemeMode) with zero wrapper ceremony, and you have an immediate default or fallback value for frame 0.
  2. Rich Asynchronous Lifecycle States (AsyncState<T>): You want first-class lifecycle tracking (AsyncLoadingAsyncData or AsyncError) with exhaustive pattern matching.

To make the API completely intuitive and predictable, BlocSignal adheres to the Universal Dual-Track Principle:

┌────────────────────────────────────────────────────────────────────────┐
│                   BlocSignal Universal Adapter Matrix                  │
├───────────────────────────────┬────────────────────────────────────────┤
│ Method                        │ Resulting Container Type               │
├───────────────────────────────┼────────────────────────────────────────┤
│ .toBlocSignal(...)            │ BlocSignalBase<T> (Raw domain state)   │
│ .toAsyncBlocSignal(...)       │ BlocSignalBase<AsyncState<T>> (Async)  │
└───────────────────────────────┴────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Let us examine how each track works.


⚡ Track 1: Direct Raw Domain State (.toBlocSignal)

The .toBlocSignal(...) family strictly yields a BlocSignalBase<T> holding raw values of type T.

1. Adapting ReadonlySignal<T> & Lifted Primitives

Because signals in the signals_core graph always hold an immediate synchronous value, converting any signal to a BlocSignalBase requires no arguments:

// 1. Raw Signal -> BlocSignalBase<int>
final countSignal = signal(0);
final countBloc = countSignal.toBlocSignal();

// 2. Computed Expression -> BlocSignalBase<bool>
final isValidSignal = computed(() => username().isNotEmpty && password().length >= 8);
final authBloc = isValidSignal.toBlocSignal();

// 3. Lifted Primitive -> BlocSignalBase<double>
final priceBloc = 49.99.$.toBlocSignal();
Enter fullscreen mode Exit fullscreen mode

The resulting SignalBlocSignal<T> subscribes directly to the underlying signal graph and emits synchronous state transitions whenever the source signal mutates. Calling close() on the container automatically unsubscribes the effect.

2. Adapting Future<T> with a Required Initial State

Futures do not have a synchronous value on frame 0. Therefore, when requesting raw domain values of type T, Future.toBlocSignal() requires an explicit initialState::

// Future<User> -> BlocSignalBase<User>
final userBloc = api.fetchUserProfile(userId).toBlocSignal(
  initialState: User.anonymous(),
);
Enter fullscreen mode Exit fullscreen mode

On frame 0, userBloc.stateValue is immediately User.anonymous(). As soon as the future completes, it emits the resolved User synchronously into the state machine. If the future throws, the error is routed safely to onError() and the container's registered BlocObserver.

3. Adapting Stream<T> with a Required Initial State

Similarly, any multi-value Stream<T> (such as a WebSocket, sensor stream, or legacy BLoC/Redux stream) adapts with a required initial value:

// Stream<int> -> BlocSignalBase<int>
final counterBloc = myStream.toBlocSignal(initialState: 0);

// Redux Store -> BlocSignalBase<AppState>
final reduxBloc = store.onChange.toBlocSignal(
  initialState: store.state,
);
Enter fullscreen mode Exit fullscreen mode

🔄 Track 2: Rich Asynchronous State (.toAsyncBlocSignal)

When you do not have an initial fallback value and want first-class loading, data, and error states, .toAsyncBlocSignal() converts Future<T> and Stream<T> into a BlocSignalBase<AsyncState<T>>.

// 1. Future<UserProfile> -> BlocSignalBase<AsyncState<UserProfile>>
final userProfileBloc = api.fetchUserProfile(userId).toAsyncBlocSignal();

// 2. Stream<List<ChatMessage>> -> BlocSignalBase<AsyncState<List<ChatMessage>>>
final chatBloc = chatSocket.messageStream.toAsyncBlocSignal();
Enter fullscreen mode Exit fullscreen mode

The state lifecycle is completely automatic:

  • Frame 0: Starts synchronously in AsyncLoading().
  • On Resolution / Emission: Synchronously transitions to AsyncData(value).
  • On Failure / Error: Synchronously transitions to AsyncError(error, stackTrace).

💡 Bonus Win: Goodbye to Async Singleton Boilerplate (Inherently Lazy State)

Think for a moment about how Flutter applications traditionally handled an asynchronous resource with a fallback default. You often had to write 30+ lines of fragile singleton and ChangeNotifier plumbing:

// 😩 The old way: Fragile singleton boilerplate with mutable async init
class UserManager extends ChangeNotifier {
  UserManager._() { _init(); }
  static final instance = UserManager._();

  User _user = User.anonymous();
  User get user => _user;
  bool _loading = true;

  Future<void> _init() async {
    try {
      _user = await api.fetchUser();
    } finally {
      _loading = false;
      notifyListeners();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Compare that ceremony with BlocSignal:

// 🪄 The BlocSignal way: 1 declarative expression
final userBloc = api.fetchUserProfile(userId).toBlocSignal(
  initialState: User.anonymous(),
);
Enter fullscreen mode Exit fullscreen mode

Even better: it is inherently lazy.

Because top-level variables and late fields in Dart are evaluated on demand:

  • Zero Application Startup Cost: Defining top-level or module-level state containers does not execute during void main(). If the user never navigates to that screen or feature, the network request is never fired and no resources are wasted.
  • Signal-Graph Laziness: In .toAsyncBlocSignal(), the underlying FutureSignal defaults to lazy: true, meaning evaluation only triggers when an active UI consumer or test actually reads the signal.
  • No async main() Initialization Bottlenecks: You no longer need to stall app startup with await initAllServices() before runApp(). Features initialize on demand on the exact frame they are requested.

The Mindblowing Part: Reactive Dependency Chains

What if userId is dynamic (for example derived from an auth session, a route parameter, or a dropdown selection)?

You can wire it directly into the reactive dependency graph:

// 1. Upstream reactive dependency:
final selectedUserId = signal<String>('42');

// 2. Downstream async pipeline — automatically refetches when selectedUserId changes:
final userProfileBloc = futureSignal(
  () => api.fetchUserProfile(selectedUserId()),
).toBlocSignal();
Enter fullscreen mode Exit fullscreen mode

When selectedUserId.value = '99' changes anywhere in your app, the futureSignal automatically detects the dependency change, refetches the profile, and emits state transitions through userProfileBloc directly into your BlocSignalBuilder—with zero manual listeners, zero event dispatch plumbing, and zero didUpdateWidget lifecycle gymnastics.


🛠️ Step-by-Step Conversion 1: Eliminating FutureBuilder

Let us take a real-world example of FutureBuilder and see how clean and non-scary it is to replace it.

The Legacy Anti-Pattern (FutureBuilder)

Here is the classic code that lives in thousands of Flutter codebases:

// ❌ ANTI-PATTERN: Trapping async I/O inside the widget tree
class UserProfilePage extends StatelessWidget {
  const UserProfilePage({super.key, required this.userId});
  final String userId;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('User Profile')),
      body: FutureBuilder<UserProfile>(
        // ⚠️ DANGER: Re-invoked on every parent rebuild!
        future: apiClient.fetchUserProfile(userId),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          }
          if (snapshot.hasData) {
            final user = snapshot.data!;
            return ProfileView(user: user);
          }
          return const SizedBox.shrink();
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The Clean, Non-Scary Refactor

Instead of wrapping the view in complex provider nesting, we simply pass the adapted container directly into our view widget via constructor parameters (clean prop-drilling):

// 1. At the route boundary, controller, or parent widget:
final userProfileBloc = apiClient.fetchUserProfile(userId).toAsyncBlocSignal();

// 2. Pass it directly to the view:
UserProfilePage(userProfileBloc: userProfileBloc);
Enter fullscreen mode Exit fullscreen mode

Our presentation widget becomes a pure, lightweight StatelessWidget:

// ✅ BLOCSIGNAL PATTERN: Clean, synchronous projection of state
class UserProfilePage extends StatelessWidget {
  const UserProfilePage({
    super.key, 
    required this.userProfileBloc,
  });

  final BlocSignalBase<AsyncState<UserProfile>> userProfileBloc;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('User Profile')),
      body: BlocSignalBuilder(
        bloc: userProfileBloc,
        builder: (context, state) => switch (state) {
          AsyncData(:final value) => ProfileView(user: value),
          AsyncLoading() => const Center(child: CircularProgressIndicator()),
          AsyncError(:final error) => Center(child: Text('Error: $error')),
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at how clear that is!

There are no nested InheritedWidget provider trees or noisy generic boilerplate. Type inference handles the builder automatically.

Note on Dependency Injection: Because BlocSignal containers are pure Dart objects with zero framework baggage, you can supply them using whichever DI strategy fits your project: simple constructor prop-drilling (as shown above), top-level globals, service locators (such as GetIt), BlocSignalProvider, or Riverpod adapters.

What You Just Won:

  1. Zero Accidental Refetches: If the software keyboard opens, an animation ticks, or the device rotates, build() re-runs synchronously without dispatching a new HTTP request.
  2. Exhaustive Safety: The compiler verifies that you handled AsyncLoading, AsyncData, and AsyncError. No more forgetting edge cases or dealing with force unwraps (snapshot.data!).
  3. 0ms Synchronous Testability: In unit tests, you can test userProfileBloc with blocSignalTest in 0ms without mocking microtask timers or calling tester.pumpAndSettle().

🌊 Step-by-Step Conversion 2: Eliminating StreamBuilder

Now let us look at StreamBuilder.

The Legacy Anti-Pattern (StreamBuilder)

// ❌ ANTI-PATTERN: Stream subscription lifecycle entangled with widget tree
class DeviceTemperatureWidget extends StatelessWidget {
  const DeviceTemperatureWidget({super.key, required this.sensorService});
  final SensorService sensorService;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<double>(
      stream: sensorService.temperatureStream,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Text('Reading sensor...');
        }
        return Text('${snapshot.data!.toStringAsFixed(1)}°C');
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The Clean Refactor with .toBlocSignal()

If you have a sensible initial value (for example 0.0), adapt the stream directly to a raw domain container and pass it straight into your widget:

// ✅ OPTION A: Raw Domain State with explicit initial value
class DeviceTemperatureWidget extends StatelessWidget {
  const DeviceTemperatureWidget({
    super.key, 
    required this.temperatureBloc,
  });

  final BlocSignalBase<double> temperatureBloc;

  @override
  Widget build(BuildContext context) {
    return BlocSignalBuilder(
      bloc: temperatureBloc,
      builder: (context, temperature) {
        return Text('${temperature.toStringAsFixed(1)}°C');
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Or, if you prefer rich async lifecycle tracking:

// ✅ OPTION B: Rich Async Lifecycle State
class DeviceTemperatureWidget extends StatelessWidget {
  const DeviceTemperatureWidget({
    super.key, 
    required this.temperatureBloc,
  });

  final BlocSignalBase<AsyncState<double>> temperatureBloc;

  @override
  Widget build(BuildContext context) {
    return BlocSignalBuilder(
      bloc: temperatureBloc,
      builder: (context, state) => switch (state) {
        AsyncData(:final value) => Text('${value.toStringAsFixed(1)}°C'),
        AsyncLoading() => const Text('Reading sensor...'),
        AsyncError(:final error) => Text('Sensor offline: $error'),
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

🧪 Testing: From Async Pump Hell to 0ms Determinism

Consider the difference in testability.

Testing a FutureBuilder Widget

// ⚠️ Fragile, slow, timer-dependent
testWidgets('renders user profile', (tester) async {
  await tester.pumpWidget(UserProfilePage(
    userProfileBloc: Future.value(mockUser).toAsyncBlocSignal(),
  ));

  // Instant synchronous verification of the initial loading state:
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Settle completion cleanly:
  await tester.pump();
  expect(find.text('Alice'), findsOneWidget);
});
Enter fullscreen mode Exit fullscreen mode

Testing the BlocSignal Adapter Unit

Because BlocSignal operates synchronously with 0ms microtask delays, testing state transitions is declarative and instantaneous:

// ✅ Fast, synchronous, 100% deterministic
blocSignalTest<SignalBlocSignal<AsyncState<String>>, AsyncState<String>>(
  'emits AsyncLoading then AsyncData on future completion',
  build: () => Future.value('Alice').toAsyncBlocSignal(),
  wait: const Duration(milliseconds: 10),
  expect: () => [
    isA<AsyncLoading<String>>(),
    isA<AsyncData<String>>().having((s) => s.value, 'value', 'Alice'),
  ],
);
Enter fullscreen mode Exit fullscreen mode

🌌 Like the Force: Binding the Galaxy Together

In modern application engineering, you will inevitably interact with diverse reactive abstractions:

  • Third-party SDKs that return Future<T>.
  • Hardware APIs and WebSockets that stream Stream<T>.
  • Reactive view models built with Signal<T> or Computed<T>.
  • Classic legacy state stores built with BLoC or Redux.

BlocSignal does not force you to rewrite your entire data layer or choose between the discipline of unidirectional data flow and the speed of fine-grained signals.

With .toBlocSignal() and .toAsyncBlocSignal(), every asynchronous stream, future, and signal in your architecture adapts seamlessly into a synchronous, predictable, 0ms reactive state machine.

Try out bloc_signals 1.1.0 today:

dependencies:
  bloc_signals: ^1.1.0
  bloc_signals_flutter: ^1.1.0
Enter fullscreen mode Exit fullscreen mode

Happy coding!

Top comments (0)