Flutter Intermediate Lesson 45 of 135 3 min read
StreamController + StreamBuilder and FutureBuilder for State in Flutter
Manage Flutter state with streams and futures: StreamController with StreamBuilder for live values and FutureBuilder for async results. Runnable code.
On this page
Dart has two built-in types for values that arrive over time:
- A
Futuregives you one value later (the result of an API call). - A
Streamgives you many values over time (a timer, chat messages, a counter).
Flutter has a builder for each: FutureBuilder and StreamBuilder. Together with a StreamController, they are a complete state management approach with no package. The original BLoC pattern was exactly this, and the Bloc package grew from it.
StreamController + StreamBuilder: a counter
import 'dart:async';
import 'package:flutter/material.dart';
class CounterController {
int _count = 0;
final _controller = StreamController<int>.broadcast();
Stream<int> get stream => _controller.stream;
int get current => _count;
void increment() => _controller.add(++_count);
void dispose() => _controller.close();
}
void main() => runApp(const MaterialApp(home: StreamCounterPage()));
class StreamCounterPage extends StatefulWidget {
const StreamCounterPage({super.key});
@override
State<StreamCounterPage> createState() => _StreamCounterPageState();
}
class _StreamCounterPageState extends State<StreamCounterPage> {
final counter = CounterController();
@override
void dispose() {
counter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('StreamBuilder')),
body: Center(
child: StreamBuilder<int>(
stream: counter.stream,
initialData: counter.current,
builder: (context, snapshot) => Text(
'${snapshot.data}',
style: const TextStyle(fontSize: 48),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: counter.increment,
child: const Icon(Icons.add),
),
);
}
}
StreamController.broadcast()lets more than one widget listen.addpushes a new value into the stream;StreamBuilderrebuilds with it.- Always
close()the controller when you are done.
A live clock with Stream.periodic
Some streams need no controller at all:
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ClockPage()));
class ClockPage extends StatelessWidget {
const ClockPage({super.key});
@override
Widget build(BuildContext context) {
final ticks = Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now());
return Scaffold(
appBar: AppBar(title: const Text('Live clock')),
body: Center(
child: StreamBuilder<DateTime>(
stream: ticks,
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final t = snapshot.data!;
String two(int n) => n.toString().padLeft(2, '0');
return Text(
'${two(t.hour)}:${two(t.minute)}:${two(t.second)}',
style: const TextStyle(fontSize: 56),
);
},
),
),
);
}
}
In a real app create the stream once (in initState or a field), not inside build, so a rebuild does not start a new stream.
FutureBuilder for one async result
import 'package:flutter/material.dart';
Future<List<String>> loadFruits() async {
await Future.delayed(const Duration(seconds: 2)); // pretend this is an API
return ['Apple', 'Banana', 'Mango'];
}
void main() => runApp(const MaterialApp(home: FruitsPage()));
class FruitsPage extends StatefulWidget {
const FruitsPage({super.key});
@override
State<FruitsPage> createState() => _FruitsPageState();
}
class _FruitsPageState extends State<FruitsPage> {
late Future<List<String>> _fruits = loadFruits(); // created once
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FutureBuilder')),
body: FutureBuilder<List<String>>(
future: _fruits,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final fruits = snapshot.data!;
return ListView(children: [for (final f in fruits) ListTile(title: Text(f))]);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _fruits = loadFruits()),
child: const Icon(Icons.refresh),
),
);
}
}
The snapshot
Both builders give you an AsyncSnapshot:
| Property | Meaning |
|---|---|
connectionState | none, waiting, active (streams) or done |
hasData / data | The latest value |
hasError / error | Something went wrong |
The lessons FutureBuilder and StreamBuilder cover these in depth for network data.
When to use streams for state
- Data that genuinely arrives over time: sockets, Firebase, sensors, timers.
- When you want to transform values with stream operators (see RxDart).
For ordinary UI state, ChangeNotifier or a package is simpler to read.
Try it yourself
Add a decrement method to CounterController and a second StreamBuilder that shows “even” or “odd” using counter.stream.map((n) => n.isEven ? 'even' : 'odd').