Introduction to flutter_hooks: Get rid of your stateful boilerplate
Nikodem Bernat · Jul 27, 2024 · 8 min read
You probably wrote at least one Flutter widget in your life. Writing them is straightforward - define a constructor, override a build method, and return whatever you want.
import 'package:flutter/material.dart';
class HelloWorld extends StatelessWidget {
const HelloWorld({super.key});
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: const Text('Hello, World!'),
onPressed: () {
// TODO: Welcome the world.
},
);
}
}
But wait! Real apps are not that simple. You always work with some kind of data, and you have to update your UI when that data changes. Fortunately, Flutter provides a StatefulWidget
which can help us. StatefulWidget
is a special type of widget that can manage the state and react to its changes. Take a look at the following snippet.
import 'package:flutter/material.dart';
class HelloWorld extends StatefulWidget {
const HelloWorld({super.key});
@override
State<HelloWorld> createState() => _HelloWorldState();
}
class _HelloWorldState extends State<HelloWorld> {
int count = 0;
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: Text('Hello, $count!'),
onPressed: () => setState(() {
count += 1;
}),
);
}
}
Here, we create a new StatefulWidget
that after clicking on a button increments the value of count
. It's not an ordinary incrementation though. As you can see, it's wrapped in setState
which notifies the framework that the state of this variable has changed. Thanks to that, Flutter knows when to rerender our view and show updated value. Without calling setState
the value changes, but we can't see it on the screen.
But, there is a problem. This StatefulWidget
is 50% longer than our previous version and introduces a lot of boilerplate code and an additional class. To fix that we can use hooks.
Captain Hook
Hooks aren't a new concept - Flutter's implementation is inspired by React which introduced hooks in 2019 making class components not the cool kid anymore.
To use hooks in Flutter you have to add flutter_hooks
to your pubspec.yaml
file. You can check what is the latest version of flutter_hooks
on pub.dev. After that, you are ready to use hooks.
Do you remember our old StatefulWidget
? You can forget about it. Now, we can replace it with a new HookWidget
which simplifies our code a lot.
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class HelloWorld extends HookWidget {
const HelloWorld({super.key});
@override
Widget build(BuildContext context) {
final count = useState(0);
return ElevatedButton(
child: Text('Hello, $count!'),
onPressed: () => count.value += 1,
);
}
}
That's our previous example rewritten using hooks. As you can see, we have only one class and we no longer have to call setState
explicitly. It's called automatically when we assign a new value
to count
.
Actually, our count
is no longer an int
, but it's a ValueNotifier<int>
instead. ValueNotifier
is a class that provides notifications when the underlying value
changes. It's also available without hooks but requires some more work to make it reactive.
We will dive more into hooks in a second, but first, let's learn some basic rules of hooks.
Two Commandments
There are some rules when using hooks. The first one is related to naming conventions.
To make it easy to distinguish between hooks and ordinary functions, you should prefix all names with "use". For example, a hook to get the current context is called useContext
and a hook that stores a single state is called useState
.
The other rule is avoiding conditional hooks.
Due to the way hooks are implemented you shouldn't call hooks conditionally. It may result in unexpected behavior, bugs, and the loss of state.
// Bad - `useState` hooks is used conditionally.
final childrenCount = hasChildren
? useState(children.length)
: null;
// Good - We no longer use hooks conditionally! 🎉
final childrenCount = useState(
hasChildren ? 1 : null,
);
Currently, there is no official package that can force you to respect those rules. Until then, you can take a look at leancode_lint, it's a package that is used at LeanCode to ensure good coding style. It consists of a careful lint selection and a bunch of custom lints. I work at LeanCode, I use it every single day, and I actually like it, so it may be worth looking at. Keep in mind though that analyzer plugins in Dart are experimental and struggle with bad performance, so I would think twice before enabling custom lints in a solo project.
Basics
Every tutorial should start with the basics, and this one isn't an exception. Let's cover some of the most common hooks, so you can start building your app.
useState
useState
is a hook that stores a single value. That state is accessible via the value
property.
final state = useState(0);
// Read state.
print(state.value);
// Write state (automatically makes our widget rerender).
state.value = 1;
This hook uses ValueNotifier
underneath, manages listening to value changes, and automatically handles the disposal of this instance once the widget is unmounted.
useRef
useRef
is a hook that is very similar to useState
. The difference is, it doesn't rerender our widget. It can be useful when you want to store a value without causing unnecessary rebuilds. The syntax is identical to useState
. You have a value
property and you can read from it and write to it.
final count = useRef(0);
return ElevatedButton(
child: const Text('Hello, World!'),
onPressed: () {
// This value isn't accessed by the UI, so we don't have
// to rebuild our widget when it changes.
count.value += 1;
print(count);
},
);
useMemoized
useMemoized
is a hook that is similar to useRef
. Actually, useRef
is implemented as a one-liner using useMemoized
!
// Implementation of `useRef` in `flutter_hooks`.
ObjectRef<T> useRef<T>(T initialValue) {
return useMemoized(() => ObjectRef<T>(initialValue));
}
useMemoized
is used to store more complex object instances. It exposed a builder and a list of properties called keys
. If any of those properties change, the builder is called and recalculates the value that this hook exposes.
final items = [
'zero',
'one',
'two',
];
final numbersWithNames = useMemoized(
() {
return {
for (var i = 0; i < items.length; i++)
i: items[i],
};
},
[...items],
);
In this example, we have a list of items
that is also provided as a key to useMemoized
hook (using the spread operator). Inside our builder, we generate a key-value map with numbers as keys and the associated value from items
as a value. The result of this useMemoized
hook is a variable numbersWithNames
that looks like this:
final numbersWithNames = {
0: 'zero',
1: 'one',
2: 'two',
};
This variable will be regenerated every time items
change.
useEffect
useEffect
allows you to write code that is executed when the list of keys changes. Its simplest usage is when you provide an empty list. By doing that you can execute code that normally would go into initState
or dispose
. Keep in mind that you have to specify a list of keys - otherwise, useEffect
will be called on every rebuild.
useEffect(
() {
print("I'm called when the widget is mounted!");
// Code that is written inside return is called
// when this widget is disposed or any parameter in
// the list of keys changes.
return () {
print("I'm called when the widget is disposed!");
};
},
// This empty list with keys below is very important! ⚠️
[],
);
Of course, instead of an empty list as keys, you can provide some variables inside that list. In such case, your useEffect
will be executed when the value of this list changes.
dart:async & Listenable
Working with hooks can greatly reduce boilerplate and indentation of your widgets since you no longer need FutureBuilder
, StreamBuilder
, or ListenableBuilder
.
Depending on your app, you probably consume some network APIs. If you have a standard REST API then you may return from your repositories Future<T>
, or if you work with web sockets you may have Stream<T>
. Working with that using hooks is extremely easy. To subscribe to futures use useFuture
which returns AsyncSnapshot<T>
, and to subscribe to streams use useStream
which also returns AsyncSnapshot<T>
.
You can also use a similar hook with Listenables. Simply wrap your Listenable<T>
with useListenable
. It returns T
that rebuilds the widget every time this Listenable
notifies listeners.
Controllers & Other Hooks
This article is called "Introduction" for a reason. It intents to introduce you to hooks, not to copy the whole documentation. If you are interested in learning all hooks then feel free to check the documentation that is available on pub.dev. If you scroll down there is a table that in one sentence describes each hook that is available.
Having said that, there is another group of hooks that deserve attention - controllers. You have useTextEditingController
for TextEditingController
, usePageController
for PageController
, and a lot of other hooks for all your controller needs. If there is a controller in Flutter, there probably is a hook that can create it. Simply prefix the controller's type with use
and hope for the best.
Besides controllers, there are other miscellaneous hooks.
useOnAppLifecycleStateChange
is a great hook that can be useful when you want to execute some code when the user leaves your app (e.g. send an event, pause video playback, etc.).
useOnPlatformBrightnessChange
is useful if you want to have a callback that is executed each time the user switches from light mode to dark mode.
useListenableSelector
can subscribe to a Listenable
, and filter rebuilds at the same time.
Composite Hooks
After some time with hooks, you will want to create your own hook. Fortunately, you can combine other hooks to create a new one.
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
ValueNotifier<int> useNumberNotifier(int number) {
final notifier = useMemoized(
() => ValueNotifier(number),
);
useEffect(() => notifier.dispose, []);
return useListenable(notifier);
}
When creating custom hooks there is one more hook that can be useful for you.
useContext
which allows you to automagically obtain BuildContext
for the current widget.
That's it
If you enjoyed reading this article then feel free to follow me on X (Twitter):
https://x.com/nikodembernat.
You can also subscribe to get notified when I make more Flutter-related articles.
See you next time!