Skip to content

6. Asynchronous Processing and Shared State

6.1. Example 19: Future, Stream, and Timers

Many things happen “later”: timer ticks, keystrokes, a server’s response. [Dart] has two types for this, at the core of the language:

  • [Future<T>]: ONE value, later (a server’s response, a choice made in a window);
  • [Stream<T>]: a SEQUENCE of values over time (ticks, messages, GPS positions...).

[Flutter] has a widget to display each one, rebuilding it when a value arrives: [FutureBuilder] and [StreamBuilder]. And for what must happen after a delay: [Timer].

// =========================================================================
// lib/ex19_asynchronous/main.dart - Future, Stream, timers
// -------------------------------------------------------------------------
// Many things happen "later": the ticks of a timer, the
// keystrokes, the response of a server. Dart has two types for
// this, at the heart of the language:
//   - Future<T>: ONE value, later (the response of a server);
//   - Stream<T>: a SEQUENCE of values, over time (ticks,
//     messages...).
// Flutter has a widget to display each one, rebuilding it when a
// value arrives: FutureBuilder and StreamBuilder.
// And for what must happen after a delay: Timer (dart:async).
// =========================================================================

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';

const doctors = ['Marie PELISSIER', 'Paul TATOU', 'Sophie DUPONT', 'Jacques DURAND', 'Martine PELLETIER'];
final _random = Random();

// a simulated "server": the response (a Future) arrives after a random delay
Future<List<String>> search(String text) async {
  await Future<void>.delayed(Duration(milliseconds: 200 + _random.nextInt(600)));
  return doctors.where((m) => m.toLowerCase().contains(text)).toList();
}

// 1. a Stream produced by an "async*" function: each yield SENDS a
// value to those who listen to the Stream
Stream<int> countdown(int start) async* {
  for (var remaining = start; remaining >= 0; remaining--) {
    yield remaining;
    if (remaining > 0) await Future<void>.delayed(const Duration(seconds: 1));
  }
}

String currentTime() => DateTime.now().toString().substring(11, 19);

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const AsynchronousPage(),
    ),
  );
}

class AsynchronousPage extends StatefulWidget {
  const AsynchronousPage({super.key});

  @override
  State<AsynchronousPage> createState() => _AsynchronousPageState();
}

class _AsynchronousPageState extends State<AsynchronousPage> {
  final List<String> _log = [];
  // the Stream and the Future are created ONCE (and not on each build:
  // each call of build would restart the countdown!)
  final Stream<int> _countdown = countdown(20);
  // 3. a Future that completes after one second
  final Future<String> _message = Future.delayed(const Duration(seconds: 1), () => 'three (one second later)');

  // 2. the search as you type
  Timer? _debounce;
  int _requestNumber = 0; // the number of the LAST search launched
  List<String> _results = [];

  void _trace(String message) => setState(() => _log.add('${currentTime()} $message'));

  // called on each keystroke
  void _onInput(String text) {
    // the DEBOUNCE: each keystroke cancels the timer of the previous
    // keystroke; the search only starts after 400 ms without a keystroke
    _debounce?.cancel();
    _debounce = Timer(const Duration(milliseconds: 400), () => _launch(text.trim().toLowerCase()));
  }

  Future<void> _launch(String text) async {
    // fewer than two letters: no search, no result
    if (text.length < 2) {
      _requestNumber++; // a response still expected will be ignored
      setState(() => _results = []);
      return;
    }
    // "stale": a response that arrives AFTER another search has been
    // launched must be ignored (otherwise the slow response of an old
    // search would overwrite the one of the new one)
    final number = ++_requestNumber;
    _trace('search sent: "$text"');
    final found = await search(text);
    if (!mounted) return;
    if (number != _requestNumber) {
      _trace('response ignored (stale): "$text"');
      return;
    }
    setState(() => _results = found);
  }

  @override
  void dispose() {
    _debounce?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final titleStyle = Theme.of(context).textTheme.titleSmall;
    return Scaffold(
      appBar: AppBar(title: const Text('Example 19 - asynchrony')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('A countdown (a Stream and StreamBuilder)', style: titleStyle),
                  // StreamBuilder: rebuilds its content on each value of the Stream;
                  // snapshot.data: the last value received (null before the first one)
                  StreamBuilder<int>(
                    stream: _countdown,
                    builder: (context, snapshot) =>
                        Text(snapshot.hasData ? '${snapshot.data}' : '...', style: Theme.of(context).textTheme.displaySmall),
                  ),
                ],
              ),
            ),
          ),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('A search as you type (debounce, stale responses)', style: titleStyle),
                  TextField(
                    decoration: const InputDecoration(hintText: 'at least two letters: pel, dur...'),
                    onChanged: _onInput,
                  ),
                  for (final r in _results) Text('• $r'),
                  if (_results.isEmpty) const Text('no result', style: TextStyle(color: Colors.grey)),
                ],
              ),
            ),
          ),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('Waiting for a Future (FutureBuilder)', style: titleStyle),
                  // as long as the Future is not complete: the waiting content
                  FutureBuilder<String>(
                    future: _message,
                    builder: (context, snapshot) => switch (snapshot.connectionState) {
                      ConnectionState.done => Text('received: ${snapshot.data}'),
                      _ => const Text('waiting...', style: TextStyle(color: Colors.grey)),
                    },
                  ),
                ],
              ),
            ),
          ),
          Text('Log', style: titleStyle),
          for (final (i, line) in _log.indexed) Text('${i + 1}. $line', style: const TextStyle(fontSize: 12)),
        ],
      ),
    );
  }
}
  • lines 24–26: a simulated “server”: a function [async], whose response (a [Future]) arrives after a random delay. [await Future<void>.delayed(...)] waits for this delay without blocking the application;
  • lines 31–34: a [Stream] generated by a function [async*] (with an asterisk): an asynchronous generator. Each [yield] sends a value to those listening to [Stream]; between values, the function waits one second;
  • lines 61–63: the [Stream] and the [Future] are created ONCE, in fields of the [State] object, and not in [build]: each rebuild would restart the countdown! This is the most common error with [FutureBuilder] and [StreamBuilder];
  • lines 66–68: the status of the search as the user types: a timer, the number of the last search initiated, and the results;
  • lines 73–77: debouncing: the program waits for the user to pause before querying the server, rather than sending a request with every keystroke. Each keystroke resets the timer from the previous keystroke ([cancel()]) and starts a new one: the search is only triggered after 400 milliseconds without a keystroke;
  • Lines 80–98: the search. An “out-of-date” response (one that arrives AFTER another search has been launched) must be ignored: otherwise, the slow response from an old search would override that of the new one. Each search is assigned a number ([++_requestNumber]); when its response arrives, if it is no longer the number of the most recent search, it is ignored. [if (!mounted) return;]: the screen may have disappeared while waiting;
  • lines 102–104: the timer is stopped if the screen disappears;
  • lines 124–127: [StreamBuilder] listens for [Stream] and reconstructs its content with each new value. [builder] receives a snapshot ([snapshot], an [AsyncSnapshot]): [snapshot.hasData], [snapshot.data] (the last value received), [snapshot.hasError], [snapshot.connectionState]. When the widget disappears, [StreamBuilder] unsubscribes on its own;
  • lines 158–162: [FutureBuilder] waits for [Future]: until it is finished, the placeholder content; then, its value. [switch] on [snapshot.connectionState] chooses one or the other.

We type “p,” “pe,” “pel” quickly: only one search is sent. Then “du,” followed by “dup” before the response: the response to “du” arrives too late, and it is ignored if “dup” has already received its response:

In [JavaScript], the same thing is written using promises and timers; [Angular] adds the "observables" from [RxJS]. In [Dart], [Future] and [Stream] are in the base library, and [async], [await], [async*], [yield], and [await for] are in the language: this is why [Flutter] does not require a library for asynchronous operations.

6.2. Example 20: A Shared State

Same question as in previous lessons: what are the “scopes” for an application’s data?

  • A widget’s local state disappears when the widget is removed;
  • the state of a “store” is shared by all widgets and survives their removal… but not the application’s shutdown;
  • State stored on the device survives when the application is shut down.

6.2.1. The shopping cart

// =========================================================================
// lib/ex20_shared_state/stores/cart.dart - a SHARED STATE, with provider
// -------------------------------------------------------------------------
// A "store" is a state that lives OUTSIDE the screens, in a SINGLE
// instance, shared by all those who read it. Example 14 did it
// by hand (InheritedNotifier); the provider package, the most used
// with Flutter (and recommended by its documentation), does the same thing
// in one line:
//
//     ChangeNotifierProvider(create: (_) => Cart(), child: ...)
//
// The store is an ordinary ChangeNotifier. Usual organization:
//   - the STATE: PRIVATE data (_items);
//   - the ACTIONS: methods, the only ones to modify the state, which end
//     with notifyListeners();
//   - the computed values: getters.
// A widget accesses it through its context:
//   - context.watch<Cart>()  : read AND subscribe (rebuilt on each change);
//   - context.read<Cart>()   : read without subscribing (to call an action);
//   - context.select<Cart, int>((p) => p.count) : subscribe to ONE value:
//     rebuilt only if THIS value changes.
// The store lives as long as its provider lives: it survives the removal of a
// screen... but not the stopping of the application.
// =========================================================================

import 'package:flutter/foundation.dart';

typedef Item = ({String label, double price});

class Cart extends ChangeNotifier {
  final List<Item> _items = [];

  // a NON-MODIFIABLE view of the list: only the actions change it
  List<Item> get items => List.unmodifiable(_items);
  int get count => _items.length;
  double get total => _items.fold(0, (s, a) => s + a.price);

  void add(Item item) {
    _items.add(item);
    notifyListeners();
  }

  void clear() {
    _items.clear();
    notifyListeners();
  }
}

A store is a state that spans multiple screens, existing as a single instance shared by all who access it. Example 14 implemented this manually. The [provider] package, the most commonly used one along with [Flutter] (and the one recommended in its documentation), does the same thing in a single line: [ChangeNotifierProvider(create: (_) => Cart(), child: ...)].

  • Line 31: the state: private data;
  • lines 34–36: calculated values: getters. [List.unmodifiable]: a read-only view of the list; only actions can modify it;
  • lines 38–45: the actions: methods—the only ones that modify the state—which end with [notifyListeners()].

6.2.2. Preferences

// =========================================================================
// lib/ex20_shared_state/stores/preferences.dart - a state that survives the stopping
// -------------------------------------------------------------------------
// For a piece of data to survive the stopping of the application, it must be stored
// ON THE DEVICE. The shared_preferences package offers a small dictionary
// key -> value (bool, int, double, String), specific to the application and
// kept on the device (on the web: in the browser's localStorage).
//   - at startup, the stored values are read (main.dart);
//   - on each change, they are written again.
// NEVER store sensitive data there (password, token): they are
// written in clear text.
// =========================================================================

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

class Preferences extends ChangeNotifier {
  static const _darkKey = 'example20.dark';
  static const _sizeKey = 'example20.size';

  final SharedPreferences _storage;
  bool _dark;
  double _size; // size of the text

  // the initial state: what the device has stored, otherwise the default values
  Preferences(this._storage)
    : _dark = _storage.getBool(_darkKey) ?? false,
      _size = _storage.getDouble(_sizeKey) ?? 16;

  bool get dark => _dark;
  double get size => _size;

  // one optional named parameter per preference: only what is provided changes
  void update({bool? dark, double? size}) {
    if (dark != null) {
      _dark = dark;
      _storage.setBool(_darkKey, dark); // writing on the device
    }
    if (size != null) {
      _size = size;
      _storage.setDouble(_sizeKey, size);
    }
    notifyListeners();
  }
}

For data to survive after the application is shut down, it must be stored on the device. The [shared_preferences] package provides a small key->value dictionary ([bool], [int], [double], [String]), specific to the application and stored on the device (on the web: in the browser’s [localStorage]).

  • Line 21: the storage data, received upon creation;
  • lines 26–28: the initial state: what the device has stored, or default values otherwise. The fields are initialized by the constructor’s “initialization list” (after the colon), before the body;
  • Lines 34–43: a single action, with optional named parameters: only the provided values are changed, and they are immediately written to the device ([setBool], [setDouble]).

NEVER store any sensitive data (passwords, tokens) there: such data is written in plain text. For this purpose, there is the [flutter_secure_storage] package, which uses the system’s vault ([Keystore] from [Android], [Keychain] from [iOS]).

6.2.3. The widgets

// lib/ex20_shared_state/components/shop.dart - ADDS to the shared cart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../stores/cart.dart';

const products = <Item>[(label: 'Consultation', price: 30), (label: 'Vaccine', price: 12.5)];

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

  @override
  Widget build(BuildContext context) {
    // read: we only CALL an action; Shop is not
    // rebuilt when the cart changes
    final cart = context.read<Cart>();
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Shop', style: Theme.of(context).textTheme.titleSmall),
            for (final p in products)
              TextButton.icon(
                icon: const Icon(Icons.add),
                label: Text('${p.label} (${p.price} €)'),
                onPressed: () => cart.add(p),
              ),
          ],
        ),
      ),
    );
  }
}
  • Line 16: [context.read<Cart>()] reads the shopping cart provided above; WITHOUT subscribing: the store simply calls an action; it does not need to be rebuilt when the shopping cart changes.
// lib/ex20_shared_state/components/cart_summary.dart - READS the same cart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../stores/cart.dart';

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

  @override
  Widget build(BuildContext context) {
    // select: rebuilds ONLY if the count or the total changes
    final count = context.select<Cart, int>((p) => p.count);
    final total = context.select<Cart, double>((p) => p.total);
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Row(
          children: [
            const Icon(Icons.shopping_cart),
            const SizedBox(width: 8),
            Expanded(child: Text('$count item(s), $total €')),
            TextButton(
              onPressed: count == 0 ? null : () => context.read<Cart>().clear(),
              child: const Text('Clear'),
            ),
          ],
        ),
      ),
    );
  }
}
  • Lines 13–14: [context.select] reads ONE value of the shopping cart and subscribes the widget to THAT value: it is rebuilt only if that value changes. [context.watch<Cart>()] would read and subscribe to the entire shopping cart.
// lib/ex20_shared_state/components/local_counter.dart - a LOCAL state, which disappears with the widget
import 'package:flutter/material.dart';

class LocalCounter extends StatefulWidget {
  final String title;

  const LocalCounter({super.key, required this.title});

  @override
  State<LocalCounter> createState() => _LocalCounterState();
}

class _LocalCounterState extends State<LocalCounter> {
  int _clicks = 0;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(widget.title),
        trailing: OutlinedButton(onPressed: () => setState(() => _clicks++), child: Text('$_clicks click(s)')),
      ),
    );
  }
}

A local state that disappears with the widget.

6.2.4. The application

// =========================================================================
// lib/ex20_shared_state/main.dart - three "scopes" of data
// -------------------------------------------------------------------------
//   - the LOCAL state of a widget (LocalCounter) disappears with it;
//   - the state of a STORE (the cart) is shared by all the widgets
//     and survives their removal - but not the stopping of the application;
//   - the state stored on the device (the preferences) survives the stopping.
// And Visibility(maintainState: true): the content is HIDDEN, but not
// removed; its local state is kept.
// Uncheck then check again "show the area", then stop and restart
// the application: compare.
// =========================================================================

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'components/shop.dart';
import 'components/local_counter.dart';
import 'components/cart_summary.dart';
import 'stores/cart.dart';
import 'stores/preferences.dart';

Future<void> main() async {
  // essential before using a "plugin" (code specific to Android, iOS,
  // web...) like shared_preferences, BEFORE runApp
  WidgetsFlutterBinding.ensureInitialized();
  final storage = await SharedPreferences.getInstance();
  runApp(
    // MultiProvider: several providers, nested in one another,
    // above the whole application
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => Cart()),
        ChangeNotifierProvider(create: (_) => Preferences(storage)),
      ],
      child: const Application(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    // watch: the application is rebuilt when the preferences change
    final preferences = context.watch<Preferences>();
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      // the dark theme, and the choice between the two
      darkTheme: ThemeData(colorSchemeSeed: Colors.indigo, brightness: Brightness.dark),
      themeMode: preferences.dark ? ThemeMode.dark : ThemeMode.light,
      // builder: wraps ALL the screens; here, the size of the text
      builder: (context, child) => MediaQuery(
        data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(preferences.size / 16)),
        child: child!,
      ),
      home: const SharedStatePage(),
    );
  }
}

class SharedStatePage extends StatefulWidget {
  const SharedStatePage({super.key});

  @override
  State<SharedStatePage> createState() => _SharedStatePageState();
}

class _SharedStatePageState extends State<SharedStatePage> {
  bool _areaVisible = true;

  @override
  Widget build(BuildContext context) {
    final preferences = context.watch<Preferences>();
    return Scaffold(
      appBar: AppBar(title: const Text('Example 20 - a shared state')),
      body: ListView(
        padding: const EdgeInsets.all(12),
        children: [
          SwitchListTile(
            title: const Text('dark theme'),
            value: preferences.dark,
            onChanged: (v) => preferences.update(dark: v),
          ),
          ListTile(
            title: Text('text size: ${preferences.size.round()}'),
            subtitle: Slider(
              value: preferences.size,
              min: 12,
              max: 22,
              divisions: 10,
              onChanged: (v) => preferences.update(size: v),
            ),
          ),
          CheckboxListTile(
            title: const Text('show the area'),
            value: _areaVisible,
            onChanged: (v) => setState(() => _areaVisible = v!),
          ),
          // removed: these widgets disappear, with their local state
          if (_areaVisible) ...[const Shop(), const CartSummary(), const LocalCounter(title: 'Local state')],
          // hidden: the widget stays in memory, its state is kept
          Visibility(
            visible: _areaVisible,
            maintainState: true,
            child: const LocalCounter(title: 'Local state, in a Visibility'),
          ),
          const Padding(
            padding: EdgeInsets.symmetric(vertical: 8),
            child: Text('Outside the area, a second summary of the SAME cart (it stays there when the area disappears):'),
          ),
          const CartSummary(),
        ],
      ),
    );
  }
}
  • Lines 27–28: [shared_preferences] is a "plugin": it uses platform-specific code ([Android], web...). To use it BEFORE [runApp], you must first initialize [Flutter] ([ensureInitialized]). Reading from storage is asynchronous: [main] waits for it to complete;
  • lines 32–35: [MultiProvider] places multiple providers, one inside the other, at the top of the entire application. [ChangeNotifierProvider] creates [ChangeNotifier] ([create]), provides it to its descendants, rebuilds those that subscribe to it when it changes, and releases it when it disappears;
  • line 48: the application reads the preferences and subscribes to them; it is rebuilt when they change;
  • lines 53–54: two themes, light and dark, and the option to choose between them ([themeMode]);
  • lines 56–58: the [builder] from [MaterialApp] wraps ALL the screens. Here, the text size for the entire app is changed: [MediaQuery] describes the screen and the user’s settings (including [textScaler], the text magnification factor, which can also be adjusted in the phone’s accessibility settings);
  • line 104: removed; the area disappears, along with the local state of its widgets;
  • Lines 106–109: [Visibility] with [maintainState: true] HIDES its child without removing it: its state is preserved. (This is what [<Activity mode="hidden">] does in the [React] course.)

We add two items, click once on each local counter (on the left); we remove the area and switch to dark mode (in the middle); we restore the area (on the right):

The shopping cart survived the removal of the section (as shown in the second summary); the removed counter reset to zero; the hidden counter retained its value. Stop the app and restart it: the dark theme and text size are preserved, and the shopping cart is empty.

Why [provider]? There are many state management libraries for [Flutter] ([Riverpod], [Bloc], [GetX], [MobX]...). [provider] is the simplest: it is simply a convenient way to write the [InheritedWidget] from Example 14, based on the [ChangeNotifier] from [Flutter]. This is the one used in the architecture guide for the [Flutter] documentation, and the one that will be used in the case study.