flutter_hooks en Flutter: ejercicio resuelto con useState y useEffect

flutter_hooks en Flutter: ejercicio resuelto con useState y useEffect

flutter_hooks lleva el patrón de hooks de React a Flutter. En lugar de extender StatefulWidget + State, extiendes HookWidget y llamas a funciones use* dentro del build. El resultado es menos código repetitivo y mejor reutilización de lógica con estado.

Enunciado

Implementa tres pantallas con flutter_hooks:

  1. Cronómetro: useState para tiempo transcurrido, useEffect para el Timer.
  2. Buscador reactivo: useTextEditingController + useMemoized para filtrar lista sin StatefulWidget.
  3. Hook personalizado: useCountdown reutilizable desde cualquier HookWidget.

Dependencias

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

Solución completa

  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
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: 'Cronómetro'),
            Tab(text: 'Buscador'),
            Tab(text: 'Countdown'),
          ]),
        ),
        body: const TabBarView(children: [
          StopwatchPage(),
          SearchPage(),
          CountdownPage(),
        ]),
      ),
    );
  }
}

// ── 1. Cronómetro con useState + useEffect ────────────────────────────────────
class StopwatchPage extends HookWidget {
  const StopwatchPage({super.key});

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

    // useEffect: se ejecuta cuando cambia [running.value]
    // Devuelve la función de cleanup (cancela el Timer al desmontar o al cambiar)
    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 ? 'Pausar' : 'Iniciar'),
              ),
              const SizedBox(width: 16),
              OutlinedButton(
                onPressed: () {
                  running.value = false;
                  elapsed.value = Duration.zero;
                },
                child: const Text('Resetear'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// ── 2. Buscador reactivo con useTextEditingController + useMemoized ───────────
const _fruits = [
  'Manzana', 'Mango', 'Melón', 'Melocotón', 'Mora',
  'Plátano', 'Pera', 'Piña', 'Papaya', 'Pomelo',
  'Naranja', 'Nectarina', 'Níspero',
  'Fresa', 'Frambuesa', 'Arándano', 'Cereza',
];

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

  @override
  Widget build(BuildContext context) {
    final controller = useTextEditingController();
    // useValueListenable convierte el controller en un valor reactivo
    final query = useValueListenable(controller);

    // useMemoized recalcula solo cuando cambia el texto
    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: 'Buscar fruta...',
              border: OutlineInputBorder(),
            ),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: filtered.length,
            itemBuilder: (_, i) => ListTile(title: Text(filtered[i])),
          ),
        ),
      ],
    );
  }
}

// ── 3. Hook personalizado: useCountdown ───────────────────────────────────────
/// Devuelve los segundos restantes de una cuenta atrás.
/// Se reinicia automáticamente al terminar si [loop] es 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 []);  // [] = solo al montar/desmontar

  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 segundos',
              style: TextStyle(
                  fontSize: 40, fontWeight: FontWeight.bold, color: color)),
          const SizedBox(height: 8),
          const Text('Se reinicia en bucle automáticamente',
              style: TextStyle(color: Colors.grey)),
        ],
      ),
    );
  }
}

Comparativa HookWidget vs StatefulWidget

AspectoStatefulWidgetHookWidget
BoilerplatecreateState(), State, dispose()Solo build() + llamadas use*
Reutilización de lógicaMixins o extracción a clasesHook personalizado (función pura)
Cleanup de recursosdispose() manualRetorno de función en useEffect
TestabilidadNormalNormal (mismos widget tests)
Curva de aprendizajeBajaMedia (hay que entender las reglas de hooks)

Reglas de los hooks

  1. Solo llama hooks dentro de HookWidget.build.
  2. Nunca dentro de condicionales, bucles o callbacks.
  3. El orden de llamada debe ser siempre el mismo en cada build.

Errores frecuentes

  • Hook fuera de HookWidget: lanza HookNotFoundError. Asegúrate de extender HookWidget en vez de StatelessWidget.
  • Dependencias vacías con efecto que usa variables: si el useEffect captura una variable pero su lista de dependencias es const [], el efecto no se re-ejecuta. Añade la variable a la lista.
  • useMemoized con lista mutable: si pasas un List como dependencia, compara por referencia. Usa valores primitivos o genera un hash explícito.

Aplicación práctica

flutter_hooks encaja bien en apps donde tienes muchos StatefulWidget pequeños con Timer, AnimationController o TextEditingController. Elimina el dispose manual y reduce la superficie de bugs por recursos no liberados.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿flutter_hooks es compatible con Riverpod o BLoC?

Sí. Existe hooks_riverpod que combina ambos paquetes. Para BLoC, simplemente usa HookWidget y accede al BLoC vía context.read/context.watch de forma normal.

¿Puedo mezclar HookWidget con StatefulWidget en el mismo árbol?

Sí, son completamente compatibles. Puedes migrar gradualmente pantalla a pantalla.

¿Los hooks afectan el rendimiento?

No negativamente. Internamente usan el mismo mecanismo que StatefulWidget. El código resultante es equivalente en rendimiento pero más compacto.