- Published on
Reactive Flutter: which tool for which job
- Authors

- Name
- Phat Tran
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
| Tool | Reach for it when | Example |
|---|---|---|
| Streams | Async events keep arriving over time | Firebase Realtime data, Sockets |
| ValueNotifier | One synchronous value on one screen | Form validation, Toggles |
| RxDart | Streams need combining or transforming | Search debouncing, Stream merging |
| Riverpod | State is app-wide or an API needs caching | User 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.