During my time building apps in flutter, I have found that online examples of implementations really boil down to two things. The two implementations I see most often rely on a Stateful Widget or a Provider to handle everything: updating the view, performing business logic, calling APIs, etc.
While these examples show a structure that will work for most simple apps, and provide useful insights on how state management and rebuilds work in Flutter, when working with mobile apps that consume 20+ API endpoints, or that need to perform more complex business logic, these common implementations fall short. The Stateful Widgets or Providers take on a bit too much responsibility to have an easily extensible and sustainable codebase.
With that in mind, I strove to create a flutter structure that would be simple to use, quick to pivot to from a MVC+S background, and easy to update as well as develop.
Let us consider the following code:
class MyCounter extends StatefulWidget {
const MyCounter({super.key});
@override
State createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $count'),
TextButton(
onPressed: () {
setState(() {
count++;
});
},
child: Text('Increment'),
),
],
);
}
}
Source : https://docs.flutter.dev/get-started/fundamentals/state-management
As you can see, the Stateful Widget is doing everything. Typically, I like to keep my views as simple as possible, so I need to remove the code dealing with state. Taking inspiration from Grokking Simplicity by Eric Normand, that means categorizing code that deals with state to be one of the following:
-
A calculation, which is our business logic.
-
In the code example above it is ‘count++;’
-
-
An action, which is what mutates the state, and calls the business logic.
-
In the code example above we see this in the ‘setState((){...});’
-
-
Finally the data, which is the representation of state.
-
In the code above 'int count = 0;'
-
When calculations, actions, and data are delegated out of the view? The result is a less bloated view.
Let's take a look at the following working code:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MyCounter extends StatelessWidget {
const MyCounter({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => ExampleController(),
builder: (context, _) {
var controller = Provider.of<ExampleController>(context);
return Column(
children: [
Text('Count: ${controller.count}'),
TextButton(
onPressed: controller.onButtonClick,
child: const Text('Increment'),
),
],
);
},
);
}
}
class ExampleController extends ChangeNotifier {
int count = 0;
void onButtonClick() {
count++;
notifyListeners();
}
}
By removing the stateful widget and replacing it with a stateless one, the‘MyCounter’ class is now more simple, and it merely presents the view. You may have also noticed a Provider named ‘ExampleController’. The controller will define all the ‘actions’ we can take on the view, as well as hold the view state.
As a side note, when starting out on a new feature, I find it extremely expedient to write out all the possible actions I can take on a page. Having the function signatures written out lets me just think about how I write the code, rather than worrying about what code to write.
The 'MyCounter' view and the 'ExampleController' controller together make up what I call the 'View State’, or in other terms, they hold the specific logic that can update and present the view. I also recommend sharing controllers between views very sparely, as too many views utilizing the same controller may lead to bloating of the controller.
This structure is starting to take shape, but we aren't done yet. The 'ExampleController' is not only defining actions, but also holding calculations ('i++'). Remember, for fairly simple apps this is perfectly fine. However, if you want your business logic to be extensible and reusable, I recommend moving calculations into a 'Service'.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MyCounter extends StatelessWidget {
const MyCounter({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => ExampleController(),
builder: (context, _) {
var controller = Provider.of<ExampleController>(context);
return Column(
children: [
Text('Count: ${controller.count}'),
TextButton(
onPressed: controller.onButtonClick,
child: const Text('Increment'),
),
],
);
},
);
}
}
class ExampleController extends ChangeNotifier {
int count = 0;
var service = IncrementService();
void onButtonClick() {
count = service.incrementByOne(count);
notifyListeners();
}
}
class IncrementService {
int incrementByOne(int i) {
return i + 1;
}
}
By moving the calculations into an 'IncrementService' and giving the service to the controller as a property, we accomplish two major things. First, the 'incrementByOne' function is now available to be reused by other controllers. Secondly, we have made our business logic much more testable.
I recommend using a service to wrap third party packages, to store your business logic (calculations), to manipulate cache ('data'), and to call a function from a 'repo’. Here is why:
Third party packages may eventually lose support, or you may need to swap them out in the future for some other reason. It is extremely handy when swapping out packages to have the package wrapped by a service where its implementation is called directly once, instead of using the package in several areas through the codebase. As a side note, you'll also want to wrap third party widgets in a view.
Business Logic, as stated before, can be more easily reused if kept in a service.
A ‘repo’ is a class used to store API calls and return models (‘data’). It is super useful whenever you need to debug API calls.
As opposed to Controllers and Views which make up the 'View State’; Services, Caches, and Repos are considered to be the 'Data State'. As such, if the application calls for it, you could store data between Services in a cache (In the simplest case, a singleton class with a map) without utilizing a Provider. This prevents too much data in the 'View State' and means you don't have to pass data through a view just to get it into a service.
Here are some examples.
class FooService {
var repo = FooRepo();
var cache = MemoryCache(); //DIY Singleton with Map Class
void StartUpFoo() async {
var model = await repo.get();
cache.store("foo-key", model);
}
DateTime getTimeZone() {
var timezone = ThirdPartyPackageThatCouldBeDeprecatedAnyday();
return timezone;
}
}
class FooRepo {
get() async {
//make an asynchronous call to an api, or other external data source
//marshal the data into a model and return the model
}
}
class BarService {
var cache = MemoryCache();
Foo? getFoo(key) {
if (cache.get(key) != null) {
return cache.get(key);
}
//may have a cache miss but you get the idea
}
}
Now we have some asynchronous calls that may throw exceptions. I’ve found that I prefer to handle all exceptions in the controllers themselves. This is because the controllers can control the state of the view and gracefully instruct the end user what to do next. That being said, each service should be called and wrapped in try-catches by controllers.
Here is an example.
import 'package:flutter/material.dart';
class ExampleController extends ChangeNotifier {
int i = 0;
var service = IncrementService();
bool didError = false;
bool isLoading = false;
void onButtonClick() async {
isLoading = true;
notifyListeners();
try {
i = await service.incrementByOneAsynchronously(i);
} catch (e) {
//Log E
didError = true;
}
isLoading = false;
notifyListeners();
}
}
If we do everything above, we should end up with a folder structure like this.
-features
--foo_feature
---controller
----foo_controller.dart
---service
----foo_service.dart
----bar_service.dart
---view
----foo_view.dart
---repo
----foo_srepo.dart
-utils
--cache
---memory_cache.dart
In summary, a mobile app that’s API heavy or has hefty business logic (or both!) benefits from being split into a 'View State' and a 'Data State’.
The 'View State' contains:
-
Views, that merely present state, as well as trigger actions, and
-
Controllers, that define the actions, update/store the view states, and call Services
The 'Data State' contains:
-
Services, that wrap business logic (‘calculations’), external packages, and data into reusable functions.
-
Repos, that store our api/database calls for organization and debugging purposes, and
-
Caches, which give us the ability to have resources accessible to the services by storing the resources for later use.
With this information in hand, I hope you go on to build more robust, and more joyful mobile codebases.