alberto marturelo
← Volver

8 de septiembre de 2026

App Status reactivo en Flutter: un stream para gobernarlos a todos

por Alberto

En apps de producción, el estado de autenticación no se verifica — se observa.

Si en tu proyecto hay más de un punto del código preguntando "¿el usuario está logueado?", tienes un problema que todavía no explotó. Después de implementar este patrón en apps financieras y de alto tráfico, puedo decirte que la diferencia se nota en producción: sin navegación manual, sin verificaciones dispersas, sin estados zombie. Un solo stream reactivo que condiciona toda la app.


El problema: estado fragmentado

Este es un escenario que he visto (y cometido) en proyectos reales. El estado de autenticación vive en múltiples lugares y cada uno lo verifica a su manera:

1// En el SplashScreen
2final token = await secureStorage.read(key: 'token');
3if (token != null) {
4 navigator.pushReplacement(HomeScreen());
5} else {
6 navigator.pushReplacement(LoginScreen());
7}
8
9// En el interceptor de Dio
10final token = await secureStorage.read(key: 'token');
11if (token == null) {
12 // ¿Y ahora qué? No tengo contexto de navegación acá
13}
14
15// En el ProfileScreen
16final token = await authRepository.getToken();
17if (token == null || isExpired(token)) {
18 context.go('/login');
19}

Tres puntos del código decidiendo lo mismo de forma independiente. Tres fuentes de verdad para un solo dato. ¿Qué puede salir mal?

  • Race conditions: el interceptor detecta un 401 y borra el token, pero otra pantalla ya estaba haciendo otra petición con el token viejo.
  • Navegación inconsistente: el usuario ve un flash de una pantalla antes de ser redirigido al login.
  • Estado zombie: la app cree que está autenticada pero el token ya expiró, o viceversa.

El problema de fondo no es técnico, es arquitectónico: nadie debería preguntar si el usuario está autenticado. El sistema debería saberlo y reaccionar solo.

La solución: un stream reactivo como única fuente de verdad

El principio es simple: un BehaviorSubject en la capa de datos emite el estado del token. Un Bloc global escucha ese stream y emite el estado de la app. El router observa ese Bloc y redirige automáticamente.

Nadie navega — todos reaccionan.

graph TD
    accTitle: Las tres capas del patrón — DataSource emite al stream, el Bloc lo traduce a estado y el Router redirige
    A["AuthLocalDataSource\nBehaviorSubject‹TokenMetadata?›\nEmite: metadata al guardar, null al borrar"]
    B["AppStatusBloc\nEscucha el stream via UseCase\nEmite: Authenticated / Unauthenticated"]
    C["GoRouter\nrefreshListenable: appStatusBloc\nRedirige según el estado, automáticamente"]

    A -->|"Stream‹TokenMetadata?›"| B
    B -->|"AppStatusState"| C

Cada capa tiene una sola responsabilidad. Ninguna conoce los detalles de las demás. Y lo más importante: nadie llama context.go('/login') desde lógica de negocio.

Veamos cómo se construye esto, capa por capa.

Implementación

1. El DataSource: un stream con memoria

Usamos un BehaviorSubject de RxDart porque necesitamos dos cosas: un stream reactivo y que cualquier nuevo listener reciba inmediatamente el último valor emitido. Un StreamController.broadcast no te da eso sin código extra.

1abstract class AuthLocalDataSource {
2 Stream<TokenMetadata?> get tokenMetadataStream;
3 Future<void> saveToken(String token);
4 Future<void> deleteToken();
5}
6
7class AuthLocalDataSourceImpl implements AuthLocalDataSource {
8 final FlutterSecureStorage _secureStorage;
9 final JwtDecoder _jwtDecoder;
10
11 final BehaviorSubject<TokenMetadata?> _tokenMetadataSubject =
12 BehaviorSubject<TokenMetadata?>.seeded(null);
13
14 @override
15 Stream<TokenMetadata?> get tokenMetadataStream =>
16 _tokenMetadataSubject.stream;
17
18 @override
19 Future<void> saveToken(String token) async {
20 final metadata = _jwtDecoder.decodeToken(token);
21 await _secureStorage.write(key: _tokenKey, value: token);
22 _tokenMetadataSubject.add(metadata);
23 }
24
25 @override
26 Future<void> deleteToken() async {
27 await _secureStorage.delete(key: _tokenKey);
28 _tokenMetadataSubject.add(null);
29 }
30}

El BehaviorSubject actúa como espejo reactivo de lo que hay en SecureStorage. Cuando alguien guarda un token, el stream emite los metadatos. Cuando alguien lo borra, emite null. No importa quién lo haga ni desde dónde — el efecto siempre es el mismo.

2. El UseCase: conectando capas sin romper CLEAN

El Bloc no conoce el DataSource ni el Repository directamente. Un UseCase stream expone la información que la capa de presentación necesita:

1class ObserveAuthStatusUseCase {
2 final AuthRepository _repository;
3
4 ObserveAuthStatusUseCase(this._repository);
5
6 Stream<TokenMetadata?> call() => _repository.observeTokenMetadata();
7}

El Repository delega al DataSource:

1class AuthRepositoryImpl implements AuthRepository {
2 final AuthLocalDataSource _localDataSource;
3
4 @override
5 Stream<TokenMetadata?> observeTokenMetadata() =>
6 _localDataSource.tokenMetadataStream;
7}

Sí, es una capa extra. Pero es la misma capa que te permite testear el Bloc sin levantar SecureStorage, o cambiar de RxDart a otra implementación sin tocar una sola línea de presentación.

3. El Bloc: transformando datos en estado de app

El AppStatusBloc escucha el stream del UseCase y emite el estado correspondiente:

1class AppStatusBloc extends Bloc<AppStatusEvent, AppStatusState> {
2 final ObserveAuthStatusUseCase _observeAuthStatus;
3 StreamSubscription<TokenMetadata?>? _tokenSubscription;
4
5 AppStatusBloc({required ObserveAuthStatusUseCase observeAuthStatus})
6 : _observeAuthStatus = observeAuthStatus,
7 super(const AppStatusInitial()) {
8 on<_TokenChanged>(_onTokenChanged);
9 on<UserLoggedOut>(_onUserLoggedOut);
10
11 _tokenSubscription = _observeAuthStatus().listen(
12 (metadata) => add(_TokenChanged(metadata)),
13 );
14 }
15
16 void _onTokenChanged(_TokenChanged event, Emitter<AppStatusState> emit) {
17 if (event.tokenMetadata != null && !event.tokenMetadata!.isExpired) {
18 emit(const AppStatusAuthenticated());
19 } else {
20 emit(const AppStatusUnauthenticated());
21 }
22 }
23
24 void _onUserLoggedOut(UserLoggedOut event, Emitter<AppStatusState> emit) {
25 emit(const AppStatusUnauthenticated());
26 }
27
28 @override
29 Future<void> close() {
30 _tokenSubscription?.cancel();
31 return super.close();
32 }
33}

Fíjate que _TokenChanged es un evento privado. Nadie desde afuera puede emitirlo — solo el stream interno. El Bloc es un transformador puro: recibe metadata, evalúa validez, emite estado.

4. El Router: navegación declarativa

Aquí es donde todo se conecta. GoRouter tiene una propiedad refreshListenable que acepta cualquier Listenable. Bloc extiende BlocBase, que implementa StateStreamableSource, que a su vez implementa Listenable. En la práctica, esto significa que cada vez que el Bloc emite un nuevo estado, el router re-evalúa sus reglas de redirect automáticamente:

1class AppRouter {
2 final AppStatusBloc _appStatusBloc;
3
4 late final GoRouter router = GoRouter(
5 initialLocation: AppRoutes.splash,
6 refreshListenable: _appStatusBloc,
7 redirect: (context, state) {
8 final appState = _appStatusBloc.state;
9 final location = state.matchedLocation;
10
11 if (appState is AppStatusInitial) {
12 return location == AppRoutes.splash ? null : AppRoutes.splash;
13 }
14
15 if (appState is AppStatusUnauthenticated) {
16 return location == AppRoutes.login ? null : AppRoutes.login;
17 }
18
19 if (appState is AppStatusAuthenticated) {
20 if (location == AppRoutes.login || location == AppRoutes.splash) {
21 return AppRoutes.home;
22 }
23 }
24
25 return null;
26 },
27 routes: [ /* ... */ ],
28 );
29
30 AppRouter({required AppStatusBloc appStatusBloc})
31 : _appStatusBloc = appStatusBloc;
32}

No hay un solo context.go() en la lógica de negocio. El router reacciona al estado, y punto.

Donde el patrón brilla: tres flujos, un mismo mecanismo

La verdadera prueba de una arquitectura no es el happy path — es cuántos escenarios distintos resuelve sin código adicional. Este patrón maneja login, logout y expiración automática con el mismo mecanismo:

graph LR
    accTitle: Login, logout y expiración de token recorren la misma cadena y acaban en una redirección del Router

    subgraph Login
        direction LR
        L1["saveToken()"] --> L2["stream emite metadata"] --> L3["Bloc: Authenticated"] --> L4["Router: /home"]
    end

    subgraph Logout
        direction LR
        O1["deleteToken()"] --> O2["stream emite null"] --> O3["Bloc: Unauthenticated"] --> O4["Router: /login"]
    end

    subgraph "401 / Expiración"
        direction LR
        E1["deleteToken()"] --> E2["stream emite null"] --> E3["Bloc: Unauthenticated"] --> E4["Router: /login"]
    end

Los tres flujos convergen en el mismo punto: alguien modifica el token en el DataSource, y todo lo demás reacciona solo.

El interceptor de Dio es el mejor ejemplo de esta simplicidad:

1class AuthInterceptor extends Interceptor {
2 final AuthLocalDataSource _localDataSource;
3
4 @override
5 void onError(DioException err, ErrorInterceptorHandler handler) async {
6 if (err.response?.statusCode == 401) {
7 await _localDataSource.deleteToken();
8 }
9 handler.next(err);
10 }
11}

Una línea. deleteToken(). El interceptor no sabe nada de navegación, de Blocs, ni de UI. Solo borra el token y el sistema reactivo hace el resto.

Lifecycle: el edge case que nadie cubre

¿Qué pasa si el token expira mientras la app está en background? El usuario vuelve, la app muestra el home por un instante, y solo después lo redirige al login. Ese flash es la diferencia entre una app profesional y una que "más o menos funciona".

La solución encaja naturalmente en el patrón. Un UseCase valida el token y, si expiró, lo borra — activando la misma cadena reactiva de siempre:

1class ValidateTokenUseCase {
2 final AuthRepository _repository;
3
4 ValidateTokenUseCase(this._repository);
5
6 Future<void> call() async {
7 final metadata = await _repository.getTokenMetadata();
8 if (metadata != null && metadata.isExpired) {
9 await _repository.deleteToken();
10 }
11 }
12}
13
14class AppLifecycleObserver with WidgetsBindingObserver {
15 final ValidateTokenUseCase _validateToken;
16
17 AppLifecycleObserver({required ValidateTokenUseCase validateToken})
18 : _validateToken = validateToken;
19
20 @override
21 void didChangeAppLifecycleState(AppLifecycleState state) {
22 if (state == AppLifecycleState.resumed) {
23 _validateToken();
24 }
25 }
26}

El Observer no conoce ni el DataSource ni el Repository. Solo invoca un UseCase. Y deleteToken() desencadena la misma cascada: stream emite null, Bloc reacciona, Router redirige.

Tests

Si cada componente tiene una sola responsabilidad, testearlo debería ser igual de simple. Y lo es:

1// DataSource: ¿el stream emite lo que debe?
2test('WHEN saveToken THEN emite TokenMetadata', () async {
3 await dataSource.saveToken(validJwt);
4
5 expect(
6 dataSource.tokenMetadataStream,
7 emits(isA<TokenMetadata>()),
8 );
9});
10
11test('WHEN deleteToken THEN emite null', () async {
12 await dataSource.deleteToken();
13
14 expect(
15 dataSource.tokenMetadataStream,
16 emits(isNull),
17 );
18});
1// Bloc: ¿transforma el stream en el estado correcto?
2blocTest<AppStatusBloc, AppStatusState>(
3 'GIVEN token válido WHEN stream emite THEN estado es Authenticated',
4 build: () {
5 when(() => mockObserveAuthStatus())
6 .thenAnswer((_) => Stream.value(validMetadata));
7 return AppStatusBloc(observeAuthStatus: mockObserveAuthStatus);
8 },
9 expect: () => [const AppStatusAuthenticated()],
10);
1// Lifecycle: ¿borra el token expirado al volver del background?
2test('GIVEN token expirado WHEN app resume THEN borra token', () async {
3 when(() => mockRepository.getTokenMetadata())
4 .thenAnswer((_) async => expiredMetadata);
5
6 observer.didChangeAppLifecycleState(AppLifecycleState.resumed);
7
8 verify(() => mockRepository.deleteToken()).called(1);
9});

Sin mocks cruzados, sin setup complejo. Cada test verifica exactamente una cosa.

Conclusión

El patrón de App Status reactivo no es sobre tecnología específica — RxDart, Bloc y GoRouter son herramientas que pueden cambiar. Lo que no cambia es el principio: un estado global observable que condiciona la app de forma declarativa, sin navegación imperativa dispersa en tu codebase.

Si en tu proyecto hay más de un if (token == null) context.go('/login'), tienes estado fragmentado. Y el estado fragmentado es deuda técnica silenciosa: no explota hoy, explota cuando estás en producción con miles de usuarios y un edge case que no anticipaste.

En el próximo artículo vamos a llevar este patrón más lejos: token refresh con interceptores en cola, múltiples Blocs globales con prioridad de estados, y cómo escalar este sistema para manejar maintenance mode, force update y feature flags.

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