Testing Flutter input debounce - no frameworks required

Posted on 25 January 2025 in Flutter · About test, async · 11 minutes reading
aron visuals BXOXnQ26B7o unsplash
Figure 1. Photo by Aron Visuals on Unsplash

Introduction

The term "debounce" comes from the eletronics industry as a technique to mitigate the effects of "contact bounce" (also called chatter) that affects mechanical relays and switches. When their contacts strike together, instead of a clear transition from "open" to "close", a pulsed electric current arrises, leading fast enough circuits, such as microcontrollers, to interpret as multiple on-off transitions. Debouncing in this context ensures correctness.

In the higher level context of developing user interfaces, debouncing an input means waiting for user inactivity after entering some input before acting on that input. For example, a password creation input field may have a strict validation schema, but it would be annoying to display error messages at every character the user types. On the other hand, waiting until they leave the field or submit the input to show validation errors is an outdated practice that also harms user experience, as the user is no longer at the closest context to take the appropriate corrective action. What we usually want to do is delay error messages until the user stops entering more data. Besides improving user experience, debouncing may also be used for efficiency, like delaying API calls that depend on user input, preventing multiple incomplete calls.

How do we debounce inputs with Flutter?

Richer libraries with batteries included such as Bloc, Riverpod and RxDart comes with solutions for that problem. If, for any reason, you need to stick with just the Flutter framework, it’s quite intuitive to implement something on top of Future.delayed or Timer from dart:async. In fact a quick search on the web will show examples like the snippet below:

import "dart:async";

class Debouncer {
  final Duration duration;
  final VoidCallback action;

  Timer? _timer;
  Debouncer(this.duration, {required this.action});

  void dispose() {
    _timer?.cancel();
  }

  void start() {
    if (_timer?.isActive == true) {
      _timer!.cancel();
    }

    _timer = Timer(duration, action);
  }
}

You’d use that class as a field of a stateful widget’s state object that contains the input to debounce as a child in its widget tree. To see it in action, first create a simple app that demonstrates the need for debouncing. The sample below contains a text field in main page that should display an error message if the user inputs less than 12 characters.

$ flutter create -t app debouncing
$ cd debouncing
$ flutter pub get
$ flutter run -d linux

Replace the contents of lib/main.dart with the following:

import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

/// Returns an error message or null if the input is valid.
String? _hasError(String? value) {
  if (value == null) {
    return 'Cannot be null';
  }

  if (value.length < 12) {
    return 'Password is too short';
  }

  return null;
}

class _MyAppState extends State<MyApp> {
  String? _errorText;

  void _onChanged(String? value) {
    setState(() {
      _errorText = _hasError(value);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My App',
      theme: ThemeData(
        useMaterial3: true,
      ),
      home: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(64.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('Enter a valid password. '
                    'It must contain at least 12 characters.'),
                TextField(
                  onChanged: _onChanged,
                  decoration: InputDecoration(
                    errorText: _errorText,
                    labelText: 'Your password',
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

You can now play with the current state of the app. Notice that, when attempting to enter a "password", you’ll be immediately greated with a disturbing error message when you are just beginning your flow.

01 always error
Figure 2. Disturbing error message when starting to type

There comes the Debouncer class for the rescue. Add this implementation in the end of your lib/main.dart:

A Timer-based debouncer.
class Debouncer {
  final Duration duration;
  final VoidCallback action;

  Timer? _timer;
  Debouncer(this.duration, {required this.action});

  void dispose() {
    _timer?.cancel();
  }

  void start() {
    if (_timer?.isActive == true) {
      _timer!.cancel();
    }

    _timer = Timer(duration, action);
  }
}

And modify the _MyAppState class to make use of that debouncer:

class _MyAppState extends State<MyApp> {
  String? _errorText;

  late final Debouncer _debouncer; (1)
  @override
  void initState() {
    super.initState();
    _debouncer = Debouncer(Durations.medium4, action: _allowErrorsToShow);
  }

  bool _showError = false; (2)
  void _allowErrorsToShow() {
    setState(() => _showError = true);
  }

  void _onChanged(String? value) {
    final err = _hasError(value); (3)
    // We want to show success immediately...
    if (err == null) {
      setState(() => _errorText = null);
      return;
    }
    //... but delay showing errors.
    _errorText = err;
    _debouncer.start();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My App',
      theme: ThemeData(
        useMaterial3: true,
      ),
      home: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(64.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('Enter a valid password. '
                    'It must contain at least 12 characters.'),
                TextField(
                  onChanged: _onChanged,
                  decoration: InputDecoration(
                    // errorText: _errorText, (4)
                    errorText: _showError ? _errorText : null, (4)
                    labelText: 'Your password',
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
1Adding a _debouncer field marked as late because it’s initialisation happes in initState() as it depends on this. When the timer expires it runs the private method _allowErrorsToShow().
2Adding a _showError to be only set to true when the Timer expires, via the method mentioned above.
3Modifying _onChanged to start the debouncer timer if the input is invalid.
4Checking if showing errors is allowed via the _showError flag, instead of directly showing the current value of _errorText.

Let’s discuss more in depth what will happen when the user enters some text in the input field, that is, when the _onChanged() method is called:

  void _onChanged(String? value) {
    final err = _hasError(value); (3)
    // We want to show success immediately...
    if (err == null) {
      setState(()=>_errorText = null);
      return;
    }
    //... but delay showing errors.
    _errorText = err;
    _debouncer.start();
  }

If the input is valid, then setState() is called immediately, so the widget rebuilds to clear out any previous error state. Otherwise, we don’t do it immediately after assigning a non-null value to errorText as it may change soon again, so we skip some unnecessary rebuilds of our widget. But we trigger the debouncer. Debouncer.start() (re)sets a timer to run the action passed as argument when the timer expires. The timer won’t ever expire while the user keeps typing, as every call to Debouncer.start() cancels the existing timer and creates a new one. If the user stops typing for the defined duration of the timer, than setState() kicks-in, causing the widget to rebuild with the latest widget state.

Now the user can type peacefully, without distracting error messages, which are only shown when the user settles, no need to leave the field or hit "Enter", though.

02 no error yet
Figure 3. Widget patiently waiting on the user to type without screaming error messages at them.

It works! But…​

Can we assert the appropriate error message is displayed in a widget test?

Let’s replace all contents of test/widget_test.dart with the following naïve test case:

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

import 'package:debouncing/main.dart';

void main() {
  testWidgets('Displays too short error message', (WidgetTester tester) async {
    // Build our app and trigger a frame.
    await tester.pumpWidget(const MyApp());

    // Finds the text input field and enters a too short input.
    final input = find.byType(TextField);
    expect(input, findsOneWidget);
    await tester.enterText(input, "Short");

    // Waits until animations complete and the error message is shown, right? (1)
    await tester.pumpAndSettle();

    // Verify that errors are shown.
    expect(find.text('Password is too short'), findsOneWidget);
  });
}
1Flutter test docs say that pumpAndSettle() waits until no more frames are scheduled, which ensures completion of animations, for example.
$ flutter test

|| ══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞═════════════════════════════════════
|| The following TestFailure was thrown running a test:
|| Expected: exactly one matching candidate
||   Actual: _TextWidgetFinder:<Found 0 widgets with text "Password is too short": []>
||    Which: means none were found but one was expected
||
...

Before you start complaining that pumpAndSettle() didn’t do its job, you must remember that Flutter tests run under a fake async environment, where time is an illusion controlled by the testing framework. As Timer is a low level construct that knows nothing about flutter_test goodies, thus for the purposes of the Timer started by the Debouncer object the elapsed time was not enough. When dealing with trully asynchronous APIs that are not fully controlled by the Flutter framework, we need to force the test to actually run on a true async environment. That’s what runAsync() is for. So, to fix our test case, let’s add some real asynchronous action that takes at least as long as our timer duration before pumping the frames:

void main() {
  testWidgets('Displays too short error message', (WidgetTester tester) async {
    // Build our app and trigger a frame.
    await tester.pumpWidget(const MyApp());

    // Finds the text input field.
    final input = find.byType(TextField);
    expect(input, findsOneWidget);

    // Enters a short text, waits until animations complete and the error is shown.
    await tester.runAsync(() async {
      await tester.enterText(input, "Short");
      await Future.delayed(Durations.long1);
      await tester.pumpAndSettle();
    });

    // Verify that errors are shown.
    expect(find.text('Password is too short'), findsOneWidget);
  });
}

Test pass!

$ flutter test
00:02 +1: All tests passed!

So far so good, but it’s annoying to have to keep track of whether the components we use depend on true asynchronous events when writing widget tests. There should be a more plug-n-play way of implementing this so our naïve tests would just work, don’t you think so?

My answer to that question is: let the Framework handle the time lapse. As I said before, Timer is a low level construct. Think for a moment: how don’t we have trouble to test code that relies on animations? How AnimationController, for example, tracks time? The answer is the Ticker.

Doing like the AnimationController does: enter the Ticker

A ticker serves one purpose: offering an API to run a callback once per frame, when enabled. Being "per frame" already tells us that Flutter has full awareness and control of it. After the Ticker is started, it’s callback will be invoked at every frame with the current amount of time elapsed since it was started. We can leverage that information to change our debouncer to use a Ticker instead of a Timer and let the framework control the details. If you checked the documentation, though, you’d see that we don’t create a Ticker, but rather we obtain one from a TickerProvider. Does that name sound familiar? Do you remember having ever had to mix in with SingleTickerProviderMixin to be able to pass this when instantiating an AnimationController object? That’s exactly what happens. When you add that mixin to your stateful widget state class and pass it to the AnimationController as the vsync parameter, under the hood the controller calls vsync.createTick(_some_internal_callback), stores and uses the ticker retrieved to get its notion of elapsed time.

Let’s do something similar, then. Replace the Debouncer class implementation in lib/main.dart with the following contents:

Ticker-based Debouncer class implementation.
class Debouncer {
  final Duration duration;
  final VoidCallback action;
  late Ticker _ticker;

  Debouncer(
    this.duration, {
    required this.action,
    required TickerProvider vsync,
  }) {
    _ticker = vsync.createTicker(_onTick);
  }

  void _onTick(Duration elapsed) {
    if (elapsed >= duration) {
      _ticker.stop();
      // Minor precaution in case action calls `setState` or something else that triggers widget rebuilds:
      // let the current frame finish rendering first.
      scheduleMicrotask(action);
    }
  }

  void dispose() => _ticker.stop();

  void start() {
    _ticker.stop();
    _ticker.start();
  }
}

Since the API didn’t changed significantly, few things need to change in the implementation of _MyAppState:

New implementation of _MyAppState.
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin { (1)
  String? _errorText;

  late final Debouncer _debouncer;
  @override
  void initState() {
    super.initState();
    _debouncer =
        Debouncer(Durations.medium4, action: _allowErrorsToShow, vsync: this); (2)
  }
  ...
1_MyAppState mixes in with SingleTickerProviderStateMixin
2so we can pass it as the vsync parameter when initialising the _debouncer object.

The rest of the class remains unchanged. We can then reset the test back to the naïve implementation:

Test implementation as we originally intended.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:debouncing/main.dart';

void main() {
  testWidgets('Displays too short error message', (WidgetTester tester) async {
    // Build our app and trigger a frame.
    await tester.pumpWidget(const MyApp());

    // Finds the text input field and enters a too short input.
    final input = find.byType(TextField);
    expect(input, findsOneWidget);
    await tester.enterText(input, "Short");

    // Waits until animations complete and the error message is shown.
    await tester.pumpAndSettle();

    // Verify that errors are shown.
    expect(find.text('Password is too short'), findsOneWidget);
  });
}

Èt voilá! Our naïve test case just works! It does so because our debouncer now relies on the Flutter framework to track time, instead of using lower level APIs.

$ flutter test
00:02 +1: All tests passed!

What about performance? Is this heavier than the timer-based solution? With the previous approach, our callbacks were rarely called, but we created timers too often. That cost can be somewhat reduced by using package:async RestartableTimer, which allows you to reset() the timer count instead of creating new a Timer at every character the user types. The current (ticker-based) implementation adds the cost of an extra function call per frame: the Debouncer._onTick() method. That method was crafted to avoid doing any work. It either returns without doing anything if not enough time elapsed, or schedules a microtask to be processed after the current frame completes. As all benchmarks are illusions, unless they replicate a real use case, I defer to the reader performing this comparison in the way that suits best their interest. The timer-based approach is likely more accurate than the ticker based, as the time tracking resolution now is the period between frames (the inverse of the frame rate ;) ), thus certainly not at the microssecond scale. The good news is: we don’t need such accuracy when dealing with user input, as humans won’t interact with the app at a high enough speed to notice the difference.

Conclusion

In conclusion, next time your Flutter application needs to take time-sensitive actions, consider using the Ticker approach instead of Timer to track time if lower resolution is not an issue, as that will make the life of your callers easier when it comes to testing their stuff without relying on priviledged knowledge of your implementation details.

Cheers!

This post is signed. To verify it’s signature, run the following commands on a Bash-like shell:

gpg --keyserver keyserver.ubuntu.com --recv-keys C8B952808BDB6325
POST_URL="https://cn.olivec.dev/blog/posts/flutter-testing-debounce/"
gpg --verify <(curl -sS "${POST_URL}index.html.asc") <(curl -sS "${POST_URL}")

Those commands will import my public key into your gpg keyring and use it to verify the signature of this post.