“Stop” using state management libraries

sahar vanunu
Flutter Community
Published in
9 min readApr 26, 2022

Since the release of flutter in 2017, I have seen many changes in the platform, mainly in state management libraries such as provider, bloc, Getx, Mobx, and more.

Most beginners start using libraries without yet knowing what is going on behind the scenes. As a result, when asked a question during an interview regarding the behind-the-scenes aspects, maybe 5% of interviewees were actually able to answer.

In this article, I will explain the most important underlying components for most libraries, including:

  • FutureBuilder
  • StreamBuilder
  • ChangeNotifier
  • Set State (yes, I know that most users are familiar with this, but I will explain its relevance below)

ChangeNotifier:

These component is used often in “Provider”, but wait, how do it truly work without the use of additional libraries?!

First example how to implement it

HomeController

controller that increment the count variable then notifyListeners called.

class HomeController extends ChangeNotifier {
var _count = 0;
get count => _count;
set count(value) {
_count = value;
}
increment(){
_count++;
notifyListeners();
}
}

HomePage

Handle to display our count changes

class HomePage extends StatefulWidget {
const HomePage2({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final controller = HomeController();
@override
void initState() {
controller.addListener(() {
print("${controller.count}");
setState(() {});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("My app bar"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("${controller.count}"),
OutlinedButton(
child: const Text("increment"),
onPressed: controller.increment,
),
],
),
),
);
}
}

Behind the scenes ChangeNotifier contains

List<VoidCallback?> _listeners = List<VoidCallback?>.filled(0, null);

This is simply a list of callbacks, so for each “addListener”, it add the most recent callback to the list and the new data created will be changed by “notifyListeners”, resulting them to loop over all the listeners and emit them.

But if you look closely, you can see that setState is being used here (weird :/)

FutureBuilder Vs StreamBuilder:

Before digging into these components, let’s provide a theoretical explanation. For example, have you ever heard about observable pattern?! What is observable?!After reading many descriptions on google, stack-overflow, and more, I struggled to find any satisfactory explanation for it.So, let’s start by keeping it simple.

Observable simply describes a machine that emits events directly to the subscribers (observers)

If you want, I can write an additional article about it, so let me know in the comments

So, how does this relate to flutter?

Flutter, the “native” approach, is reactive, or in other words, observable, and can be divided in two:

Cold observable:

Let’s start with an example.

Future<User> getUser() async{
final user = await OurApi.getUser();
return user;
}

You may read this and think to yourself, “This is just ‘Future’, what do you want from us?”

You are correct, this is an async function, but how is cold observable relevant here? What does it mean?

Essentially it just gives me the data that I need all at once, and that’s it.

“Cold” observable is the “default”.

Hot observable:

Example of Hot Observable:

class HotObservableSample {
final _streamController = StreamController<String>.broadcast();

void start(){
_streamController.stream.listen((event) {
//new value will receive from the event
});
}

void changeValue(String value){
_streamController.add(value);
}
}

Hot works differently. There is the “streamController”, the machine emitting the events, in addition to the observer, subscriber, and listener (no callbacks!!!).

As opposed to cold, this “machine” can emit states without any observers for each state.

In addition, if we observe this machine later, we will only see the newest values, excluding the values from the beginning of the list.

Let’s look at an analogy to summarize the differences:

After describing the differences between these 2 observables , let’s see how this relates to state management.

FutureBuilder: (Cold)

Example:

class FutureBuilderSample extends StatefulWidget {
const FutureBuilderSample({Key? key}) : super(key: key);
@override
State<FutureBuilderSample> createState() => _FutureBuilderSampleState();
}
class _FutureBuilderSampleState extends State<FutureBuilderSample> {

Future<User> getUser() async {
final user = await OurApi.getUser();
return user;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("My app bar"),
),
body: Center(
child: FutureBuilder<User>(
future: getUser(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text("has error: ${snapshot.error}");
} else if (snapshot.hasData) {
final user = snapshot.data!;
return ListTile(
title: Text(user.name),
subtitle: Text("Age: ${user.age}"),
);
} else {
return const CircularProgressIndicator();
}
}),
),
);
}

It will render the widget twice, so how is it that earlier you said cold emits once?! First time, it emits an event saying that it has “started”, the second time will be data or error. But how does this work behind the scenes?!

This is interesting, let’s dig inside the code::

void _subscribe() {
if (widget.future != null) {
final Object callbackIdentity = Object();
_activeCallbackIdentity = callbackIdentity;
widget.future!.then<void>((T data) {
if (_activeCallbackIdentity == callbackIdentity) {
setState(() {
_snapshot = AsyncSnapshot<T>.withData(ConnectionState.done, data);
});
}
}, onError: (Object error, StackTrace stackTrace) {
if (_activeCallbackIdentity == callbackIdentity) {
setState(() {
_snapshot = AsyncSnapshot<T>.withError(ConnectionState.done, error, stackTrace);
});
}
assert(() {
if(FutureBuilder.debugRethrowError) {
Future<Object>.error(error, stackTrace);
}
return true;
}());
});
_snapshot = _snapshot.inState(ConnectionState.waiting);
}
}

Firstly, FutureBuilder is a StatefulWidget ,it receives a parameter that is a function that returns “Future”

This whole process occurs in the “subscribe” function that is called from “initState” or “didUpdateWIdget”.

This method just processes the “future” function and for each state emitted, it will drop the state into AsyncSnapshot.

AsyncSnapshot is simply a callback function that alerts for each state of the future

Waiting declaration:

const AsyncSnapshot.waiting() : this._(ConnectionState.waiting, null, null, null);

Has data success declaration:

const AsyncSnapshot.withData(ConnectionState state, T data): this._(state, data, null, null);

This is what happens when an error occurs

const AsyncSnapshot.withError(
ConnectionState state,
Object error, [
StackTrace stackTrace = StackTrace.empty,
]) : this._(state, null, error, stackTrace);

But if we look closely again, it uses setState again (weird…again :/)

StreamBuilder:

StreamBuilder is also a StatfullWidget.

StreamBuilder receives a parameter that is a function that returns “stream” (i.e. StreamController, which we will discuss later)

StreamBuilder is a hot observable, so if compared to the radio station analogy, the stream builder is the listener and streamController is the radio station that emitting states.

It looks like this:

class HomeTest extends StatefulWidget {
const HomeTest({Key? key}) : super(key: key);
@override
State<HomeTest> createState() => _HomeTestState();
}
class _HomeTestState extends State<HomeTest> {
final _countStreamController = StreamController<int>.broadcast();
var count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("counter"),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
StreamBuilder<int>(
stream: _countStreamController.stream,
builder: (context, snapshot) {
final state = snapshot.data ?? 0;
return Text("$state");
}),
OutlinedButton(
child: const Text("count"),
onPressed: () {
_countStreamController.add(count++);
}),
],
),
),
);
}
}

But why do we need all this boilerplate code?!

Although we can simply use set state, this normally the incorrect way.

If we have several variables (states), we can harm the other states that are not related this specific state. In addition, if the screen is complicated with many widgets depending on multiple states, it can lower the performance.

For these situations, my moto is:

“If you don’t need to render, don’t”

What does this look like behind the scenes:

StreamSubscription<T>? _subscription; 
late S _summary;
@override
void initState() {
super.initState();
_summary = widget.initial();
_subscribe();
}
@override
void didUpdateWidget(StreamBuilderBase<T, S> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.stream != widget.stream) {
if (_subscription != null) {
_unsubscribe();
_summary = widget.afterDisconnected(_summary);
}
_subscribe();
}
}
@override
Widget build(BuildContext context) => widget.build(context, _summary);
@override
void dispose() {
_unsubscribe();
super.dispose();
}
void _subscribe() {
if (widget.stream != null) {
_subscription = widget.stream!.listen((T data) {
setState(() {
_summary = widget.afterData(_summary, data);
});
}, onError: (Object error, StackTrace stackTrace) {
setState(() {
_summary = widget.afterError(_summary, error, stackTrace);
});
}, onDone: () {
setState(() {
_summary = widget.afterDone(_summary);
});
});
_summary = widget.afterConnected(_summary);
}
}

The stream variable is incorporated in the “statefulWidget”.

Looking at the “subscribe” function, it listens consistently to the stream and for each state, it calls for setState (again setState why?!?!?!).

With this information, you might ask me, how is this harmful?

Let me show you 2 examples, one is wrong, and the other is correct.

Example A:

class WrongPage extends StatefulWidget {
const WrongPage({Key? key}) : super(key: key);
@override
State<WrongPage> createState() => _WrongPageState();
}
class _WrongPageState extends State<WrongPage> {
var count = 0;
var name = "sahar";
@override
void didUpdateWidget(covariant WrongPage oldWidget) {
super.didUpdateWidget(oldWidget);
name = "";
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("counter"),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("$count"),
Text(name),
TextField(
onChanged: (input) {
setState(() {
name = input;
});
},
),
OutlinedButton(
child: const Text("count"),
onPressed: () {
setState(() {
count++;
});
}),
],
),
),
);
}
}

Example B

class CorrectWayPage extends StatefulWidget {
const CorrectWayPage({Key? key}) : super(key: key);
@override
State<CorrectWayPage> createState() => _CorrectWayPageState();
}
class _CorrectWayPageState extends State<CorrectWayPage> {
var count = 0;
var name = "sahar";
final _countStreamController = StreamController<int>.broadcast();
final _nameStreamController = StreamController<String>.broadcast();
@override
void didUpdateWidget(covariant CorrectWayPage oldWidget) {
super.didUpdateWidget(oldWidget);
name = "";
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("counter"),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
StreamBuilder<int>(
stream: _countStreamController.stream,
builder: (context, snapshot) {
final state = snapshot.data ?? 0;
return Text("$state");
}
),
StreamBuilder<String>(
stream: _nameStreamController.stream,
builder: (context, snapshot) {
final state = snapshot.data ?? "";
return Text(state);
}
),
TextField(
onChanged: (input) {
setState(() {
name = input;
});
},
),
OutlinedButton(
child: const Text("count"),
onPressed: () {
setState(() {
count++;
});
}),
],
),
),
);
}
}

Now please take a minute to think about which example will work correctly.

Let’s first talk about the functionality. We have a button that increases count variable, and our textfield changes the name by the input.

In our example, for each setState, the “count” variable will work fine.

Each time we want to change the “name” variable and call setState, nothing will happen because didUpdateWidget will be called directly after setState and reset it (wrong!!). If we want to put some logic in our “lifecycles” functions, setState can cause many issues.

On the other hand, streambuilder will not call to this parent lifecycle for each additional state.

Fix the bug :)

SetState

This is the only way to really render the screen, From the docs “Notify the framework that the internal state of this object has changed.” This is maybe one of the only functions that communicates with the “skia” engine to render the screen.

But what really happens is that it goes to next frame. All “gui” are images running on frames one after another, and if our fps (frames per second) is high, each render will look smoother.

Now we need to understand when to use it, so let’s look at the documentation from flutter for best practice:

https://docs.flutter.dev/perf/best-practices

“When setState() is called on a State object, all descendent widgets rebuild. Therefore, localize the setState() call to the part of the subtree whose UI actually needs to change. Avoid calling setState() high up in the tree if the change is contained to a small part of the tree.”

What’s going on here?!

To put it more simply, setState can be used when we want to render a child that is part of the screen or part of a complex widget. For example, if we have a button that changes colors on each click, we can create a dependent widget for it that replaces the color for each click with setState, which is the correct way. FutureBuilder and streamBuilder are implements it but with wrapping.

Solution sample:

class ComplexScreen extends StatefulWidget {
const ComplexScreen({Key? key}) : super(key: key);
@override
State<ComplexScreen> createState() => _ComplexScreenState();
}
class _ComplexScreenState extends State<ComplexScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("my complex screen"),
),
body: Column(
children: [
//... a-lot of widgets
MyButton(onClick: (){})
],
),
);
}
}
class MyButton extends StatefulWidget {
final Function() onClick;
const MyButton({Key? key, required this.onClick}) : super(key: key); @override
State<MyButton> createState() => _MyButtonState();
}
class _MyButtonState extends State<MyButton> {
final color1 = Colors.blue;
final color2 = Colors.yellow;
var first = true;
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: const Text("Click"),
style: ElevatedButton.styleFrom(primary: first ? color1 : color2),
onPressed: () {
setState(() {
first = !first;
});
widget.onClick();
});
}
}

In conclusion, before using libraries for state management, first try out these components and try to understand the correct way to utilize setState instead of avoiding it. Then, once you understand all the basic components presented above, then you can go use external state management libraries.

When understanding what truly happens behind the scenes, it significantly simplifies the process of development.

https://twitter.com/FlutterComm

--

--