Skip to content
DeveloperMemos

Using flutter_hooks to Reduce Boilerplate in Flutter

Flutter, flutter_hooks, State Management, Hooks2 min read

If you've been writing Flutter apps for a while, you've probably noticed that using StatefulWidget can lead to a lot of boilerplate code. Every time you need to manage state, you have to create two classes, override methods, and remember to call setState(). Enter flutter_hooks - a Flutter implementation of React Hooks that lets you write cleaner, more composable code with significantly less boilerplate.

What are Flutter Hooks?

Flutter Hooks is a package inspired by React Hooks that allows you to reuse stateful logic between widgets without the need for StatefulWidget. Instead of creating a StatefulWidget with its companion State class, you can use a HookWidget and leverage various hooks to manage your state and lifecycle.

Installation

To get started with flutter_hooks, add it to your pubspec.yaml:

1dependencies:
2 flutter_hooks: ^0.20.5

Then run flutter pub get to install the package.

The Traditional Way: StatefulWidget

Let's start by looking at how we typically handle state in Flutter. Here's a simple counter example using StatefulWidget:

1import 'package:flutter/material.dart';
2
3class CounterWidget extends StatefulWidget {
4 const CounterWidget({Key? key}) : super(key: key);
5
6 @override
7 State<CounterWidget> createState() => _CounterWidgetState();
8}
9
10class _CounterWidgetState extends State<CounterWidget> {
11 int _counter = 0;
12
13 void _incrementCounter() {
14 setState(() {
15 _counter++;
16 });
17 }
18
19 @override
20 Widget build(BuildContext context) {
21 return Scaffold(
22 appBar: AppBar(
23 title: const Text('Counter'),
24 ),
25 body: Center(
26 child: Column(
27 mainAxisAlignment: MainAxisAlignment.center,
28 children: [
29 const Text(
30 'You have pushed the button this many times:',
31 ),
32 Text(
33 '$_counter',
34 style: Theme.of(context).textTheme.headlineMedium,
35 ),
36 ],
37 ),
38 ),
39 floatingActionButton: FloatingActionButton(
40 onPressed: _incrementCounter,
41 tooltip: 'Increment',
42 child: const Icon(Icons.add),
43 ),
44 );
45 }
46}

That's about 47 lines of code for a simple counter. Now let's see how we can accomplish the same thing with hooks.

The Hooks Way: useState

Here's the same counter example using flutter_hooks:

1import 'package:flutter/material.dart';
2import 'package:flutter_hooks/flutter_hooks.dart';
3
4class CounterWidget extends HookWidget {
5 const CounterWidget({Key? key}) : super(key: key);
6
7 @override
8 Widget build(BuildContext context) {
9 final counter = useState(0);
10
11 return Scaffold(
12 appBar: AppBar(
13 title: const Text('Counter'),
14 ),
15 body: Center(
16 child: Column(
17 mainAxisAlignment: MainAxisAlignment.center,
18 children: [
19 const Text(
20 'You have pushed the button this many times:',
21 ),
22 Text(
23 '${counter.value}',
24 style: Theme.of(context).textTheme.headlineMedium,
25 ),
26 ],
27 ),
28 ),
29 floatingActionButton: FloatingActionButton(
30 onPressed: () => counter.value++,
31 tooltip: 'Increment',
32 child: const Icon(Icons.add),
33 ),
34 );
35 }
36}

That's only 37 lines! We've eliminated:

  • The separate State class
  • The createState() method
  • The explicit setState() calls
  • The need to create a separate method for incrementing

Common Hooks and Their Use Cases

Let's explore some of the most commonly used hooks and how they replace typical StatefulWidget patterns.

useTextEditingController

Traditional Way:

1class FormWidget extends StatefulWidget {
2 const FormWidget({Key? key}) : super(key: key);
3
4 @override
5 State<FormWidget> createState() => _FormWidgetState();
6}
7
8class _FormWidgetState extends State<FormWidget> {
9 late TextEditingController _controller;
10
11 @override
12 void initState() {
13 super.initState();
14 _controller = TextEditingController();
15 }
16
17 @override
18 void dispose() {
19 _controller.dispose();
20 super.dispose();
21 }
22
23 @override
24 Widget build(BuildContext context) {
25 return TextField(
26 controller: _controller,
27 decoration: const InputDecoration(
28 hintText: 'Enter text',
29 ),
30 );
31 }
32}

Hooks Way:

1class FormWidget extends HookWidget {
2 const FormWidget({Key? key}) : super(key: key);
3
4 @override
5 Widget build(BuildContext context) {
6 final controller = useTextEditingController();
7
8 return TextField(
9 controller: controller,
10 decoration: const InputDecoration(
11 hintText: 'Enter text',
12 ),
13 );
14 }
15}

The hook automatically handles initialization and disposal for you - no more forgetting to dispose of controllers!

useEffect - Managing Side Effects

Traditional Way:

1class DataFetchWidget extends StatefulWidget {
2 const DataFetchWidget({Key? key}) : super(key: key);
3
4 @override
5 State<DataFetchWidget> createState() => _DataFetchWidgetState();
6}
7
8class _DataFetchWidgetState extends State<DataFetchWidget> {
9 String _data = 'Loading...';
10
11 @override
12 void initState() {
13 super.initState();
14 _fetchData();
15 }
16
17 Future<void> _fetchData() async {
18 await Future.delayed(const Duration(seconds: 2));
19 setState(() {
20 _data = 'Data loaded!';
21 });
22 }
23
24 @override
25 Widget build(BuildContext context) {
26 return Text(_data);
27 }
28}

Hooks Way:

1class DataFetchWidget extends HookWidget {
2 const DataFetchWidget({Key? key}) : super(key: key);
3
4 @override
5 Widget build(BuildContext context) {
6 final data = useState('Loading...');
7
8 useEffect(() {
9 Future.delayed(const Duration(seconds: 2)).then((_) {
10 data.value = 'Data loaded!';
11 });
12 return null;
13 }, []);
14
15 return Text(data.value);
16 }
17}

The useEffect hook replaces initState, didUpdateWidget, and dispose with a single, unified API. The empty array [] as the second parameter means this effect only runs once (like initState).

useAnimationController

Traditional Way:

1class AnimatedWidget extends StatefulWidget {
2 const AnimatedWidget({Key? key}) : super(key: key);
3
4 @override
5 State<AnimatedWidget> createState() => _AnimatedWidgetState();
6}
7
8class _AnimatedWidgetState extends State<AnimatedWidget>
9 with SingleTickerProviderStateMixin {
10 late AnimationController _controller;
11 late Animation<double> _animation;
12
13 @override
14 void initState() {
15 super.initState();
16 _controller = AnimationController(
17 vsync: this,
18 duration: const Duration(seconds: 2),
19 );
20 _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
21 _controller.repeat(reverse: true);
22 }
23
24 @override
25 void dispose() {
26 _controller.dispose();
27 super.dispose();
28 }
29
30 @override
31 Widget build(BuildContext context) {
32 return AnimatedBuilder(
33 animation: _animation,
34 builder: (context, child) {
35 return Opacity(
36 opacity: _animation.value,
37 child: const FlutterLogo(size: 100),
38 );
39 },
40 );
41 }
42}

Hooks Way:

1class AnimatedWidget extends HookWidget {
2 const AnimatedWidget({Key? key}) : super(key: key);
3
4 @override
5 Widget build(BuildContext context) {
6 final controller = useAnimationController(
7 duration: const Duration(seconds: 2),
8 );
9
10 useEffect(() {
11 controller.repeat(reverse: true);
12 return null;
13 }, []);
14
15 final animation = useAnimation(
16 Tween<double>(begin: 0, end: 1).animate(controller),
17 );
18
19 return Opacity(
20 opacity: animation,
21 child: const FlutterLogo(size: 100),
22 );
23 }
24}

No more SingleTickerProviderStateMixin, no manual disposal, and cleaner code!

useMemoized - Expensive Computations

Sometimes you need to perform expensive computations, but you don't want to recalculate them on every build:

1class ExpensiveComputationWidget extends HookWidget {
2 const ExpensiveComputationWidget({Key? key}) : super(key: key);
3
4 @override
5 Widget build(BuildContext context) {
6 final counter = useState(0);
7
8 // This expensive calculation only runs when counter.value changes
9 final expensiveValue = useMemoized(
10 () => _performExpensiveCalculation(counter.value),
11 [counter.value],
12 );
13
14 return Column(
15 children: [
16 Text('Result: $expensiveValue'),
17 ElevatedButton(
18 onPressed: () => counter.value++,
19 child: const Text('Increment'),
20 ),
21 ],
22 );
23 }
24
25 int _performExpensiveCalculation(int value) {
26 // Simulate expensive computation
27 return value * value;
28 }
29}

Custom Hooks - Reusable Logic

One of the most powerful features of hooks is the ability to create custom hooks that encapsulate reusable logic:

1// Custom hook for debounced search
2ValueNotifier<String> useDebounce(String value, Duration delay) {
3 final debouncedValue = useState(value);
4
5 useEffect(() {
6 final timer = Timer(delay, () {
7 debouncedValue.value = value;
8 });
9 return timer.cancel;
10 }, [value]);
11
12 return debouncedValue;
13}
14
15// Using the custom hook
16class SearchWidget extends HookWidget {
17 const SearchWidget({Key? key}) : super(key: key);
18
19 @override
20 Widget build(BuildContext context) {
21 final searchText = useState('');
22 final debouncedSearch = useDebounce(searchText.value, const Duration(milliseconds: 500));
23
24 useEffect(() {
25 // Perform search with debounced value
26 print('Searching for: ${debouncedSearch.value}');
27 return null;
28 }, [debouncedSearch.value]);
29
30 return TextField(
31 onChanged: (value) => searchText.value = value,
32 decoration: const InputDecoration(
33 hintText: 'Search...',
34 ),
35 );
36 }
37}

Combining Hooks with State Management

Hooks work great with state management solutions. For example, if you're using Riverpod (which has its own hooks integration via hooks_riverpod), you can combine both:

1import 'package:flutter_hooks/flutter_hooks.dart';
2import 'package:hooks_riverpod/hooks_riverpod.dart';
3
4final counterProvider = StateProvider<int>((ref) => 0);
5
6class CombinedWidget extends HookConsumerWidget {
7 const CombinedWidget({Key? key}) : super(key: key);
8
9 @override
10 Widget build(BuildContext context, WidgetRef ref) {
11 final counter = ref.watch(counterProvider);
12 final localState = useState('');
13
14 return Column(
15 children: [
16 Text('Global counter: $counter'),
17 Text('Local state: ${localState.value}'),
18 ElevatedButton(
19 onPressed: () => ref.read(counterProvider.notifier).state++,
20 child: const Text('Increment Global'),
21 ),
22 TextField(
23 onChanged: (value) => localState.value = value,
24 ),
25 ],
26 );
27 }
28}

Best Practices

Here are some tips for working with flutter_hooks:

  1. Don't call hooks conditionally: Always call hooks at the top level of your build method in the same order.
1// ❌ Bad - conditional hook
2if (someCondition) {
3 final value = useState(0);
4}
5
6// ✅ Good
7final value = useState(0);
8if (someCondition) {
9 // use value here
10}
  1. Use the dependencies array wisely: When using useEffect or useMemoized, make sure to include all values that the effect depends on in the dependencies array.

  2. Create custom hooks for reusable logic: If you find yourself repeating the same hooks pattern, extract it into a custom hook.

  3. Don't overuse hooks: While hooks are great, not every widget needs them. Simple stateless widgets should remain as StatelessWidget.

Performance Benefits

Besides reducing boilerplate, hooks can also improve performance:

  • Automatic disposal: Hooks automatically clean up resources, reducing memory leaks
  • Fine-grained rebuilds: Hooks like useMemoized and useCallback help avoid unnecessary rebuilds
  • Simplified widget tree: Fewer widget classes means less overhead

When to Use StatefulWidget vs Hooks

While hooks are powerful, there are still cases where StatefulWidget might be appropriate:

  • Legacy code: If you're working with a large codebase that uses StatefulWidget, migrating might not be worth it
  • Complex lifecycle management: Very complex lifecycle scenarios might be clearer with explicit initState, didUpdateWidget, etc.
  • Team preference: If your team is unfamiliar with hooks, the learning curve might not be worth it

However, for most cases, especially new code, hooks provide a cleaner and more maintainable approach.

Conclusion

Flutter Hooks dramatically reduces boilerplate code while maintaining (and often improving) readability and maintainability. By replacing StatefulWidget with HookWidget, you can:

  • Write less code
  • Eliminate entire classes and methods
  • Automatically manage resources
  • Create reusable custom hooks
  • Make your code more composable and testable

If you haven't tried flutter_hooks yet, I highly recommend giving it a shot in your next project. Once you get used to the hooks pattern, you'll find it hard to go back to writing all that StatefulWidget boilerplate!

For more information, check out the flutter_hooks package on pub.dev.