Mobile Development 11 min read

Separating Business Logic from UI in Flutter: AsyncNotifier‑Based Authentication Flow

The article shows how to keep Flutter UI code clean by moving authentication logic into an AsyncNotifier controller that interacts with an AuthRepository, letting the SignInScreen watch AsyncValue states for loading, success, and errors, thus improving testability and maintainability.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Separating Business Logic from UI in Flutter: AsyncNotifier‑Based Authentication Flow

When developing Flutter applications, separating business logic from UI code is crucial for testability and maintainability, especially as the app grows in complexity.

One common approach is to adopt a layered architecture, introducing a presentation layer that uses Controllers to store business logic, manage widget state, and interact with a data‑layer Repository.

The article demonstrates this pattern through a simple authentication flow, focusing on three components: an AuthRepository, a SignInScreen widget, and a corresponding Controller.

AuthRepository

An abstract AuthRepository defines three methods: a stream of authentication state changes, a method to sign in anonymously, and a method to sign out.

abstract class AuthRepository {
  // emits a new value every time the authentication state changes
  Stream
authStateChanges();

  Future
signInAnonymously();

  Future
signOut();
}

A concrete implementation (e.g., FakeAuthRepository) can be provided via a Riverpod Provider.

final authRepositoryProvider = Provider
((ref) {
  // return a concrete implementation of AuthRepository
  return FakeAuthRepository();
});

SignInScreen Widget

A basic SignInScreen widget is shown, initially as a ConsumerWidget with an ElevatedButton that triggers anonymous sign‑in.

class SignInScreen extends ConsumerWidget {
  const SignInScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sign In')),
      body: Center(
        child: ElevatedButton(
          child: Text('Sign in anonymously'),
          onPressed: () { /* TODO: Implement */ },
        ),
      ),
    );
  }
}

To handle loading and error states, the widget can be converted to a ConsumerStatefulWidget, tracking a local isLoading flag and using setState to update the UI.

class SignInScreen extends ConsumerStatefulWidget {
  const SignInScreen({Key? key}) : super(key: key);

  @override
  ConsumerState
createState() => _SignInScreenState();
}

class _SignInScreenState extends ConsumerState
{
  bool isLoading = false;

  Future
_signInAnonymously() async {
    try {
      setState(() => isLoading = true);
      await ref.read(authRepositoryProvider).signInAnonymously();
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
    } finally {
      if (mounted) setState(() => isLoading = false);
    }
  }

  // ...
}

Embedding the logic directly in the widget quickly becomes unmanageable for larger screens, motivating the extraction of a dedicated Controller.

AsyncNotifier‑based Controller

An AsyncNotifier subclass (or the newer @riverpod syntax) is used to encapsulate the sign‑in process.

class SignInScreenController extends AsyncNotifier
{
  @override
  FutureOr
build() {
    // no‑op
  }

  Future
signInAnonymously() async {
    final authRepository = ref.read(authRepositoryProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => authRepository.signInAnonymously());
  }
}

Using AsyncValue allows the UI to react to three states: data, loading, and error.

The widget now watches the controller provider and updates its UI accordingly.

class SignInScreen extends ConsumerWidget {
  const SignInScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue
state = ref.watch(signInScreenControllerProvider);
    ref.listen
(signInScreenControllerProvider, (_, state) {
      if (!state.isLoading && state.hasError) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error.toString())));
      }
    });
    return Scaffold(
      appBar: AppBar(title: const Text('Sign In')),
      body: Center(
        child: ElevatedButton(
          child: state.isLoading ? const CircularProgressIndicator() : const Text('Sign in anonymously'),
          onPressed: state.isLoading ? null : () => ref.read(signInScreenControllerProvider.notifier).signInAnonymously(),
        ),
      ),
    );
  }
}

An optional extension on AsyncValue can encapsulate the error‑snackbar logic for reuse across multiple widgets.

extension AsyncValueUI on AsyncValue {
  void showSnackbarOnError(BuildContext context) {
    if (!isLoading && hasError) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
    }
  }
}

By moving business logic into an AsyncNotifier controller, the UI layer remains stateless and focused solely on rendering, while the controller can be unit‑tested independently.

Flutterarchitecturestate managementAuthenticationRiverpodAsyncNotifier
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.