English | Português | 한국어 | 简体中文
A Flutter implementation of React hooks: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889
Hooks are a new kind of object that manage the life-cycle of a Widget
. They exist
for one reason: increase the code-sharing between widgets by removing duplicates.
StatefulWidget
suffers from a big problem: it is very difficult to reuse the
logic of say initState
or dispose
. An obvious example is AnimationController
:
class Example extends StatefulWidget {
const Example({super.key, required this.duration});
final Duration duration;
@override
_ExampleState createState() => _ExampleState();
}
class _ExampleState extends State<Example> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
}
@override
void didUpdateWidget(Example oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.duration != oldWidget.duration) {
_controller.duration = widget.duration;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container();
}
}
All widgets that desire to use an AnimationController
will have to reimplement
almost all of this logic from scratch, which is of course undesired.
Dart mixins can partially solve this issue, but they suffer from other problems:
- A given mixin can only be used once per class.
- Mixins and the class share the same object.
This means that if two mixins define a variable under the same name, the result may vary between compilation fails to unknown behavior.
This library proposes a third solution:
class Example extends HookWidget {
const Example({super.key, required this.duration});
final Duration duration;
@override
Widget build(BuildContext context) {
final controller = useAnimationController(duration: duration);
return Container();
}
}
This code is functionally equivalent to the previous example. It still disposes the
AnimationController
and still updates its duration
when Example.duration
changes.
But you're probably thinking:
Where did all the logic go?
That logic has been moved into useAnimationController
, a function included directly in
this library (see Existing hooks) - It is what we call a Hook.
Hooks are a new kind of object with some specificities:
-
They can only be used in the
build
method of a widget that mix-inHooks
. -
The same hook can be reused arbitrarily many times. The following code defines two independent
AnimationController
, and they are correctly preserved when the widget rebuild.Widget build(BuildContext context) { final controller = useAnimationController(); final controller2 = useAnimationController(); return Container(); }
-
Hooks are entirely independent of each other and from the widget.
This means that they can easily be extracted into a package and published on pub for others to use.
Similar to State
, hooks are stored in the Element
of a Widget
. However, instead
of having one State
, the Element
stores a List<Hook>
. Then in order to use a Hook
,
one must call Hook.use
.
The hook returned by use
is based on the number of times it has been called.
The first call returns the first hook; the second call returns the second hook,
the third call returns the third hook and so on.
If this idea is still unclear, a naive implementation of hooks could look as follows:
class HookElement extends Element {
List<HookState> _hooks;
int _hookIndex;
T use<T>(Hook<T> hook) => _hooks[_hookIndex++].build(this);
@override
performRebuild() {
_hookIndex = 0;
super.performRebuild();
}
}
For more explanation of how hooks are implemented, here's a great article about how it was done in React: https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e
Due to hooks being obtained from their index, some rules must be respected:
Widget build(BuildContext context) {
// starts with `use`, good name
useMyHook();
// doesn't start with `use`, could confuse people into thinking that this isn't a hook
myHook();
// ....
}
Widget build(BuildContext context) {
useMyHook();
// ....
}
Widget build(BuildContext context) {
if (condition) {
useMyHook();
}
// ....
}
Since hooks are obtained from their index, one may think that hot-reloads while refactoring will break the application.
But worry not, a HookWidget
overrides the default hot-reload behavior to work with hooks. Still, there are some situations in which the state of a Hook may be reset.
Consider the following list of hooks:
useA();
useB(0);
useC();
Then consider that we edited the parameter of HookB
after performing a hot-reload:
useA();
useB(42);
useC();
Here everything works fine and all hooks maintain their state.
Now consider that we removed HookB
. We now have:
useA();
useC();
In this situation, HookA
maintains its state but HookC
gets hard reset.
This happens because, when a hot-reload is performed after refactoring, all hooks after the first line impacted are disposed of.
So, since HookC
was placed after HookB
, it will be disposed.
There are two ways to create a hook:
-
A function
Functions are by far the most common way to write hooks. Thanks to hooks being composable by nature, a function will be able to combine other hooks to create a more complex custom hook. By convention, these functions will be prefixed by
use
.The following code defines a custom hook that creates a variable and logs its value to the console whenever the value changes:
ValueNotifier<T> useLoggedState<T>([T initialData]) { final result = useState<T>(initialData); useValueChanged(result.value, (_, __) { print(result.value); }); return result; }
-
A class
When a hook becomes too complex, it is possible to convert it into a class that extends
Hook
- which can then be used usingHook.use
.
As a class, the hook will look very similar to aState
class and have access to widget life-cycle and methods such asinitHook
,dispose
andsetState
.It is usually good practice to hide the class under a function as such:
Result useMyHook() { return use(const _TimeAlive()); }
The following code defines a hook that prints the total time a
State
has been alive on its dispose.class _TimeAlive extends Hook<void> { const _TimeAlive(); @override _TimeAliveState createState() => _TimeAliveState(); } class _TimeAliveState extends HookState<void, _TimeAlive> { DateTime start; @override void initHook() { super.initHook(); start = DateTime.now(); } @override void build(BuildContext context) {} @override void dispose() { print(DateTime.now().difference(start)); super.dispose(); } }
Flutter_Hooks already comes with a list of reusable hooks which are divided into different kinds:
A set of low-level hooks that interact with the different life-cycles of a widget
Name | Description |
---|---|
useEffect | Useful for side-effects and optionally canceling them. |
useState | Creates a variable and subscribes to it. |
useMemoized | Caches the instance of a complex object. |
useRef | Creates an object that contains a single mutable property. |
useCallback | Caches a function instance. |
useContext | Obtains the BuildContext of the building HookWidget . |
useValueChanged | Watches a value and triggers a callback whenever its value changed. |
This category of hooks the manipulation of existing Flutter/Dart objects with hooks. They will take care of creating/updating/disposing an object.
Name | Description |
---|---|
useStream | Subscribes to a Stream and returns its current state as an AsyncSnapshot . |
useStreamController | Creates a StreamController which will automatically be disposed. |
useOnStreamChange | Subscribes to a Stream , registers handlers, and returns the StreamSubscription . |
useFuture | Subscribes to a Future and returns its current state as an AsyncSnapshot . |
Name | Description |
---|---|
useSingleTickerProvider | Creates a single usage TickerProvider . |
useAnimationController | Creates an AnimationController which will be automatically disposed. |
useAnimation | Subscribes to an Animation and returns its value. |
Name | Description |
---|---|
useListenable | Subscribes to a Listenable and marks the widget as needing build whenever the listener is called. |
useListenableSelector | Similar to useListenable , but allows filtering UI rebuilds |
useValueNotifier | Creates a ValueNotifier which will be automatically disposed. |
useValueListenable | Subscribes to a ValueListenable and return its value. |
useOnListenableChange | Adds a given listener callback to a Listenable which will be automatically removed. |
A series of hooks with no particular theme.
Name | Description |
---|---|
useReducer | An alternative to useState for more complex states. |
usePrevious | Returns the previous argument called to [usePrevious]. |
useTextEditingController | Creates a TextEditingController . |
useFocusNode | Creates a FocusNode . |
useTabController | Creates and disposes a TabController . |
useScrollController | Creates and disposes a ScrollController . |
usePageController | Creates and disposes a PageController . |
useFixedExtentScrollController | Creates and disposes a FixedExtentScrollController . |
useAppLifecycleState | Returns the current AppLifecycleState and rebuilds the widget on change. |
useOnAppLifecycleStateChange | Listens to AppLifecycleState changes and triggers a callback on change. |
useTransformationController | Creates and disposes a TransformationController . |
useIsMounted | An equivalent to State.mounted for hooks. |
useAutomaticKeepAlive | An equivalent to the AutomaticKeepAlive widget for hooks. |
useOnPlatformBrightnessChange | Listens to platform Brightness changes and triggers a callback on change. |
useSearchController | Creates and disposes a SearchController . |
useWidgetStatesController | Creates and disposes a WidgetStatesController . |
useExpansionTileController | Creates a ExpansionTileController . |
useDebounced | Returns a debounced version of the provided value, triggering widget updates accordingly after a specified timeout duration |
useDraggableScrollableController | Creates a DraggableScrollableController . |
Contributions are welcomed!
If you feel that a hook is missing, feel free to open a pull-request.
For a custom-hook to be merged, you will need to do the following:
-
Describe the use-case.
Open an issue explaining why we need this hook, how to use it, ... This is important as a hook will not get merged if the hook doesn't appeal to a large number of people.
If your hook is rejected, don't worry! A rejection doesn't mean that it won't be merged later in the future if more people show interest in it. In the mean-time, feel free to publish your hook as a package on https://pub.dev.
-
Write tests for your hook
A hook will not be merged unless fully tested to avoid inadvertently breaking it in the future.
-
Add it to the README and write documentation for it.