Flutter Tutorial

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 Future gives you one value later (the result of an API call).
  • A Stream gives 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.
  • add pushes a new value into the stream; StreamBuilder rebuilds 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:

PropertyMeaning
connectionStatenone, waiting, active (streams) or done
hasData / dataThe latest value
hasError / errorSomething 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').

Practise in the playground Updated