Writing dart; the runOnce concoction!

September 2023 ยท 6 minute read

The problem

This entry covers a little adventure I had with dart based on an idea that popped into my mind. What initially caused the idea to manifest itself was working with Flutter’s state management. Flutter has what is called a StatefulWidget which as the name implies contains the functionality to contain and handle state over time. What this essentially means is that Flutter has a build method which is called everytime the state has changed; either to something new or the same state in fact. The build method renders something based on the new state.

A little diagram detailing the Stateful widget

Now in Flutter you cannot rely on this build method being called only when you change the state. It can be called arbitrarily when Flutter deems it necessary. Because of this inherent truth you must write your build method with that in mind. That means your build method shouldn’t have any side effects besides rendering what you want to render. So doing anything else than that is a no go or bad practice. Imagine doing something that isn’t rendering something visually; network IO or what have you. This sounds wrong but can happen if you use something on top of Flutter’s state management. In my case it was using the state_notifier library which changes the way you manage state in your Flutter application. Not by much but it changes the usage slightly.

Now when using Flutter’s inbuilt state management and you for example want to present a new view that renders something new you would typically have this registered as a callback on a button. You press this button and a new view is pushed, by the callback, to your screen. This is done with what is called a route in Flutter.

Something like this;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('First Route'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('Open route'),
          onPressed: () {
            // Navigate to second route when pressed.
            Navigator.push(
   		context,
    		MaterialPageRoute(builder: (context) => const SecondRoute()),
  	    );
          },
        ),
      ),
    );
  }
}

As this is done by a callback on onPressed it has no side effect on the build method.

With the aforementioned state_notifier library and with how state is handled by this library you can have instances where side effects in your build method are easy to encounter. Imagine you have some state and when that state changes you want to do something that might be in your build method. This becomes a problem because you cannot rely on Flutter calling build only when your state changes.

Something like this;

// This example uses the state_notifier package
class _SomeWidgetState extends State<SomeWidget> {
  @override
  Widget build(BuildContext context) {
    final state = context.watch<MyState>();
    
    return state.when(
      data: (data) => Placeholder(),
      loading: () => Placeholder(),
      finished: () {
      	/// You cannot do something other than
	/// rendering without possible side effects.
	/// You could end up pushing this route more than once.
        Navigator.pushNamed(context, '/b');

	return Placeholder();
      },
    );
  }  
}

Of course you would do route navigation in another way than above and not use the MyState state in this fashion for this. But what if you could?

With this chiseled in stone I now present you my concoction. It’s not much more than a little exercise or experiment but it actually works!

The solution?

The ‘?’ is there because this really isn’t a solution but more of an experiment albeit it actually solves our problem.

An intro to run_once

run_once is an extremely simple library for running ‘things’ exactly once. No matter how many times that ‘thing’ is called.

How does it work?

Well it’s very simple. We basically use the StackTrace.current and carefully use this to keep record on when a function has been called. It means that the call stack is basically used as a unique identifier for when and where a function has been called.

Rationale

This library could be considered an anti-pattern in itself and a little exercise, but imagine a Flutter StatefulWidget with its build that you cannot rely on being called only when you yourself call setState. In this case you could use runOnce to run something once during this widget’s lifetime no matter the times build was called.

Worth a mention; there also is the AsyncMemoizer.runOnce for async functions from package:async but that one isn’t aware of the call stack and works internally in a much simpler manner.

Usage

Flutter with a StatefulWidget example;

import 'package:run_once/run_once.dart';

// This example uses the state_notifier package
class _SomeWidgetState extends State<SomeWidget> {
  @override
  void dispose() {
    /// When this is called [runOnce] will be able to run
    /// once again for this class alone.
    runOnceDestroy();
    
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    final state = context.watch<MyState>();
    
    return state.when(
      data: () => Placeholder(),
      finished: () {
        runOnce(() {
          /// This is always only called once 
          /// until [dispose] is called.
          Navigator.pushNamed(context, '/b');
        });

        return Placeholder();
      },
    );
  }  
}

Pure dart example;

import 'package:run_once/run_once.dart';

void main() async {
  /// This will print 'called 1' exactly 1 time although
  /// we call this function 10 times.
  for (var i = 0; i < 10; i++) {
    runOnce(() {
      print('called 1');
    });
  }

  /// This will print 'called 2' exactly 2 times as the [runOnce] simply
  /// isn't called in the same spot.
  runOnce(() {
    print('called 2');
  });
  runOnce(() {
    print('called 2');
  });

  /// This will print 'called 3' exactly 3 times although
  /// we call this function 15 times.
  /// Note the `forDuration` is exactly a [Duration] of 500 milliseconds.
  /// Using `forDuration` means for a duration of 500 milliseconds [runOnce]
  /// can only run once.
  /// So we loop 15 times in this loop and wait 100 milliseconds in each
  /// loop cycle; thus 'called 3' is printed exactly 3 times.
  for (var i = 0; i < 15; i++) {
    await Future.delayed(Duration(milliseconds: 100));

    runOnce(() {
      print('called 3');
    }, forDuration: Duration(milliseconds: 500));
  }
}

Here’s a brief explanation of run_once API;

  • runOnce(Function function, {Duration? forDuration})

The function is the function to be called once. Using forDuration means for a duration runOnce can only run once.

  • runOnceDestroy()

Calling runOnceDestroy makes runOnce able to run once again. Note that this call is “scoped” based on the lexical scope where runOnceDestroy was called. The normal “use case” is to call runOnceDestroy somewhere in the same class that called runOnce.

Closing thoughts

This was just a fun little experiment. I’m not sure of the usefulness of run_once but I think it’s an interesting concoction I haven’t seen elsewhere. This library isn’t really tested very comprehensively at all. So it might not work in every use case.

That’s all for now; if you have any questions or request please comment or send me an email!

Try out run_once or simply checkout the source code!