— Flutter, flutter_hooks, State Management, Hooks — 2 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.
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.
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.
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 @override7 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 @override20 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.
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 @override8 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:
State
classcreateState()
methodsetState()
callsLet's explore some of the most commonly used hooks and how they replace typical StatefulWidget
patterns.
Traditional Way:
1class FormWidget extends StatefulWidget {2 const FormWidget({Key? key}) : super(key: key);3
4 @override5 State<FormWidget> createState() => _FormWidgetState();6}7
8class _FormWidgetState extends State<FormWidget> {9 late TextEditingController _controller;10
11 @override12 void initState() {13 super.initState();14 _controller = TextEditingController();15 }16
17 @override18 void dispose() {19 _controller.dispose();20 super.dispose();21 }22
23 @override24 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 @override5 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!
Traditional Way:
1class DataFetchWidget extends StatefulWidget {2 const DataFetchWidget({Key? key}) : super(key: key);3
4 @override5 State<DataFetchWidget> createState() => _DataFetchWidgetState();6}7
8class _DataFetchWidgetState extends State<DataFetchWidget> {9 String _data = 'Loading...';10
11 @override12 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 @override25 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 @override5 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
).
Traditional Way:
1class AnimatedWidget extends StatefulWidget {2 const AnimatedWidget({Key? key}) : super(key: key);3
4 @override5 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 @override14 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 @override25 void dispose() {26 _controller.dispose();27 super.dispose();28 }29
30 @override31 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 @override5 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!
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 @override5 Widget build(BuildContext context) {6 final counter = useState(0);7 8 // This expensive calculation only runs when counter.value changes9 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 computation27 return value * value;28 }29}
One of the most powerful features of hooks is the ability to create custom hooks that encapsulate reusable logic:
1// Custom hook for debounced search2ValueNotifier<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 hook16class SearchWidget extends HookWidget {17 const SearchWidget({Key? key}) : super(key: key);18
19 @override20 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 value26 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}
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 @override10 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}
Here are some tips for working with flutter_hooks:
1// ❌ Bad - conditional hook2if (someCondition) {3 final value = useState(0);4}5
6// ✅ Good7final value = useState(0);8if (someCondition) {9 // use value here10}
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.
Create custom hooks for reusable logic: If you find yourself repeating the same hooks pattern, extract it into a custom hook.
Don't overuse hooks: While hooks are great, not every widget needs them. Simple stateless widgets should remain as StatelessWidget
.
Besides reducing boilerplate, hooks can also improve performance:
useMemoized
and useCallback
help avoid unnecessary rebuildsWhile hooks are powerful, there are still cases where StatefulWidget
might be appropriate:
StatefulWidget
, migrating might not be worth itinitState
, didUpdateWidget
, etc.However, for most cases, especially new code, hooks provide a cleaner and more maintainable approach.
Flutter Hooks dramatically reduces boilerplate code while maintaining (and often improving) readability and maintainability. By replacing StatefulWidget
with HookWidget
, you can:
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.