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 SplashScreen2final token = await secureStorage.read(key: 'token');3if (token != null) {4 navigator.pushReplacement(HomeScreen());5} else {6 navigator.pushReplacement(LoginScreen());7}89// En el interceptor de Dio10final token = await secureStorage.read(key: 'token');11if (token == null) {12 // ¿Y ahora qué? No tengo contexto de navegación acá13}1415// En el ProfileScreen16final 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"| CCada 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}67class AuthLocalDataSourceImpl implements AuthLocalDataSource {8 final FlutterSecureStorage _secureStorage;9 final JwtDecoder _jwtDecoder;1011 final BehaviorSubject<TokenMetadata?> _tokenMetadataSubject =12 BehaviorSubject<TokenMetadata?>.seeded(null);1314 @override15 Stream<TokenMetadata?> get tokenMetadataStream =>16 _tokenMetadataSubject.stream;1718 @override19 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 }2425 @override26 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;34 ObserveAuthStatusUseCase(this._repository);56 Stream<TokenMetadata?> call() => _repository.observeTokenMetadata();7}
El Repository delega al DataSource:
1class AuthRepositoryImpl implements AuthRepository {2 final AuthLocalDataSource _localDataSource;34 @override5 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;45 AppStatusBloc({required ObserveAuthStatusUseCase observeAuthStatus})6 : _observeAuthStatus = observeAuthStatus,7 super(const AppStatusInitial()) {8 on<_TokenChanged>(_onTokenChanged);9 on<UserLoggedOut>(_onUserLoggedOut);1011 _tokenSubscription = _observeAuthStatus().listen(12 (metadata) => add(_TokenChanged(metadata)),13 );14 }1516 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 }2324 void _onUserLoggedOut(UserLoggedOut event, Emitter<AppStatusState> emit) {25 emit(const AppStatusUnauthenticated());26 }2728 @override29 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;34 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;1011 if (appState is AppStatusInitial) {12 return location == AppRoutes.splash ? null : AppRoutes.splash;13 }1415 if (appState is AppStatusUnauthenticated) {16 return location == AppRoutes.login ? null : AppRoutes.login;17 }1819 if (appState is AppStatusAuthenticated) {20 if (location == AppRoutes.login || location == AppRoutes.splash) {21 return AppRoutes.home;22 }23 }2425 return null;26 },27 routes: [ /* ... */ ],28 );2930 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"]
endLos 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;34 @override5 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;34 ValidateTokenUseCase(this._repository);56 Future<void> call() async {7 final metadata = await _repository.getTokenMetadata();8 if (metadata != null && metadata.isExpired) {9 await _repository.deleteToken();10 }11 }12}1314class AppLifecycleObserver with WidgetsBindingObserver {15 final ValidateTokenUseCase _validateToken;1617 AppLifecycleObserver({required ValidateTokenUseCase validateToken})18 : _validateToken = validateToken;1920 @override21 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);45 expect(6 dataSource.tokenMetadataStream,7 emits(isA<TokenMetadata>()),8 );9});1011test('WHEN deleteToken THEN emite null', () async {12 await dataSource.deleteToken();1314 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);56 observer.didChangeAppLifecycleState(AppLifecycleState.resumed);78 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.