Skip to content

Authentication ​

A state machine pattern for authentication flow: LoggedOut, Authenticating, Authenticated, and Error states. Demonstrates stateMachine() with typed states, events, async transitions, guard functions, and transition effects.

State Machine Definition ​

dart
import 'package:flutter/material.dart';
import 'package:flutter_reacton/flutter_reacton.dart';

// --- States and Events ---

enum AuthState { loggedOut, authenticating, authenticated, error }
enum AuthEvent { login, logout, tokenExpired, retry }

// --- Auth Data ---

class AuthUser {
  final String id;
  final String email;
  final String displayName;

  const AuthUser({
    required this.id,
    required this.email,
    required this.displayName,
  });
}

// --- Reactons ---

/// Holds the currently authenticated user (null when logged out).
final authUserReacton = reacton<AuthUser?>(null, name: 'authUser');

/// Holds the last authentication error message.
final authErrorReacton = reacton<String?>(null, name: 'authError');

/// The authentication state machine.
final authMachine = stateMachine<AuthState, AuthEvent>(
  initial: AuthState.loggedOut,
  transitions: {
    AuthState.loggedOut: {
      AuthEvent.login: (ctx) async {
        // Transition to authenticating state happens immediately
        // The async work determines the final state
        return AuthState.authenticating;
      },
    },
    AuthState.authenticating: {
      // No events handled while authenticating
      // The login flow completes asynchronously
    },
    AuthState.authenticated: {
      AuthEvent.logout: (ctx) => AuthState.loggedOut,
      AuthEvent.tokenExpired: (ctx) => AuthState.loggedOut,
    },
    AuthState.error: {
      AuthEvent.retry: (ctx) => AuthState.authenticating,
      AuthEvent.logout: (ctx) => AuthState.loggedOut,
    },
  },
  onTransition: (previousState, newState) {
    // Side effect: log transitions for debugging
    debugPrint('Auth: $previousState -> $newState');
  },
  name: 'auth',
);

/// Derived: whether the user is currently logged in.
final isLoggedInReacton = computed(
  (read) => read(authUserReacton) != null,
  name: 'isLoggedIn',
);

Authentication Service ​

dart
/// Simulates an authentication API.
class AuthService {
  /// Attempt to log in. Returns a user on success, throws on failure.
  static Future<AuthUser> login(String email, String password) async {
    // Simulate network delay
    await Future.delayed(const Duration(seconds: 2));

    if (email == 'test@example.com' && password == 'password') {
      return AuthUser(
        id: 'user-1',
        email: email,
        displayName: 'Test User',
      );
    }

    throw Exception('Invalid email or password');
  }
}

Login Flow with Effects ​

dart
/// Performs the actual login and updates the state machine + user reacton.
Future<void> performLogin(
  ReactonStore store,
  String email,
  String password,
) async {
  // Set machine to authenticating
  store.set(authMachine.stateReacton, AuthState.authenticating);
  store.set(authErrorReacton, null);

  try {
    final user = await AuthService.login(email, password);

    // Success: set user and transition to authenticated
    store.set(authUserReacton, user);
    store.set(authMachine.stateReacton, AuthState.authenticated);
  } catch (e) {
    // Failure: clear user and transition to error
    store.set(authUserReacton, null);
    store.set(authErrorReacton, e.toString());
    store.set(authMachine.stateReacton, AuthState.error);
  }
}

/// Log out: clear user data and transition to logged out.
void performLogout(ReactonStore store) {
  store.batch(() {
    store.set(authUserReacton, null);
    store.set(authErrorReacton, null);
    store.set(authMachine.stateReacton, AuthState.loggedOut);
  });
}

UI Implementation ​

dart
void main() => runApp(ReactonScope(child: const AuthApp()));

class AuthApp extends StatelessWidget {
  const AuthApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Auth Example',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const AuthGate(),
    );
  }
}

/// Routes to the correct page based on auth state.
class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    final authState = context.watch(authMachine.stateReacton);

    return switch (authState) {
      AuthState.loggedOut => const LoginPage(),
      AuthState.authenticating => const LoadingPage(),
      AuthState.authenticated => const HomePage(),
      AuthState.error => const ErrorPage(),
    };
  }
}

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailController = TextEditingController(text: 'test@example.com');
  final _passwordController = TextEditingController(text: 'password');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _emailController,
              decoration: const InputDecoration(
                labelText: 'Email',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _passwordController,
              obscureText: true,
              decoration: const InputDecoration(
                labelText: 'Password',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: () {
                performLogin(
                  context.reactonStore,
                  _emailController.text,
                  _passwordController.text,
                );
              },
              child: const Text('Sign In'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

class LoadingPage extends StatelessWidget {
  const LoadingPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: CircularProgressIndicator()),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    final user = context.watch(authUserReacton);

    return Scaffold(
      appBar: AppBar(
        title: Text('Welcome, ${user?.displayName ?? "User"}'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () => performLogout(context.reactonStore),
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.check_circle, size: 64, color: Colors.green),
            const SizedBox(height: 16),
            Text('Logged in as ${user?.email}'),
          ],
        ),
      ),
    );
  }
}

class ErrorPage extends StatelessWidget {
  const ErrorPage({super.key});

  @override
  Widget build(BuildContext context) {
    final error = context.watch(authErrorReacton);

    return Scaffold(
      appBar: AppBar(title: const Text('Error')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error_outline, size: 64, color: Colors.red),
            const SizedBox(height: 16),
            Text(error ?? 'An error occurred'),
            const SizedBox(height: 24),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                OutlinedButton(
                  onPressed: () => performLogout(context.reactonStore),
                  child: const Text('Back to Login'),
                ),
                const SizedBox(width: 16),
                FilledButton(
                  onPressed: () {
                    // Retry would need the credentials again
                    performLogout(context.reactonStore);
                  },
                  child: const Text('Try Again'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Key Concepts ​

State Machine Pattern ​

The stateMachine() function creates a state machine with typed states and events. Each state defines which events it can handle and what the resulting state should be:

dart
transitions: {
  AuthState.loggedOut: {
    AuthEvent.login: (ctx) async => AuthState.authenticating,
  },
  AuthState.authenticated: {
    AuthEvent.logout: (ctx) => AuthState.loggedOut,
    AuthEvent.tokenExpired: (ctx) => AuthState.loggedOut,
  },
}

Transition Effects ​

The onTransition callback runs after every state change, useful for logging, analytics, or triggering additional side effects:

dart
onTransition: (previousState, newState) {
  debugPrint('Auth: $previousState -> $newState');
},

Exhaustive Pattern Matching ​

Dart's switch expression with enum ensures every state is handled in the UI:

dart
return switch (authState) {
  AuthState.loggedOut => const LoginPage(),
  AuthState.authenticating => const LoadingPage(),
  AuthState.authenticated => const HomePage(),
  AuthState.error => const ErrorPage(),
};

If you add a new state to the enum, the compiler forces you to handle it.

Batch Updates ​

Use store.batch() to update multiple reactons atomically. During logout, we clear the user and error at the same time:

dart
store.batch(() {
  store.set(authUserReacton, null);
  store.set(authErrorReacton, null);
  store.set(authMachine.stateReacton, AuthState.loggedOut);
});

What's Next ​

Released under the MIT License.