Published on

Reactive Flutter: which tool for which job

Authors
  • avatar
    Name
    Phat Tran
    Twitter

Reactive programming in Flutter means one thing in practice: the UI watches the data, and when the data changes, the UI redraws itself. You wire the two together once and stop calling refresh methods by hand.

The concept is not the hard part. The hard part is that Flutter gives you at least four ways to do it, and they overlap just enough to cause arguments in code review. This is how I pick between them.

Streams and StreamBuilder

A Stream is for data that arrives over time on a schedule you do not control: WebSocket messages, GPS updates, Firebase Realtime Database events. If the source pushes and you receive, it is a stream.

This is the pattern behind every live price or balance widget I have built. A socket feeds the stream, a StreamBuilder repaints one small widget, and the rest of the screen never notices.

import 'package:flutter/material.dart';

class PriceTracker extends StatelessWidget {
  // A stream that emits new price data every second
  final Stream<double> priceStream = Stream.periodic(
    const Duration(seconds: 1),
    (count) => 100.0 + (count * 0.5),
  );

  
  Widget build(BuildContext context) {
    return StreamBuilder<double>(
      stream: priceStream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }

        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }

        return Text('Bitcoin Price: $${snapshot.data}');
      },
    );
  }
}

The connectionState and hasError branches are not optional politeness. A stream that has not emitted yet and a stream that died look the same if you only handle the happy path.

ValueNotifier and ValueListenableBuilder

For a single synchronous value that lives inside one screen, ValueNotifier is the cheapest tool Flutter ships. Password visibility, a remember-me checkbox, whether the submit button is enabled. No package to install, almost nothing to learn.

import 'package:flutter/material.dart';

class PasswordField extends StatefulWidget {
  
  _PasswordFieldState createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  // Simple reactive state
  final ValueNotifier<bool> _obscureText = ValueNotifier<bool>(true);

  
  void dispose() {
    _obscureText.dispose(); // Always dispose!
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return ValueListenableBuilder<bool>(
      valueListenable: _obscureText,
      builder: (context, isObscured, child) {
        return TextField(
          obscureText: isObscured,
          decoration: InputDecoration(
            labelText: 'Password',
            suffixIcon: IconButton(
              icon: Icon(isObscured ? Icons.visibility_off : Icons.visibility),
              onPressed: () => _obscureText.value = !_obscureText.value, // Triggers rebuild
            ),
          ),
        );
      },
    );
  }
}

The dispose call is not decoration. Skip it and the notifier hangs around after the screen is gone.

RxDart

Native streams handle delivery. RxDart handles manipulation: combining several streams, throttling events, debouncing input. When you catch yourself writing timers and boolean flags around a StreamController, that is usually the sign.

The classic case is search. You do not want an API call on every keystroke, so debounceTime waits until the user stops typing.

import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';

class SearchScreen extends StatefulWidget {
  
  _SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _searchController = BehaviorSubject<String>();

  
  void initState() {
    super.initState();

    // Only trigger search when the user stops typing for 500ms
    _searchController.stream
        .debounceTime(const Duration(milliseconds: 500))
        .distinctUntilChanged()
        .listen((query) {
      if (query.isNotEmpty) {
        print('Fetching API for: $query');
        // Call your API here
      }
    });
  }

  
  void dispose() {
    _searchController.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(
      onChanged: _searchController.add, // Feed the stream
      decoration: const InputDecoration(
        hintText: 'Search products...',
        prefixIcon: Icon(Icons.search),
      ),
    );
  }
}

The distinctUntilChanged in the chain covers the case where the user types a letter and deletes it. Same query, no second request.

Riverpod

Everything above is local. Once state is app-wide (the logged-in user, a cart, cached API responses), passing notifiers down the widget tree turns into plumbing, and Riverpod is what I switch to when that plumbing gets messy.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. Define the provider
final userProvider = FutureProvider.autoDispose<String>((ref) async {
  // Simulate an API call
  await Future.delayed(const Duration(seconds: 2));
  return "John Doe";
});

// 2. Consume the provider
class UserProfile extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsyncValue = ref.watch(userProvider);

    return Scaffold(
      body: Center(
        // AsyncValue automatically handles loading, error, and data states
        child: userAsyncValue.when(
          loading: () => const CircularProgressIndicator(),
          error: (error, stack) => Text('Failed to load user: $error'),
          data: (userName) => Text('Welcome back, $userName!'),
        ),
      ),
    );
  }
}

The part I appreciate most is AsyncValue: the when call will not let you forget the loading and error branches, which is exactly the code people skip when they are in a hurry.


Which one, when

ToolReach for it whenExample
StreamsAsync events keep arriving over timeFirebase Realtime data, Sockets
ValueNotifierOne synchronous value on one screenForm validation, Toggles
RxDartStreams need combining or transformingSearch debouncing, Stream merging
RiverpodState is app-wide or an API needs cachingUser authentication, Cart state

Most of the bad state management I have inherited was not the wrong library. It was a big tool doing a small job, like a global provider guarding a checkbox. Start with the smallest thing that fits and move up only when the problem grows.