Separation of Concerns in Flutter Applications

Recently, I had to implement onboarding walkthroughs for ClickUp newcomers! This was a really important task because a lot of new users were about to discover the platform with the incredibly funny ad we premiered at the Super Bowl! ✨

via ClickUp

The walkthrough allows our numerous new users, who maybe don’t know ClickUp yet, to quickly understand how to use several functionalities from the application. It is an ongoing effort, just like the new ClickUp University resource we’re pursuing! 🚀

Fortunately, the software architecture behind the ClickUp Flutter mobile application allowed me to implement this functionality quite quickly, even by reusing the real widgets from the application! This means that the walkthrough is dynamic, responsive, and exactly matches the real application screens of the app—and will continue to, even when the widgets will evolve.

I was also able to implement the functionality because of the right separation of concerns.

Let’s see what I mean here. 🤔

Separation of concerns

Designing a software architecture is one of the most complex topics for engineering teams. Among all responsibilities, it is always difficult to anticipate future software evolutions. That’s why creating a well-layered and decoupled architecture can help you and your teammates with a lot of things!

The main benefit of creating small decoupled systems is undoubtedly the testability! And this is what helped me create a demo alternative of the existing screens from the app!

A step-by-step guide

Now, how could we apply those principles to a Flutter application?

We will share a few technics we use to build ClickUp with a simple walkthrough example.

The example is so simple that it may not enlighten all of the advantages behind it, but believe me, it will help you create a lot more maintainable Flutter applications with complex codebases. 💡

The application

As an example, we will create an application that displays the population of the USA for each year.

via ClickUp

We have two screens here :

  • HomeScreen : simply lists all years from 2000 to now. When the users taps on a year tile, they will navigate to the DetailScreen with a navigation argument set to the selected year.
  • DetailScreen : gets the year from the navigation argument, calls the datausa.io API for this year, and parses the JSON data to extract the associated population value. If data is available, a label is displayed with the population.

We will focus on the DetailScreen implementation since it is the most interesting with its asynchronous call.

Step 1. Naive approach

Naive approach Stateful Widget
via ClickUp

The most obvious implementation for our app, is by using a single StatefulWidget for the whole logic.

Accessing the year navigation argument

To access the requested year, we read the RouteSettings from the ModalRoute inherited widget.

void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    // ...
}

HTTP call

This example invokes the get function from the http package to get the data from the datausa.io API, parses the resulting JSON with the jsonDecode method from the dart:convert library, and keep Future as part of the state with a property named _future.

late Future<Map<dynamic, dynamic>?> _future;


void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    if (_year != year) {
      _future = _loadMeasure(year);
    }
}


Future<Map<dynamic, dynamic>?> _loadMeasure(int year) async {
    _year = year;
    final uri = Uri.parse(
        'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year');
    final result = await get(uri);
    final body = jsonDecode(result.body);
    final data = body['data'] as List<dynamic>;
    if (data.isNotEmpty) {
      return data.first;
    }
    return null;

Rendering

To create the widget tree, we’re using a FutureBuilder, which rebuilds itself regarding the current state of our _future asynchronous call.

@override
Widget build(BuildContext context) {
return Scaffold(
    appBar: AppBar(
    title: Text('Year $_year'),
    ),
    body: FutureBuilder<Map<dynamic, dynamic>?>(
    future: _future,
    builder: (context, snapshot) {
        switch (snapshot.connectionState) {
        case ConnectionState.done:
            final error = snapshot.error;
            if (error != null) {
                // return "error" tree.
            }
            final data = snapshot.data;
            if (data != null) {
                // return "result" tree.
            }
            // return "empty" data tree.case ConnectionState.none:
        case ConnectionState.waiting:
        case ConnectionState.active:
            // return "loading" data tree.
        }
    },
    ),
);
}

Review

Okay, the implementation is short and uses only built-in widgets, but now think of our initial intention: building demo alternatives (or tests) for this screen. It is very difficult to control the result of the HTTP call to force the application to render in a certain state.

That’s where the concept of inversion of control will help us. 🧐

Step 2. Inversion of control

Inversion of control to Provider and api Client
via ClickUp

This principle can be hard to understand for new developers (and also hard to explain), but the overall idea is to extract the concerns outside of our components—so that they aren’t responsible for choosing the behavior—and delegate it instead.

In a more common situation, it simply consists of creating abstractions and injecting implementations into our components so their implementation can be changed later on if needed.

But don’t worry, it will make more sense after our next example! 👀

Creating an API client object

In order to control the HTTP call to our API, we’ve isolated our implementation into a dedicated DataUsaApiClient class. We’ve also created a Measure class to make data easier to manipulate and maintain.

class DataUsaApiClient {
  const DataUsaApiClient({
    this.endpoint = 'https://datausa.io/api/data',
  });


  final String endpoint;


  Future<Measure?> getMeasure(int year) async {
    final uri =
        Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year');
    final result = await get(uri);
    final body = jsonDecode(result.body);
    final data = body['data'] as List<dynamic>;
    if (data.isNotEmpty) {
      return Measure.fromJson(data.first as Map<String, Object?>);
    }
    return null;
  }

Provide an API client

For our example, we’re using the well-known provider package to inject a DataUsaApiClient instance at the root of our tree.

Provider<DataUsaApiClient>(
    create: (context) => const DataUsaApiClient(),
        child: const MaterialApp(
        home: HomePage(),
    ),
)

Using the API client

The provider allows any descendent widget (like our DetailScreen) to read the nearest DataUsaApiClient upper in the tree. We can then use its getMeasure method to start our Future, in place of the actual HTTP implementation.

@overridevoid didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    if (_year != year) {
      _year = year;
      final api = context.read<DataUsaApiClient>();
      _future = api.getMeasure(year);
    }
}

Demo API client

Now we can take advantage of this!

In case you didn’t know: any classes in dart also implicitly define an associated interface. This allows us to provide an alternative implementation of DataUsaApiClient which always returns the same instance from its getMeasure method calls.

This method

class DemoDataUsaApiClient implements DataUsaApiClient {
  const DemoDataUsaApiClient(this.measure);


  final Measure measure;


  @overrideString get endpoint => '';


  @override
  Future<Measure?> getMeasure(int year) {
    return Future.value(measure);
  }

Displaying a demo page

We now have all the keys to display a demo instance of the DetailPage!

We simply override the currently provided DataUsaApiClient instance by wrapping our DetailScreen in a provider that creates a DemoDataUsaApiClient instance instead!

And that’s it—our DetailScreen reads this demo instance instead, and uses our demoMeasure data instead of an HTTP call.

ListTile(
    title: const Text('Open demo'),
    onTap: () {
        const demoMeasure = Measure(
            year: 2022,
            population: 425484,
            nation: 'United States',
        );
        Navigator.push(
            context,
            MaterialPageRoute(
                settings: RouteSettings(arguments: demoMeasure.year),
                builder: (context) {
                    return Provider<DataUsaApiClient>(
                        create: (context) =>
                            const DemoDataUsaApiClient(demoMeasure),
                        child: const DetailScreen(),
                    );
                },
            ),
        );
    },
)

Review

This is a great example of Inversion of control. Our DetailScreen widget isn’t responsible for the logic of getting the data anymore, but delegates it instead to a dedicated client object. And we’re now able to create demo instances of the screen, or to implement widget tests for our screen! Awesome! 👏

But we can do even better!

Since we’re not able to simulate a loading state, for example, we don’t have full control of any state change at our widget level.

Step 3. State management

Statement management in flutter applications
via ClickUp

This is a hot topic in Flutter!

I’m sure you’ve already read long threads of people who try to elect the best state-management solution for Flutter. And to be clear, that’s not what we will do in this article. In our opinion, as long as you separate your business logic from your visual logic, you’re fine! Creating these layers is really important for maintainability. Our example is simple, but in real applications, logic can quickly become complex and such separation makes it a lot easier to find your pure logic algorithms. This subject is often summarized as state management.

In this example, we’re using a basic ValueNotifier alongside a Provider. But we could have also used flutter_bloc or riverpod (or another solution), and it would have worked great, too. The principles stay the same, and as long as you separated your states and your logic, it is even possible to port your codebase from one of the other solutions.

This separation also helps us to control any state of our widgets so we can mock it in every possible way!

Creating a dedicated state

Instead of relying on the AsyncSnapshot from the framework, we now represent our screen state as a DetailState object.

It is also important to implement the hashCode and operator == methods to make our object comparable by value. This allows us to detect if two instances should be considered different.

💡 The equatable or freezed packages are great options to help you implement the hashCode and operator == methods!

abstract class DetailState {
  const DetailState(this.year);
  final int year;


  @overridebool operator ==(Object other) =>
      identical(this, other) ||
      (other is DetailState &&
          runtimeType == other.runtimeType &&
          year == other.year);


  @overrideint get hashCode => runtimeType.hashCode ^ year;

Our state is always associated with a year, but we also have four distinct possible states regarding what we want to show to the user:

  • NotLoadedDetailState: the data update hasn’t started yet
  • LoadingDetailState: the data is currently being loaded
  • LoadedDetailState: the data has been successfully loaded with an associated measure
  • NoDataDetailState: the data has been loaded, but there’s no available data
  • UnknownErrorDetailState: the operation failed because of an unknown error
class NotLoadedDetailState extends DetailState {
  const NotLoadedDetailState(int year) : super(year);
}


class LoadedDetailState extends DetailState {
  const LoadedDetailState({
    required int year,
    required this.measure,
  }) : super(year);


  final Measure measure;


  @overridebool operator ==(Object other) =>
      identical(this, other) ||
      (other is LoadedDetailState && measure == other.measure);


  @overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}


class NoDataDetailState extends DetailState {
  const NoDataDetailState(int year) : super(year);
}


class LoadingDetailState extends DetailState {
  const LoadingDetailState(int year) : super(year);
}


class UnknownErrorDetailState extends DetailState {
  const UnknownErrorDetailState({
    required int year,
    required this.error,
  }) : super(year);


  final dynamic error;


  @overridebool operator ==(Object other) =>
      identical(this, other) ||
      (other is UnknownErrorDetailState &&
          year == other.year &&
          error == other.error);


  @overrideint get hashCode => Object.hash(super.hashCode, error.has

Those states are clearer than an AsyncSnapshot, since it really represents our use cases. And again, this makes our code more maintainable!

💡 We highly recommend the Union types from the freezed package to represent your logic states! It adds a lot of utilities like the copyWith or map methods.

Putting the logic in a Notifier

Now that we have a representation of our state, we need to store an instance of it somewhere—that’s the purpose of the DetailNotifier. It will keep the current DetailState instance in its value property and will provide methods to update the state.

We provide a NotLoadedDetailState initial state, and a refresh method to load data from the api and update the current value.

class DetailNotifier extends ValueNotifier<DetailState> {
  DetailNotifier({
    required int year,
    required this.api,
  }) : super(DetailState.notLoaded(year));


  final DataUsaApiClient api;


  int get year => value.year;


  Future<void> refresh() async {
    if (value is! LoadingDetailState) {
      value = DetailState.loading(year);
      try {
        final result = await api.getMeasure(year);
        if (result != null) {
          value = DetailState.loaded(
            year: year,
            measure: result,
          );
        } else {
          value = DetailState.noData(year);
        }
      } catch (error) {
        value = DetailState.unknownError(
          year: year,
          error: error,
        );
      }
    }
  }

Provide a state for the view

To instantiate and observe the state of our screen, we also rely on the provider and its ChangeNotifierProvider. This kind of provider automatically looks for any ChangeListener created and will trigger a rebuild from the Consumer each time it is notified of a change (when our notifier value is different from the previous one).

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    Key? key,
  }) : super(key: key);


  @override
  Widget build(BuildContext context) {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    return ChangeNotifierProvider<DetailNotifier>(
      create: (context) {
        final notifier = DetailNotifier(
          year: year,
          api: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        return notifier;
      },
      child: Consumer<DetailNotifier>(
        builder: (context, notifier, child) {
             final state = notifier.value;
            // ...
        },
      ),
    );
  }
}

Review

Great! Our application architecture is starting to look pretty good. Everything is separated into well-defined layers, with specific concerns! 🤗

One thing is still missing for testability though, we want to define the current DetailState to control the state of our associated DetailScreen.

Step 4. Visual dedicated layout widget

Visual dedicated layout widgets in flutter applications
via ClickUp

In the last step, we gave a bit too much responsibility to our DetailScreen widget: it was responsible for instantiating the DetailNotifier. And like what we’ve seen previously, we try to avoid any logic responsibility at the view layer!

We can easily solve this by creating another layer for our screen widget: we will split our DetailScreen widget into two:

  • DetailScreen is responsible for setting up the various dependencies of our screen from the current application state (navigation, notifiers, state, services, …),
  • DetailLayout simply converts a DetailState into a dedicated tree of widgets.

By combining the two, we will be able to simply create DetailLayout demo/test instances, but having DetailScreen for the real use case in our application.

Dedicated layout

To achieve a better separation of concerns, we moved everything under the Consumer widget to a dedicated DetailLayout widget. This new widget only consumes data and isn’t responsible for any instantiation. It just converts the read state to a specific widget tree.

The ModalRoute.of call and ChangeNotifierProvider instance remains in the DetailScreen, and this widget simply returns the DetailLayout with a pre-configured dependency tree!

This minor improvement is specific to provider usage, but you will notice that we’ve also added a ProxyProvider so that any descendant widget can directly consume a DetailState. This will make it easier to mock data.

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    Key? key,
  }) : super(key: key);


  @override
  Widget build(BuildContext context) {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    return ChangeNotifierProvider<DetailNotifier>(
      create: (context) {
        final notifier = DetailNotifier(
          year: year,
          api: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        return notifier;
      },
      child: child: ProxyProvider<DetailNotifier, DetailState>(
        update: (context, value, previous) => value.value,
        child: const DetailLayout(),
      ),
    );
  }
}


class DetailLayout extends StatelessWidget {
  const DetailLayout({
    Key? key,
  }) : super(key: key);


  @override
  Widget build(BuildContext context) {
    return Consumer<DetailState>(
      builder: (context, state, child) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Year ${state.year}'),
          ),
          body: () {
              // ...
          }(),
        );
      },
    );
  }

Extracting widgets as dedicated classes

Never hesitate to extract a widget tree into a dedicated class! It will improve performance, and make the code more maintainable.

In our example we created one visual layout widget for each one of the associated state types:

if (state is NotLoadedDetailState || state is LoadingDetailState) {
    return const LoadingDetailLayout();
}
if (state is LoadedDetailState) {
    return LoadedDetailLayout(state: state);
}
if (state is UnknownErrorDetailState) {
    return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();

Demo instances

Now we have full control over what we can mock and display onto our screen!

We just have to wrap a DetailLayout with a Provider<DetailState> to simulate the state of the layout.

ListTile(
    title: const Text('Open "loaded" demo'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
            return Provider<DetailState>.value(
                    value: const DetailState.loaded(
                    year: 2022,
                    measure: Measure(
                        year: 2022,
                        population: 425484,
                        nation: 'United States',
                    ),
                    ),
                    child: const DetailLayout(),
                );
            },
        ),
        );
    },
),
ListTile(
    title: const Text('Open "loading" demo'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
                    return Provider<DetailState>.value(
                        value: const DetailState.loading(2022),
                        child: const DetailLayout(),
                    );
                },
            ),
        );
    },
),

Conclusion

Creating a maintainable software architecture definitively isn’t easy! Anticipating future scenarios can demand a lot of effort, but I hope the few tips I shared will help you in the future!

The examples may look simple—it might even seem like we’re over-engineering—but as your app’s complexity grows, having those standards will help you a lot! 💪

Have fun with Flutter, and follow the blog to get more technical articles like this one! Stay tuned!

Questions? Comments? We're here for you 24/7 at help@clickup.com!

Sign up for FREE
and start using ClickUp in seconds!
Please enter valid email address