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.
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!