BLoC con eventos y estados tipados en Flutter: ejercicio resuelto

BLoC con eventos y estados tipados en Flutter: ejercicio resuelto

El patrón BLoC (Business Logic Component) separa la lógica de negocio de la UI mediante un flujo unidireccional: la UI dispara eventos, el BLoC los procesa y emite nuevos estados. Esto hace el código testeable y predecible.

Enunciado

Crea una pantalla que cargue una lista de posts desde una fuente remota simulada. Debe gestionar tres estados diferenciados: cargando, cargado y error. El usuario puede refrescar la lista con un botón.

Dependencias

1
2
dependencies:
  flutter_bloc: ^8.1.6

Solución en Flutter

  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
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// ── Modelo ───────────────────────────────────────────────────────────────────
class Post {
  final int id;
  final String title;
  const Post({required this.id, required this.title});
}

// ── Repositorio (simulado) ────────────────────────────────────────────────────
class PostRepository {
  Future<List<Post>> fetchPosts() async {
    await Future<void>.delayed(const Duration(seconds: 1));
    // Simula un error ocasional para probar el estado de error
    // throw Exception('Error de red');
    return List.generate(
      5,
      (i) => Post(id: i + 1, title: 'Post número ${i + 1}'),
    );
  }
}

// ── Eventos (sealed = exhaustivos) ───────────────────────────────────────────
sealed class PostsEvent {}

class PostsLoadRequested extends PostsEvent {}

class PostsRefreshRequested extends PostsEvent {}

// ── Estados ──────────────────────────────────────────────────────────────────
sealed class PostsState {}

class PostsInitial extends PostsState {}

class PostsLoading extends PostsState {}

class PostsLoaded extends PostsState {
  final List<Post> posts;
  PostsLoaded(this.posts);
}

class PostsError extends PostsState {
  final String message;
  PostsError(this.message);
}

// ── BLoC ─────────────────────────────────────────────────────────────────────
class PostsBloc extends Bloc<PostsEvent, PostsState> {
  final PostRepository _repository;

  PostsBloc(this._repository) : super(PostsInitial()) {
    on<PostsLoadRequested>(_onLoad);
    on<PostsRefreshRequested>(_onRefresh);
  }

  Future<void> _onLoad(
    PostsLoadRequested event,
    Emitter<PostsState> emit,
  ) async {
    emit(PostsLoading());
    await _loadPosts(emit);
  }

  Future<void> _onRefresh(
    PostsRefreshRequested event,
    Emitter<PostsState> emit,
  ) async {
    // Mantiene la lista visible mientras recarga
    if (state is PostsLoaded) {
      emit(PostsLoading());
    }
    await _loadPosts(emit);
  }

  Future<void> _loadPosts(Emitter<PostsState> emit) async {
    try {
      final posts = await _repository.fetchPosts();
      emit(PostsLoaded(posts));
    } catch (e) {
      emit(PostsError(e.toString()));
    }
  }
}

// ── App ──────────────────────────────────────────────────────────────────────
void main() {
  runApp(
    BlocProvider(
      create: (_) => PostsBloc(PostRepository())..add(PostsLoadRequested()),
      child: const MaterialApp(home: PostsPage()),
    ),
  );
}

// ── UI ───────────────────────────────────────────────────────────────────────
class PostsPage extends StatelessWidget {
  const PostsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Posts – BLoC'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () =>
                context.read<PostsBloc>().add(PostsRefreshRequested()),
          ),
        ],
      ),
      body: BlocBuilder<PostsBloc, PostsState>(
        builder: (context, state) => switch (state) {
          PostsInitial() => const SizedBox.shrink(),
          PostsLoading() =>
            const Center(child: CircularProgressIndicator()),
          PostsLoaded(:final posts) => ListView.separated(
              itemCount: posts.length,
              separatorBuilder: (_, __) => const Divider(height: 1),
              itemBuilder: (_, i) => ListTile(
                leading: CircleAvatar(child: Text('${posts[i].id}')),
                title: Text(posts[i].title),
              ),
            ),
          PostsError(:final message) => Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.error_outline, size: 48, color: Colors.red),
                  const SizedBox(height: 8),
                  Text(message, textAlign: TextAlign.center),
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: () =>
                        context.read<PostsBloc>().add(PostsLoadRequested()),
                    child: const Text('Reintentar'),
                  ),
                ],
              ),
            ),
        },
      ),
    );
  }
}

Resultado esperado

  • Al abrir la app aparece CircularProgressIndicator.
  • Tras 1 segundo se renderiza la lista de 5 posts.
  • El botón de refresco vuelve a mostrar el loader y recarga.
  • Si descomentamos el throw, se muestra la pantalla de error con botón de reintento.

Errores frecuentes

  • Registrar el mismo tipo de evento dos veces con on<>: el segundo handler nunca se llama; consolida la lógica en uno solo.
  • Emitir desde fuera del handler (ej. en un callback asíncrono anidado): usa emit solo dentro del handler o a través de Emitter directamente.
  • Estados mutables: si el estado contiene listas, no las modifiques directamente; emite siempre un nuevo estado con una nueva lista.

Diferencia entre Cubit y BLoC aquí

Con Cubit llamarías postsBloc.load() desde la UI. Con BLoC la UI solo dispara PostsLoadRequested() y desconoce cómo se resuelve, lo que facilita:

  • Testear el BLoC de forma aislada sin tocar la UI.
  • Añadir logging/analytics por evento sin cambiar la UI.
  • Transformar o debounce eventos antes de procesarlos.

Aplicación práctica

Este patrón es el estándar en equipos grandes y en apps con flujos de datos complejos: feeds, búsqueda con filtros, carrito de compra, notificaciones en tiempo real.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Por qué usar sealed para eventos y estados?

Con sealed el compilador de Dart garantiza que el switch sea exhaustivo: si añades un nuevo evento o estado sin actualizar el switch, el compilador avisa en tiempo de compilación.

¿Cuándo usar BlocListener en lugar de BlocBuilder?

BlocListener es para side effects (mostrar snackbar, navegar, reproducir sonido) que deben ocurrir una vez, no para reconstruir la UI.

¿Se puede combinar BlocBuilder y BlocListener?

Sí, con BlocConsumer, que combina ambos en un solo widget cuando necesitas reaccionar a cambios de estado tanto en UI como con side effects.