flutter_hooks in Flutter: solved exercise with useState and useEffect

flutter_hooks in Flutter: solved exercise with useState and useEffect

flutter_hooks brings React’s hooks pattern to Flutter. Instead of extending StatefulWidget + State, you extend HookWidget and call use* functions inside build. The result is less boilerplate and better reuse of stateful logic.

Problem statement

Implement three screens with flutter_hooks:

  1. Stopwatch: useState for elapsed time, useEffect for the Timer.
  2. Reactive search: useTextEditingController + useMemoized to filter a list without StatefulWidget.
  3. Custom hook: reusable useCountdown from any HookWidget.

Dependencies

1
2
3
4
5
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.21.0

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() => runApp(const MaterialApp(home: _DemoPage()));

class _DemoPage extends StatelessWidget {
  const _DemoPage();

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('flutter_hooks demo'),
          bottom: const TabBar(tabs: [
            Tab(text: 'Stopwatch'),
            Tab(text: 'Search'),
            Tab(text: 'Countdown'),
          ]),
        ),
        body: const TabBarView(children: [
          StopwatchPage(),
          SearchPage(),
          CountdownPage(),
        ]),
      ),
    );
  }
}

// ── 1. Stopwatch with useState + useEffect ────────────────────────────────────
class StopwatchPage extends HookWidget {
  const StopwatchPage({super.key});

  @override
  Widget build(BuildContext context) {
    final elapsed = useState(Duration.zero);   // equivalent to setState
    final running = useState(false);

    // useEffect: runs when [running.value] changes
    // Returns the cleanup function (cancels the Timer on unmount or change)
    useEffect(() {
      if (!running.value) return null;
      final timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
        elapsed.value += const Duration(milliseconds: 10);
      });
      return timer.cancel;  // cleanup
    }, [running.value]);

    final minutes = elapsed.value.inMinutes.toString().padLeft(2, '0');
    final seconds = (elapsed.value.inSeconds % 60).toString().padLeft(2, '0');
    final millis = ((elapsed.value.inMilliseconds % 1000) ~/ 10).toString().padLeft(2, '0');

    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text('$minutes:$seconds.$millis',
              style: const TextStyle(fontSize: 64, fontFamily: 'monospace')),
          const SizedBox(height: 32),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              FilledButton(
                onPressed: () => running.value = !running.value,
                child: Text(running.value ? 'Pause' : 'Start'),
              ),
              const SizedBox(width: 16),
              OutlinedButton(
                onPressed: () {
                  running.value = false;
                  elapsed.value = Duration.zero;
                },
                child: const Text('Reset'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// ── 2. Reactive search with useTextEditingController + useMemoized ────────────
const _fruits = [
  'Apple', 'Apricot', 'Avocado',
  'Banana', 'Blackberry', 'Blueberry',
  'Cherry', 'Coconut', 'Cranberry',
  'Fig', 'Grape', 'Guava',
  'Kiwi', 'Lemon', 'Lime',
  'Mango', 'Melon', 'Mulberry',
  'Orange', 'Papaya', 'Peach',
  'Pear', 'Pineapple', 'Plum',
  'Raspberry', 'Strawberry', 'Watermelon',
];

class SearchPage extends HookWidget {
  const SearchPage({super.key});

  @override
  Widget build(BuildContext context) {
    final controller = useTextEditingController();
    // useValueListenable turns the controller into a reactive value
    final query = useValueListenable(controller);

    // useMemoized recalculates only when the text changes
    final filtered = useMemoized(
      () => _fruits
          .where((f) => f.toLowerCase().contains(query.text.toLowerCase()))
          .toList(),
      [query.text],
    );

    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: TextField(
            controller: controller,
            decoration: const InputDecoration(
              prefixIcon: Icon(Icons.search),
              hintText: 'Search fruit...',
              border: OutlineInputBorder(),
            ),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: filtered.length,
            itemBuilder: (_, i) => ListTile(title: Text(filtered[i])),
          ),
        ),
      ],
    );
  }
}

// ── 3. Custom hook: useCountdown ──────────────────────────────────────────────
/// Returns remaining seconds for a countdown.
/// Restarts automatically when done if [loop] is true.
int useCountdown(int seconds, {bool loop = false}) {
  final remaining = useState(seconds);

  useEffect(() {
    final timer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (remaining.value > 0) {
        remaining.value--;
      } else if (loop) {
        remaining.value = seconds;
      }
    });
    return timer.cancel;
  }, const []);  // [] = only on mount/unmount

  return remaining.value;
}

class CountdownPage extends HookWidget {
  const CountdownPage({super.key});

  @override
  Widget build(BuildContext context) {
    final secs = useCountdown(60, loop: true);

    final color = secs > 30
        ? Colors.green
        : secs > 10
            ? Colors.orange
            : Colors.red;

    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TweenAnimationBuilder<double>(
            tween: Tween(begin: 1, end: secs / 60),
            duration: const Duration(milliseconds: 900),
            builder: (_, value, __) => CircularProgressIndicator(
              value: value,
              color: color,
              strokeWidth: 8,
              strokeCap: StrokeCap.round,
            ),
          ),
          const SizedBox(height: 24),
          Text('$secs seconds',
              style: TextStyle(
                  fontSize: 40, fontWeight: FontWeight.bold, color: color)),
          const SizedBox(height: 8),
          const Text('Restarts in a loop automatically',
              style: TextStyle(color: Colors.grey)),
        ],
      ),
    );
  }
}

HookWidget vs StatefulWidget comparison

AspectStatefulWidgetHookWidget
BoilerplatecreateState(), State, dispose()Just build() + use* calls
Logic reuseMixins or class extractionCustom hook (pure function)
Resource cleanupManual dispose()Return function from useEffect
TestabilityNormalNormal (same widget tests)
Learning curveLowMedium (must understand hook rules)

Hook rules

  1. Only call hooks inside HookWidget.build.
  2. Never inside conditionals, loops, or callbacks.
  3. The call order must always be the same on every build.

Common mistakes

  • Hook outside HookWidget: throws HookNotFoundError. Make sure to extend HookWidget instead of StatelessWidget.
  • Empty dependencies with an effect that uses variables: if useEffect captures a variable but its dependency list is const [], the effect won’t re-run. Add the variable to the list.
  • useMemoized with a mutable list: if you pass a List as a dependency, it compares by reference. Use primitive values or generate an explicit hash.

Practical use

flutter_hooks fits well in apps where you have many small StatefulWidgets with Timer, AnimationController, or TextEditingController. It eliminates manual dispose and reduces the bug surface from unreleased resources.

Guided practice and next step

FAQ

Is flutter_hooks compatible with Riverpod or BLoC?

Yes. There is hooks_riverpod that combines both packages. For BLoC, simply use HookWidget and access the BLoC via context.read/context.watch as normal.

Can I mix HookWidget with StatefulWidget in the same widget tree?

Yes, they are completely compatible. You can migrate screen by screen gradually.

Do hooks affect performance?

Not negatively. Internally they use the same mechanism as StatefulWidget. The resulting code is equivalent in performance but more compact.