Introduction to flutter_hooks: Get rid of your stateful boilerplate

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!

Subscribe to my newsletter

Read articles from Nikodem Bernat directly inside your inbox.

Subscribe to the newsletter, and don't miss out.