Black Friday Sale

Flutter Foundations Course

Buy now and get 33% off the regular price!

View Course

Flutter App Architecture: The Presentation Layer

Source code on GitHub

When writing Flutter apps, separating any business logic from the UI code is very important.

This makes our code more testable and easier to reason about, and is especially important as our apps become more complex.

To accomplish this, we can use design patterns to introduce a separation of concerns between different components in our app.

And for reference, we can adopt a layered app architecture such as the one represented in this diagram:

Flutter App Architecture using data, domain, application, and presentation layers. Arrows show the dependencies between layers
Flutter App Architecture using data, domain, application, and presentation layers. Arrows show the dependencies between layers

I have already covered some of the layers above in other articles:

And this time, we will focus on the presentation layer and learn how we can use controllers to:

  • hold business logic
  • manage the widget state
  • interact with repositories in the data layer

This kind of controller is the same as the view model that you would use in the MVVM pattern. If you've worked with flutter_bloc before, it has the same role as a cubit.

We will learn about the StateNotifier class, which is a replacement for the ValueNotifier class in the Flutter SDK.

And to make this more useful, we will implement a simple authentication flow as an example.

Ready? Let's go!

Note: this article is based on Riverpod version 2.0.0-dev.5 (which is currently a pre-release).

A simple authentication flow

Let's consider a very simple app that we can use to sign in anonymously and toggle between two screens:

Simple sign-in flow
Simple sign-in flow

And in this article, we'll focus on how to implement:

  • an auth repository that we can use to sign in and sign out
  • a sign-in widget screen that we show to the user
  • the corresponding controller class that mediates between the two

Here's a simplified version of the reference architecture for this specific example:

Layered architecture for the sign in feature
Layered architecture for the sign in feature

You can find the complete source code for this app on GitHub. For more info about how it is organized, read this: Flutter Project Structure: Feature-first or Layer-first?

The AuthRepository class

As a starting point, we can define a simple abstract class that contains three methods that we'll use to sign in, sign out, and check the authentication state:

abstract class AuthRepository { // emits a new value every time the authentication state changes Stream<AppUser?> authStateChanges(); Future<AppUser> signInAnonymously(); Future<void> signOut(); }

In practice, we also need a concrete class that implements AuthRepository. This could be based on Firebase or any other backend. We can even implement it with a fake repository for now. For more details, see this article about the repository pattern.

For completeness, we can also define a simple AppUser model class:

/// Simple class representing the user UID and email. class AppUser { const AppUser({required this.uid}); final String uid; // TODO: Add other fields as needed (email, displayName etc.) }

And if we use Riverpod, we also need a Provider that we can use to access our repository:

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

Next up, let's focus on the sign-in screen.

The SignInScreen widget

Suppose we have a simple SignInScreen widget defined like so:

import 'package:flutter_riverpod/flutter_riverpod.dart'; 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 */ }, ), ), ); } }

This is just a simple Scaffold with an ElevatedButton in the middle.

Note that since this class extends ConsumerWidget, in the build() method we have an extra ref object that we can use to access providers as needed.

Accessing the AuthRepository directly from our widget

As a next step, we can use the onPressed callback to sign in like so:

ElevatedButton( child: Text('Sign in anonymously'), onPressed: () => ref.read(authRepositoryProvider).signInAnonymously(), )

This code works by:

  • obtaining the AuthRepository with a call to ref.read(authRepositoryProvider)
  • calling the signInAnonymously() method on it

This covers the happy path (sign-in successful). But we should also account for loading and error states by:

  • disabling the sign-in button and showing a loading indicator while sign-in is in progress
  • showing a SnackBar or alert if the call fails for any reason

The "StatefulWidget + setState" way

One simple way of addressing this is to:

  • convert our widget into a StatefulWidget (or rather, ConsumerStatefulWidget since we're using Riverpod)
  • add some local variables to keep track of state changes
  • set those variables inside calls to setState() to trigger a widget rebuild
  • use them to update the UI

Here's how the resulting code may look like:

class SignInScreen extends ConsumerStatefulWidget { const SignInScreen({Key? key}) : super(key: key); @override ConsumerState<SignInScreen> createState() => _SignInScreenState(); } class _SignInScreenState extends ConsumerState<SignInScreen> { // keep track of the loading state bool isLoading = false; // call this from the `onPressed` callback Future<void> _signInAnonymously() async { try { // update the state setState(() => isLoading = true); // sign in await ref .read(signInScreenControllerProvider.notifier) .signInAnonymously(); } catch (e) { // show a snackbar if something went wrong ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString())), ); } finally { // check if we're still on this screen (widget is mounted) if (mounted) { // reset the loading state setState(() => isLoading = false); } } } ... }

For a simple app like this, this is probably ok.

But this approach gets quickly out of hand when we have more complex widgets, as we are mixing business logic and UI code in the same widget class.

And if we want to handle loading in error states consistently across multiple widgets, copy-pasting and tweaking the code above is quite error-prone (and not much fun).

Instead, it would be best to move all these concerns into a separate controller class that can:

  • mediate between our SignInScreen and the AuthRepository
  • manage the widget state
  • provide a way for the widget to observe state changes and rebuild itself as a result
Layered architecture for the sign in feature
Layered architecture for the sign in feature

So let's see how to implement it in practice.

A controller class based on StateNotifier

The first step is to create a StateNotifier subclass:

import 'package:flutter_riverpod/flutter_riverpod.dart'; // Create a StateNotifier subclass using AsyncValue<void> as the state class SignInScreenController extends StateNotifier<AsyncValue<void>> { // set the initial value SignInScreenController() : super(const AsyncData<void>(null)); }

Note that since StateNotifier is a generic class, we need to specify the type of the state class we want to use.

In this case, we can choose AsyncValue<void>, as this allows us to represent three states:

  • default (not loading) as AsyncData (same as AsyncValue.data)
  • loading as AsyncLoading (same as AsyncValue.loading)
  • error as AsyncError (same as AsyncValue.error)

And since StateNotifier needs an initial value, we must call super in the initializer list.

If you're not familiar with AsyncValue and its subclasses, read this: How to handle loading and error states with StateNotifier & AsyncValue in Flutter

Implementing the method to sign in

Next up, let's add a method that we can use to sign in:

// Create a StateNotifier subclass using AsyncValue<void> as our state class SignInScreenController extends StateNotifier<AsyncValue<void>> { SignInScreenController({required this.authRepository}) // set the initial value : super(const AsyncData<void>(null)); final AuthRepository authRepository; Future<void> signInAnonymously() async { // set the state to loading state = const AsyncLoading<void>(); // call `authRepository.signInAnonymously` and await for the result state = await AsyncValue.guard<void>( () => authRepository.signInAnonymously(), ); } }

A few notes:

  • We have added an AuthRepository dependency since this is needed for signing in
  • Inside signInAnonymously(), we set the state to AsyncLoading so that the widget can show a loading UI
  • Then, we call AsyncValue.guard and await for the result (which will be either AsyncData or AsyncError)

AsyncValue.guard is a handy alternative to try/catch. For more info, read this: Use AsyncValue.guard rather than try/catch inside your StateNotifier subclasses

And as an extra tip, we can use a method tear-off to simplify our code even further:

// pass authRepository.signInAnonymously directly using tear-off state = await AsyncValue.guard<void>(authRepository.signInAnonymously);

And this completes the implementation of our controller class in just 10 lines of code:

class SignInScreenController extends StateNotifier<AsyncValue<void>> { SignInScreenController({required this.authRepository}) : super(const AsyncData<void>(null)); final AuthRepository authRepository; Future<void> signInAnonymously() async { state = const AsyncLoading<void>(); state = await AsyncValue.guard<void>(authRepository.signInAnonymously); } }

Creating a StateNotifierProvider

Next up, let's create a StateNotifierProvider that we will use in our widget class:

final signInScreenControllerProvider = // StateNotifierProvider takes the controller class and state class as type arguments StateNotifierProvider.autoDispose<SignInScreenController, AsyncValue<void>>( (ref) { return SignInScreenController( authRepository: ref.watch(authRepositoryProvider), ); });

It's worth noting that StateNotifierProvider takes two type arguments:

  • the type of our StateNotifier subclass (SignInScreenController)
  • the type of our state class (AsyncValue<void>)

We also use the autoDispose modifier to ensure the provider's state is disposed when no longer needed.

And we can easily obtain the authRepository dependency with a call to ref.watch(authRepositoryProvider).

Time to get back to our widget class and wire everything up!

Using our controller in the widget class

Here's an updated version of the SignInScreen that uses our new SignInScreenController class:

class SignInScreen extends ConsumerWidget { const SignInScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { // watch and rebuild when the state changes final AsyncValue<void> state = ref.watch(signInScreenControllerProvider); return Scaffold( appBar: AppBar( title: const Text('Sign In'), ), body: Center( child: ElevatedButton( // conditionally show a CircularProgressIndicator if the state is "loading" child: state.isLoading ? const CircularProgressIndicator() : const Text('Sign in anonymously'), // disable the button if the state is loading onPressed: state.isLoading ? null // otherwise, get the notifier and sign in : () => ref .read(signInScreenControllerProvider.notifier) .signInAnonymously(), ), ), ); } }

Note how in the build() method we watch our provider and rebuild the widget when the state changes.

And in the onPressed callback we read the provider's notifier and call signInAnonymously().
And we can also use the isLoading property to conditionally disable the button while sign-in is in progress.

We're almost done, and there's only one thing left to do.

Listening to state changes

Right at the top of the build method, we can add this:

@override Widget build(BuildContext context, WidgetRef ref) { ref.listen<AsyncValue>( signInScreenControllerProvider, (_, state) { if (!state.isRefreshing && state.hasError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(state.error.toString())), ); } }, ); // rest of the build method }

We can use this code to call a listener callback whenever the state changes.

This is useful for showing an error alert or a SnackBar if an error occurs when signing in.

Note: since Riverpod version 2.0.0-dev.1, when a provider emits an AsyncError followed by AsyncData or AsyncLoading, we can still read the previous error data. As a result, we can check the isRefreshing flag to avoid showing multiple snackbars/errors.

Bonus: An AsyncValue extension method

The listener code above is quite useful and we may want to reuse it in multiple widgets.

To do that, we can define this AsyncValue extension:

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

And then, in our widget, we can just import our extension and call this:

ref.listen<AsyncValue>( signInScreenControllerProvider, (_, state) => state.showSnackbarOnError(context), );

Conclusion

By implementing a custom controller class based on StateNotifier, we've separated our business logic from the UI code.

As a result, our widget class is now completely stateless and is only concerned with:

  • watching state changes and rebuilding as a result (with ref.watch)
  • responding to user input by calling methods in the controller (with ref.read)
  • listening to state changes and showing errors if something goes wrong (with ref.listen)

Meanwhile, the job of our controller is to:

  • talk to the repository on behalf of the widget
  • emit state changes as needed

And since the controller doesn't depend on any UI code, it can be easily unit tested.

This makes it an ideal place to store any widget-specific business logic.

The Application Layer

In more complex apps, you may encounter use cases where:

  • multiple widgets share the same logic
  • we need to talk to more than one repository in the same method

These cases may be better described as application-specific business logic that is not tied to any specific widget.

And we can hold this logic inside service classes that belong to the application layer in this architecture diagram:

Flutter App Architecture using data, domain, application, and presentation layers. Arrows show the dependencies between layers
Flutter App Architecture using data, domain, application, and presentation layers. Arrows show the dependencies between layers

But that will be the topic of a follow-up article. 👌

New Flutter Course Now Available

I launched a brand new course that covers Flutter app architecture in great depth, along with other important topics like state management, navigation & routing, testing, and much more:

Want More?

Invest in yourself with my high-quality Flutter courses.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass - Full Course

Flutter Animations Masterclass - Full Course

Master Flutter animations and build a completely custom habit tracking application.