Flutter Tutorial

Flutter Intermediate Lesson 60 of 135 3 min read

RxDart in Flutter: BehaviorSubject, Operators and Reactive Streams

Learn RxDart for Flutter with runnable examples: BehaviorSubject, debounceTime, combineLatest and switchMap with StreamBuilder and the BLoC pattern.

On this page

RxDart adds extra power to Dart streams: special subjects (streams you can push values into) and many operators that transform streams (debounceTime, combineLatest, switchMap and more). It is not a full state management solution; it is a tool used with StreamBuilder or the Bloc pattern.

If streams are new to you, read StreamController + StreamBuilder first.

Install

flutter pub add rxdart

Subjects

SubjectWhat a new listener gets
BehaviorSubjectThe latest value immediately, then new ones
PublishSubjectOnly new values after it starts listening
ReplaySubjectAll previous values, then new ones

BehaviorSubject is the one you use for state, because it always has a current value.

The counter

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

class CounterBloc {
  final _count = BehaviorSubject<int>.seeded(0);

  Stream<int> get count => _count.stream;
  void increment() => _count.add(_count.value + 1);
  void dispose() => _count.close();
}

final bloc = CounterBloc();

void main() => runApp(const MaterialApp(home: CounterPage()));

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('RxDart')),
      body: Center(
        child: StreamBuilder<int>(
          stream: bloc.count,
          builder: (context, snapshot) =>
              Text('${snapshot.data ?? 0}', style: const TextStyle(fontSize: 48)),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: bloc.increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}

A search box with debounce

debounceTime waits until the user stops typing before searching. switchMap cancels the old search when a new one starts.

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

const allCities = ['Kathmandu', 'Pokhara', 'Delhi', 'Mumbai', 'Toronto', 'Sydney', 'Lisbon', 'London'];

Future<List<String>> searchApi(String q) async {
  await Future.delayed(const Duration(milliseconds: 300)); // pretend network delay
  return allCities.where((c) => c.toLowerCase().contains(q.toLowerCase())).toList();
}

class SearchBloc {
  final _query = BehaviorSubject<String>.seeded('');

  late final Stream<List<String>> results = _query
      .debounceTime(const Duration(milliseconds: 400))
      .distinct()
      .switchMap((q) => Stream.fromFuture(searchApi(q)));

  void search(String q) => _query.add(q);
  void dispose() => _query.close();
}

final searchBloc = SearchBloc();

void main() => runApp(const MaterialApp(home: SearchPage()));

class SearchPage extends StatelessWidget {
  const SearchPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Debounced search')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextField(
              onChanged: searchBloc.search,
              decoration: const InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Search cities'),
            ),
          ),
          Expanded(
            child: StreamBuilder<List<String>>(
              stream: searchBloc.results,
              builder: (context, snapshot) {
                if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
                final cities = snapshot.data!;
                if (cities.isEmpty) return const Center(child: Text('No matches'));
                return ListView(children: [for (final c in cities) ListTile(title: Text(c))]);
              },
            ),
          ),
        ],
      ),
    );
  }
}

Combining streams

Rx.combineLatest2 produces a value whenever either input changes, which is perfect for form validation:

final email = BehaviorSubject<String>.seeded('');
final password = BehaviorSubject<String>.seeded('');

final canSubmit = Rx.combineLatest2<String, String, bool>(
  email,
  password,
  (e, p) => e.contains('@') && p.length >= 6,
);
// StreamBuilder<bool>(stream: canSubmit, ...) enables the button.

Operators you will use most

OperatorDoes
map, whereTransform and filter (built into Dart)
debounceTimeWait for a pause
throttleTimeAt most one value per period
distinctSkip repeated values
switchMapStart a new inner stream, cancel the old one
combineLatestCombine the latest values of several streams
startWithEmit a first value immediately

Try it yourself

Add a “minimum 2 letters” rule to the search with .where((q) => q.length >= 2 || q.isEmpty), and show “Type at least 2 letters” when it is too short.

Practise in the playground Updated