alberto marturelo
← Volver

8 de septiembre de 2026

Side effects en Flutter Bloc 2026: ¿estados transitorios o stream separado?

Después de escalar apps a millones de usuarios, he aprendido que el mayor enemigo de una UI fluida no es el hardware, sino un estado contaminado.

Imagina esta escena (la hemos vivido todos): estás en una pantalla de login compleja. El usuario pulsa "Entrar", aparece un overlay de carga, la petición falla y… el teclado se cierra solo, el campo de texto pierde el foco y aparece un SnackBar de error de la nada.

¿Qué pasó? Tu Bloc emitió un estado de error, el BlocBuilder reconstruyó todo el árbol de widgets innecesariamente y destruyó la experiencia del usuario.


En 2026, seguir tratando las acciones únicas (side effects) como estados es deuda técnica que tu equipo no debería pagar. Vamos a ver por qué el patrón de stream separado está ganando terreno en equipos con más experiencia.

Confundir "lo que la pantalla es" con "lo que la pantalla hace"

El estado es la identidad de tu pantalla en un momento dado: "tengo estos datos", "hay un error persistente". Un side effect es una acción efímera, un one-shot: navegar, mostrar un diálogo, vibrar.

Esa distinción parece obvia, pero la mayoría de tutoriales la ignoran.

Enfoque A: los "estados fantasma"

Muchos tutoriales sugieren meter efectos dentro de un sealed class de estado:

1@freezed
2class AuthState with _$AuthState {
3 const factory AuthState.loading() = _Loading;
4 const factory AuthState.showError(String msg) = _ShowError; // efecto disfrazado de estado
5 const factory AuthState.success() = _Success;
6}

¿Por qué esto duele en producción?

  • Te obliga a emitir un estado de "limpieza" justo después del error para evitar que el SnackBar se repita al rotar la pantalla. Ciclos de vida artificiales.
  • Tu DevTools se llena de estados transitorios que dificultan seguir el flujo real de datos.
  • Cada emit dispara un rebuild. Si tienes animaciones o formularios, vas a notar saltos visuales o pérdida de foco.

Enfoque B: doble canal (state + effect)

En este modelo, el Bloc tiene dos salidas: una para el qué y otra para el cuándo. Para implementarlo sin reinventar la rueda, usamos bloc_one_shot, una librería liviana que resuelve esto con un mixin y un par de widgets.

Definimos los efectos

1sealed class LoginEffect {}
2
3class NavigateToHome extends LoginEffect {}
4
5class ShowErrorSnackbar extends LoginEffect {
6 final String message;
7 ShowErrorSnackbar(this.message);
8}

El Bloc

En vez de manejar un StreamController a mano, agregás SideEffectMixin a tu Bloc o Cubit. Eso te da emitEffect() y el stream effects gratis, con buffering incluido (si un efecto se emite antes de que el listener se suscriba, no se pierde).

1class LoginCubit extends Cubit<LoginState>
2 with SideEffectMixin<LoginState, LoginEffect> {
3
4 Future<void> login(String email, String password) async {
5 emit(LoginLoading());
6 try {
7 await authRepo.login(email, password);
8 emit(LoginSuccess());
9 emitEffect(NavigateToHome());
10 } catch (e) {
11 emit(LoginInitial());
12 emitEffect(ShowErrorSnackbar(e.toString()));
13 }
14 }
15}

Fijate que el estado solo refleja datos persistentes (loading, success, initial). La navegación y el snackbar van por el canal de efectos.

La UI

Nada de suscribirte manualmente en initState. SideEffectListener se encarga de escuchar los efectos sin triggear rebuilds:

1SideEffectListener<LoginCubit, LoginEffect>(
2 listener: (context, effect) {
3 switch (effect) {
4 case NavigateToHome():
5 Navigator.of(context).pushReplacementNamed('/home');
6 case ShowErrorSnackbar(:final message):
7 ScaffoldMessenger.of(context).showSnackBar(
8 SnackBar(content: Text(message)),
9 );
10 }
11 },
12 child: LoginForm(),
13)

Si necesitás combinar el builder de estado con el listener de efectos en un solo widget, también existe SideEffectConsumer:

1SideEffectConsumer<LoginCubit, LoginState, LoginEffect>(
2 builder: (context, state) {
3 if (state is LoginLoading) return CircularProgressIndicator();
4 return LoginForm();
5 },
6 listener: (context, effect) {
7 if (effect is NavigateToHome) {
8 Navigator.of(context).pushReplacementNamed('/home');
9 }
10 },
11)

Y los tests?

Una de las ventajas más claras del enfoque B es lo fácil que es testear. Con blocEffectTest podés verificar estados y efectos en un solo test, sin estados fantasma en el medio:

1blocEffectTest<LoginCubit, LoginState, LoginEffect>(
2 'emite LoginSuccess y NavigateToHome cuando el login es correcto',
3 build: () => LoginCubit(authRepo: mockAuthRepo),
4 act: (cubit) => cubit.login('test@test.com', 'password'),
5 expect: () => [isA<LoginLoading>(), isA<LoginSuccess>()],
6 expectEffects: () => [isA<NavigateToHome>()],
7);

Dos canales, dos asserts limpios.

Conclusión

Si estás construyendo un MVP o una app de tres pantallas, el enfoque A te va a servir. Pero si trabajas en una app financiera, un e-commerce o cualquier proyecto que aspire a ser mantenible por más de 6 meses, el enfoque B es lo que mejor funciona hoy.

Al separar los efectos del estado, ganás tranquilidad: sabés que un cambio visual no va a romper la lógica de navegación y viceversa.

¿Vos qué opinás? ¿Seguís limpiando estados manualmente o ya te pasaste a streams independientes? Hablemos en los comentarios.


bloc_one_shot en GitHub

Uso Google Analytics para saber qué se lee. Sin cookies de publicidad ni de terceros más allá de esa medición. Más detalle