State Management in Flutter — No Third Party Dependencies

Gustavo Navarro
Prolog App
Published in
5 min readJun 4, 2024

Since we started migrating our native Android app to Flutter, we’ve had to make a lot of hard decisions about our project, in order to avoid making the same mistakes we did when creating the native app.

That means we had to deal with all the state management solutions that are available in the Flutter environment and decide which one we would use. That was actually hard because we have so many of them: Provider, Riverpod, Bloc, Cubit, MobX, GetX, Redux, etc. It took a lot of effort to understand each one, and after we read and tried different solutions, we were more certain that there was too much magic happening inside them.

After some research, we found an article that literally showed us that there were a lot of big apps in the stores that do not use any third-party libraries for state management. Researching a little bit more, we found another article, that shows a very nice implementation of state management in Flutter. We were inspired by these contents and started research to find a third-party-free solution. In the lines below, I’m going to show you how we implemented state management in our app just using Flutter’s native resources.

The App Pattern

We opted for the MVVM (Model-View-ViewModel) pattern as the architecture for our Flutter app. Having utilized this pattern comfortably in our native Android app, we were confident it would suffice for our Flutter project.

The MVVM pattern primarily consists of three classes:

  • Model: This class holds the data we intend to display on the screen.
  • View: In Flutter, this is essentially our widget that represents the screen.
  • ViewModel: Here, we house the logic. Every interaction with the screen (View) should trigger a method call in the ViewModel.
Visual MVVM representation

Merging Flutter Native Resources with MVVM

First and foremost, we need to create some classes that will facilitate the construction of our example. Below are the classes you must create to reproduce our example:

  1. user_dto.dart: This file will contain the class representing our DTO (Data Transfer Object).
class UserDto {
final String name;

UserDto({required this.name});
}

2. user_repository.dart: This file will house the class responsible for making the HTTP fetch request.

import 'package:teste_artigo/user_dto.dart';

class UserRepository {
Future<UserDto> getUserData() async {
// fetching some user data simulation, it could be an API call, in a repository file.
await Future.delayed(const Duration(seconds: 2), () {});
return UserDto(name: 'John Doe');
}
}

Then we can finally start creating our MVVM structure.

We utilized ValueNotifier and ValueListenableBuilder to establish a connection between our Model, View, and ViewModel.

First, let’s create our home_screen_state.dart file. Every screen has a state: loading state, error state, successful state, etc. We will differentiate these states using class types:

abstract class HomeScreenState {}

class HomeScreenLoadingState extends HomeScreenState {}

class HomeScreenSuccessfulState extends HomeScreenState {
final String userName;

HomeScreenSuccessfulState(this.userName);
}

class HomeScreenErrorState extends HomeScreenState {
final String errorMessage;

HomeScreenErrorState(this.errorMessage);
}

Keep in mind that we could have as many states as we want, and each state can have as many variables as it needs. We can even have common variables, which should be placed inside the abstract HomeScreenState class.

Next, we should create our ViewModel class:

import 'package:flutter/cupertino.dart';
import 'package:teste_artigo/home_screen_state.dart';
import 'package:teste_artigo/user_repository.dart';

class HomeScreenViewModel {
var state = ValueNotifier<HomeScreenState>(HomeScreenLoadingState());
final userRepository = UserRepository();

Future<void> onInit() async {
_tryToFetchUserData();
}

Future<void> onRefreshUserData() async {
_tryToFetchUserData();
}

Future<void> _tryToFetchUserData() async {
try {
state.value = HomeScreenLoadingState();
final response = await userRepository.getUserData();
state.value = HomeScreenSuccessfulState(response.name);
} catch (e) {
state.value = HomeScreenErrorState('An error occurred');
}
}
}

In this class, you can see that our public method names represent callbacks for the UI class. We do not explicitly state in the method name that a user fetch is going to be done. Instead, we simply indicate that when the UI initializes or when a user data refresh is requested, the specific method should be called. The logic resides inside the _tryToFetchUserData method, which is private and not visible to the UI.

Additionally, you’ll notice that when we need to update the data, we update the value of the state variable with the appropriate state type and parameters. This variable is a stream: it is already responsible for notifying listeners when it changes.

Lastly, we have our UI:

import 'package:flutter/material.dart';
import 'package:teste_artigo/home_screen_view_model.dart';

import 'home_screen_state.dart';

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

@override
State<HomeScreen> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<HomeScreen> {
late final HomeScreenViewModel _vm;

@override
void initState() {
super.initState();
_vm = HomeScreenViewModel();
_vm.onInit();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('State Management'),
),
floatingActionButton: _buildFloatingActionButton(),
body: _buildBody(),
);
}

Widget _buildFloatingActionButton() {
return FloatingActionButton(
onPressed: () => _vm.onRefreshUserData(),
tooltip: 'Refresh',
child: const Icon(Icons.refresh),
);
}

Widget _buildBody() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder(
valueListenable: _vm.state,
builder: (context, state, _) {
switch (state.runtimeType) {
case const (HomeScreenLoadingState):
return const Center(child: CircularProgressIndicator());
case const (HomeScreenSuccessfulState):
final successfulState = state as HomeScreenSuccessfulState;
return Center(child: Text(successfulState.userName));
case const (HomeScreenErrorState):
final errorState = state as HomeScreenErrorState;
return Center(child: Text(errorState.errorMessage));
default:
return Container();
}
},
)
],
);
}
}

All the “magic” happens inside ValueListenableBuilder: based on the type of the state, we render a specific widget to be shown.

The builder function is called every time the state variable from the ViewModel is changed, so there's no need to worry about calling setState or anything else. You could also use ValueListenableBuilder in deeper parts of the code to avoid re-rendering the entire screen every time something changes. However, the point is that you don't need it: Flutter is already optimized to render the entire screen and knows how to deal with it because it was created to update the screen using setState, which renders everything again.

Conclusion

That’s it! It’s as simple as that, with no third-party dependencies.

I understand that this is a very simple example, and as we begin to create more complex screens, more questions may arise, and it may become more challenging to stick to such a straightforward solution. However, we are successfully using this method to create very complex screens (in fact, we are migrating our entire app, the main product of the company, to Flutter), and it’s working very well for us.

If you have any doubts or would like to make any suggestions, feel free to message me directly on my LinkedIn. I’ll be there, kindly available for a conversation.

You can find the code for this example in this repository.

--

--