The Missing Flutter State Management Manual
Somewhat better, more detailed instructions than lift state.
Look, I have played around with Apache Cordova, JQuery Mobile, Android native, and now Flutter. And no one really explains state management in terms app designers can understand. And using a page of Flutter Docs to list 20-plus state management solutions does nothing but confuse.
What you will find in this article is that it boils down to breaking up the GOD state controller that is part of the stateful widget and switching from synchronous callbacks to asynchronous callbacks. Everyone forgets that Dart is single threaded and that one should not have the state event change flow on the same thread as the UI as that will speed up the UI recognizing the state event changes.
It's three basic steps in total that reach both the goals of breaking up the GOD object and switch the state event changes flow to a different thread than the UI.
Why Lift State (Google's way of obtusely referring to breaking up the GOD object)
Google at times goofs up in naming. Lifting state is the start of breaking up the GOD state object.
But why do it?
-Business logic and UI stuff in the whole stateful widget increase the noise to signal ratio making comprehending what the code does difficult.
-With Model inserted into the state controller we have two competing concerns accessing that model, the view and the state controller which is not a good idea.
That means our first step is always to move from MVC to MVVM. For example, here is the big-Controller if we just used MVC to break up the GOD controller into a big-C Controller of MVC:
import 'package:flutter/material.dart'; | |
import 'package:mvc/archs/mvc/user_model.dart'; | |
import 'login_repository.dart'; | |
class LoginController { | |
final formKey = GlobalKey<FormState>(); | |
final LoginRepository repository; | |
LoginController(this.repository); | |
UserModel user = UserModel(); | |
userEmail(String value) => user.email = value; | |
userPassword(String value) => user.password = value; | |
Future<bool> login() async { | |
if (!formKey.currentState.validate()) { | |
return false; | |
} | |
formKey.currentState.save(); | |
try { | |
return await repository.doLogin(user); | |
} catch (e) { | |
return false; | |
} | |
} | |
} |
import 'package:flutter/material.dart'; | |
import 'package:mvc/archs/mvc/user_model.dart'; | |
import '../../home_page.dart'; | |
import 'login_controller.dart'; | |
import 'login_repository.dart'; | |
class LoginPageMVC extends StatefulWidget { | |
@override | |
_LoginPageMVCState createState() => _LoginPageMVCState(); | |
} | |
class _LoginPageMVCState extends State<LoginPageMVC> { | |
final _scaffoldKey = GlobalKey<ScaffoldState>(); | |
LoginController controller; | |
bool isLoading = false; | |
@override | |
void initState() { | |
super.initState(); | |
controller = LoginController(LoginRepository()); | |
} | |
@override | |
void dispose() { | |
super.dispose(); | |
} | |
_loginSuccess() { | |
Navigator.pushReplacement( | |
context, | |
MaterialPageRoute(builder: (_) => HomePage()), | |
); | |
} | |
_loginError() { | |
_scaffoldKey.currentState.showSnackBar(SnackBar( | |
content: Text('Login error'), | |
backgroundColor: Colors.red, | |
)); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
key: _scaffoldKey, | |
body: Form( | |
key: controller.formKey, | |
child: Padding( | |
padding: const EdgeInsets.all(8.0), | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
TextFormField( | |
decoration: InputDecoration( | |
border: OutlineInputBorder(), | |
labelText: 'email', | |
), | |
onSaved: controller.userEmail, | |
validator: (value) { | |
if (value.isEmpty) { | |
return 'Campo não pode ser vazio'; | |
} else if (!value.contains('@')) { | |
return 'Email não é válido'; | |
} | |
return null; | |
}, | |
), | |
SizedBox(height: 10), | |
TextFormField( | |
obscureText: true, | |
decoration: InputDecoration( | |
border: OutlineInputBorder(), | |
labelText: 'password', | |
), | |
onSaved: controller.userPassword, | |
validator: (value) { | |
if (value.isEmpty) { | |
return 'Campo não pode ser vazio'; | |
} | |
return null; | |
}, | |
), | |
SizedBox(height: 30), | |
RaisedButton( | |
padding: EdgeInsets.symmetric(horizontal: 80), | |
textColor: Colors.white, | |
color: Colors.blue, | |
child: Text('ENTER'), | |
onPressed: isLoading | |
? null | |
: () async { | |
setState(() { | |
isLoading = true; | |
}); | |
if (await controller.login()) { | |
_loginSuccess(); | |
} else { | |
_loginError(); | |
} | |
setState(() { | |
isLoading = false; | |
}); | |
}), | |
], | |
), | |
), | |
), | |
); | |
} | |
} |
import 'package:mvc/archs/mvc/user_model.dart'; | |
class LoginRepository { | |
Future<bool> doLogin(UserModel model) async { | |
//API Conection | |
await Future.delayed(Duration(seconds: 2)); | |
return model.email == 'jacob@gmail.com' && model.password == '123'; | |
} | |
} |
class UserModel { | |
String email = ''; | |
String password = ''; | |
} |
And this the View-Model of MVVM:
import 'package:flutter/material.dart'; | |
import '../../home_page.dart'; | |
import 'login_repository.dart'; | |
import 'login_viewmodel.dart'; | |
import 'user_model.dart'; | |
class LoginPageMVVM extends StatefulWidget { | |
@override | |
_LoginPageMVVMState createState() => _LoginPageMVVMState(); | |
} | |
class _LoginPageMVVMState extends State<LoginPageMVVM> { | |
final _scaffoldKey = GlobalKey<ScaffoldState>(); | |
final _formKey = GlobalKey<FormState>(); | |
final user = UserModel(); | |
PageViewModel viewModel; | |
@override | |
void initState() { | |
super.initState(); | |
viewModel = PageViewModel(LoginRepository()); | |
viewModel.isLoginOut.listen((isLogin) { | |
if (isLogin) { | |
loginSuccess(); | |
} else { | |
loginError(); | |
} | |
}); | |
} | |
@override | |
void dispose() { | |
super.dispose(); | |
viewModel.dispose(); | |
} | |
loginSuccess() { | |
Navigator.pushReplacement( | |
context, | |
MaterialPageRoute(builder: (_) => HomePage()), | |
); | |
} | |
loginError() { | |
_scaffoldKey.currentState.showSnackBar(SnackBar( | |
content: Text('Login error'), | |
backgroundColor: Colors.red, | |
)); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
key: _scaffoldKey, | |
body: Form( | |
key: _formKey, | |
child: Padding( | |
padding: const EdgeInsets.all(8.0), | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
TextFormField( | |
decoration: InputDecoration( | |
border: OutlineInputBorder(), | |
labelText: 'email', | |
), | |
onSaved: (value) => user.email = value, | |
validator: (value) { | |
if (value.isEmpty) { | |
return 'Campo não pode ser vazio'; | |
} else if (!value.contains('@')) { | |
return 'Email não é válido'; | |
} | |
return null; | |
}, | |
), | |
SizedBox(height: 10), | |
TextFormField( | |
obscureText: true, | |
decoration: InputDecoration( | |
border: OutlineInputBorder(), | |
labelText: 'password', | |
), | |
onSaved: (value) => user.password = value, | |
validator: (value) { | |
if (value.isEmpty) { | |
return 'Campo não pode ser vazio'; | |
} | |
return null; | |
}, | |
), | |
SizedBox(height: 30), | |
StreamBuilder<bool>( | |
stream: viewModel.isLoadingOut, | |
initialData: false, | |
builder: (context, snapshot) { | |
bool isLoading = snapshot.data; | |
return RaisedButton( | |
padding: EdgeInsets.symmetric(horizontal: 80), | |
textColor: Colors.white, | |
color: Colors.blue, | |
child: Text('ENTER'), | |
onPressed: isLoading | |
? null | |
: () { | |
if (!_formKey.currentState.validate()) { | |
return; | |
} | |
_formKey.currentState.save(); | |
viewModel.isLoginIn.add(user); | |
}, | |
); | |
}), | |
], | |
), | |
), | |
), | |
); | |
} | |
} |
import 'user_model.dart'; | |
class LoginRepository { | |
Future<bool> doLogin(UserModel model) async { | |
//API Conection | |
await Future.delayed(Duration(seconds: 2)); | |
return model.email.trim() == 'jacob@gmail.com' && | |
model.password.trim() == '123'; | |
} | |
} |
import 'dart:async'; | |
import 'login_repository.dart'; | |
import 'user_model.dart'; | |
class PageViewModel { | |
final _isLoading$ = StreamController<bool>(); | |
PageViewModel(this.repository); | |
Sink<bool> get isLoadingIn => _isLoading$.sink; | |
Stream<bool> get isLoadingOut => _isLoading$.stream; | |
final _isLogin$ = StreamController<UserModel>(); | |
Sink<UserModel> get isLoginIn => _isLogin$.sink; | |
Stream<bool> get isLoginOut => _isLogin$.stream.asyncMap(login); | |
final LoginRepository repository; | |
Future<bool> login(UserModel user) async { | |
bool isLogin; | |
isLoadingIn.add(true); | |
try { | |
isLogin = await repository.doLogin(user); | |
} catch (e) { | |
isLogin = false; | |
} | |
isLoadingIn.add(false); | |
return isLogin; | |
} | |
dispose() { | |
_isLoading$.close(); | |
_isLogin$.close(); | |
} | |
} |
class UserModel { | |
String email = ''; | |
String password = ''; | |
} |
You will notice that both examples are missing the dependency of a usecase class. Despite what some old dusty books say, there is no absolute need to abstract out business methods to another class besides either the Controller in MVC or the View-Model in MVVM. Thus, do not do it but instead keep to code that is needed for the user-interface.
So our steps thus far are:
1. Switch from MVC to MVVM
Now, we have to somehow get the View Model and it's dependencies into the app.
View-Model And Its Dependencies
This is our second step in that we have to inject the view-model and its dependencies into the app. This an example, where I am using provider to inject the view-model (store):
// Copyright 2024 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// ignore_for_file: unused_field | |
import 'dart:developer'; | |
import 'package:mobx/mobx.dart'; | |
// Include generated file | |
part 'counter_store.g.dart'; | |
// This is the class used by rest of your codebase | |
class Counter = CounterBase with _$Counter; | |
// The store-class | |
abstract class CounterBase with Store { | |
@observable | |
int value = 0; | |
late ReactionDisposer _dispose; | |
void setupReactions() { | |
_dispose = when((_) => value >= 10, () { | |
log("Count has reached the limit of 10"); | |
}); | |
} | |
@action | |
void increment() { | |
value++; | |
} | |
} |
// Copyright 2024 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// ignore_for_file: avoid_print | |
import 'package:flutter/material.dart'; | |
import 'package:mobx/mobx.dart'; | |
import 'package:mobx_logging/src/app_logging.dart'; | |
import 'package:mobx_logging/src/features/counter_store.dart'; | |
import 'package:mobx_logging/src/my_app.dart'; | |
import 'package:provider/provider.dart'; | |
void main() { | |
// initialize logging | |
AppLogging(); | |
mainContext.config = mainContext.config.clone( | |
isSpyEnabled: true, | |
); | |
mainContext.spy((event) { | |
appLogger.info("event name: ${event.name}"); | |
appLogger.info("event type:${event.type}"); | |
}); | |
runApp(MultiProvider( | |
providers: [ | |
Provider<Counter>(create: (_) => Counter()), | |
], | |
child: const MyApp(), | |
)); | |
} |
Note, in a real app with view-model dependencies I would use ProxyProvider with ConsumerN with the N being how many dependencies.
You of course can use what ever dependency injection package you want to use.
Our steps now look like this:
1. Move from MVC to MVVM.
2. Dependency inject the view-model and its dependencies.
We now have to do the third step in that we need to somehow observer the state changes.
Flutter SDK Observers And Why Not To Use Them
In OOP, we use the observer pattern to communicate state changes to other components of the app. The Flutter SDK has two observers called Notifiers.
Here is how ChangeNotifier is used:
import 'package:flutter/material.dart'; | |
/// Flutter code sample for a [ChangeNotifier] with a [ListenableBuilder]. | |
void main() { | |
runApp(const ListenableBuilderExample()); | |
} | |
class CounterModel with ChangeNotifier { | |
int _count = 0; | |
int get count => _count; | |
void increment() { | |
_count += 1; | |
notifyListeners(); | |
} | |
} | |
class ListenableBuilderExample extends StatefulWidget { | |
const ListenableBuilderExample({super.key}); | |
@override | |
State<ListenableBuilderExample> createState() => | |
_ListenableBuilderExampleState(); | |
} | |
class _ListenableBuilderExampleState extends State<ListenableBuilderExample> { | |
final CounterModel _counter = CounterModel(); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
home: Scaffold( | |
appBar: AppBar(title: const Text('ListenableBuilder Example')), | |
body: CounterBody(counterNotifier: _counter), | |
floatingActionButton: FloatingActionButton( | |
onPressed: _counter.increment, | |
child: const Icon(Icons.add), | |
), | |
), | |
); | |
} | |
} | |
class CounterBody extends StatelessWidget { | |
const CounterBody({super.key, required this.counterNotifier}); | |
final CounterModel counterNotifier; | |
@override | |
Widget build(BuildContext context) { | |
return Center( | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
const Text('Current counter value:'), | |
// Thanks to the ListenableBuilder, only the widget displaying the | |
// current count is rebuilt when counterValueNotifier notifies its | |
// listeners. The Text widget above and CounterBody itself aren't | |
// rebuilt. | |
ListenableBuilder( | |
listenable: counterNotifier, | |
builder: (BuildContext context, Widget? child) { | |
return Text('${counterNotifier.count}'); | |
}, | |
), | |
], | |
), | |
); | |
} | |
} |
What you are seeing is a rich model with the business logic in the model itself. Typically used in MVC.
Problems with ChangeNotifier:
-In Dart we do not have deepcopy for complex primitives by default and thus since
ChangeNotifier has no extra deep-copy wiring it can not handle the List, Set, and Map complex primitives found in more complex models which is cousin to the other issue of no way to make complex primitives immutable.
Thus, we cannot use ChangeNotfier.
ValueNotifier is an extension of ChangeNotifier in such a way that we can use immutables with ValueNotifier. But, ValueNotifier has the same problem as ChangeNotifier in that it does not handle List, Set, and Map. Now, for the asynchronous part of the problem.
Now, the way we get an asynchronous callback in Dart is to use Futures.
When we use streams from Dart asynch we get as part of the machinery future powered callbacks. That means if we use streams we can make the ValueNotifier reactive as the state change event communication will be on a different thread than the UI thread.
Now, if we give up the idea of moving from MVC to MVVM we could use Ste Notifier or Rx Notifier. But then we would miss out on getting some automatic wiring along with having the model be bound only the view-model instead of two competing bindings to both view and controller.
A better solution is to use the successor to Redux, namely Recoil, as Recoil has view-model stores instead of global app view-model like Redux. As it happens there is in fact a flutter implementation of Facebook's recoill called MobX.
Using MobX Observers
Let me list the packages needed and then cover Reactive, TFRP, and Recoil:
https://pub.dev/packages/mobx
https://pub.dev/packages/flutter_mobx
https://pub.dev/packages/mobx_codegen
Transparent Functional Reactive Programming
Let's start with the term Transparent Functional Reactive Programming. That simply means that we took the observers and made them reactive which then lifts them up to be nodes of the dynamic graph of the app state.
Recoil Terms
-Actions, how the observers (atoms) are mutated.
-Reactions, when state changes how do we react
-Atoms, the observers which are reactive value observers.
Let me show you an example that shows off the auto wiring.
// Copyright 2024 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// ignore_for_file: unused_field | |
import 'dart:developer'; | |
import 'package:mobx/mobx.dart'; | |
// Include generated file | |
part 'counter_store.g.dart'; | |
// This is the class used by rest of your codebase | |
class Counter = CounterBase with _$Counter; | |
// The store-class | |
abstract class CounterBase with Store { | |
@observable | |
int value = 0; | |
late ReactionDisposer _dispose; | |
void setupReactions() { | |
_dispose = when((_) => value >= 10, () { | |
log("Count has reached the limit of 10"); | |
}); | |
} | |
@action | |
void increment() { | |
value++; | |
} | |
} |
// GENERATED CODE - DO NOT MODIFY BY HAND | |
part of 'counter_store.dart'; | |
// ************************************************************************** | |
// StoreGenerator | |
// ************************************************************************** | |
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers | |
mixin _$Counter on CounterBase, Store { | |
late final _$valueAtom = Atom(name: 'CounterBase.value', context: context); | |
@override | |
int get value { | |
_$valueAtom.reportRead(); | |
return super.value; | |
} | |
@override | |
set value(int value) { | |
_$valueAtom.reportWrite(value, super.value, () { | |
super.value = value; | |
}); | |
} | |
late final _$CounterBaseActionController = | |
ActionController(name: 'CounterBase', context: context); | |
@override | |
void increment() { | |
final _$actionInfo = _$CounterBaseActionController.startAction( | |
name: 'CounterBase.increment'); | |
try { | |
return super.increment(); | |
} finally { | |
_$CounterBaseActionController.endAction(_$actionInfo); | |
} | |
} | |
@override | |
String toString() { | |
return ''' | |
value: ${value} | |
'''; | |
} | |
} |
// Copyright 2024 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_mobx/flutter_mobx.dart'; | |
import 'package:mobx_logging/src/features/counter_store.dart'; | |
import 'package:provider/provider.dart'; | |
class CounterView extends StatelessWidget { | |
const CounterView({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
var myCounter = Provider.of<Counter>(context); | |
return Scaffold( | |
appBar: AppBar( | |
// TRY THIS: Try changing the color here to a specific color (to | |
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar | |
// change color while the other colors stay the same. | |
backgroundColor: Theme.of(context).colorScheme.inversePrimary, | |
// Here we take the value from the MyHomePage object that was created by | |
// the App.build method, and use it to set our appbar title. | |
title: const Text("MyCounter"), | |
), | |
body: Center( | |
// Center is a layout widget. It takes a single child and positions it | |
// in the middle of the parent. | |
child: Column( | |
// Column is also a layout widget. It takes a list of children and | |
// arranges them vertically. By default, it sizes itself to fit its | |
// children horizontally, and tries to be as tall as its parent. | |
// | |
// Column has various properties to control how it sizes itself and | |
// how it positions its children. Here we use mainAxisAlignment to | |
// center the children vertically; the main axis here is the vertical | |
// axis because Columns are vertical (the cross axis would be | |
// horizontal). | |
// | |
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" | |
// action in the IDE, or press "p" in the console), to see the | |
// wireframe for each widget. | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
const Text( | |
'You have pushed the button this many times:', | |
), | |
Observer( | |
builder: (_) => Text( | |
'$myCounter', | |
style: Theme.of(context).textTheme.headlineMedium, | |
)), | |
], | |
), | |
), | |
floatingActionButton: FloatingActionButton( | |
onPressed: myCounter.increment, | |
tooltip: 'Increment', | |
child: const Icon(Icons.add), | |
), // This trailing comma makes auto-formatting nicer for build methods. | |
); | |
} | |
} |
// Copyright 2024 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// ignore_for_file: avoid_print | |
import 'package:flutter/material.dart'; | |
import 'package:mobx/mobx.dart'; | |
import 'package:mobx_logging/src/app_logging.dart'; | |
import 'package:mobx_logging/src/features/counter_store.dart'; | |
import 'package:mobx_logging/src/my_app.dart'; | |
import 'package:provider/provider.dart'; | |
void main() { | |
// initialize logging | |
AppLogging(); | |
mainContext.config = mainContext.config.clone( | |
isSpyEnabled: true, | |
); | |
mainContext.spy((event) { | |
appLogger.info("event name: ${event.name}"); | |
appLogger.info("event type:${event.type}"); | |
}); | |
runApp(MultiProvider( | |
providers: [ | |
Provider<Counter>(create: (_) => Counter()), | |
], | |
child: const MyApp(), | |
)); | |
} |
While the store code generation takes care of actions and observer wiring the magic of keeping track of reactions and triggering them properly comes from the mainContext Singleton that is setup that has ReactiveContext and ReactiveState interactions and tracking. In short words, I do not have to do any listener registration or removal and do not have to even touch using the RxDart package.
The nearest comparison to RxDart is the bloc example:
import 'dart:async'; | |
import 'package:rxdart/rxdart.dart'; | |
import 'github_api.dart'; | |
import 'search_state.dart'; | |
class SearchBloc { | |
final Sink<String> onTextChanged; | |
final Stream<SearchState> state; | |
factory SearchBloc(GithubApi api) { | |
final onTextChanged = PublishSubject<String>(); | |
final state = onTextChanged | |
// If the text has not changed, do not perform a new search | |
.distinct() | |
// Wait for the user to stop typing for 250ms before running a search | |
.debounceTime(const Duration(milliseconds: 250)) | |
// Call the Github api with the given search term and convert it to a | |
// State. If another search term is entered, switchMap will ensure | |
// the previous search is discarded so we don't deliver stale results | |
// to the View. | |
.switchMap<SearchState>((String term) => _search(term, api)) | |
// The initial state to deliver to the screen. | |
.startWith(SearchNoTerm()); | |
return SearchBloc._(onTextChanged, state); | |
} | |
SearchBloc._(this.onTextChanged, this.state); | |
void dispose() { | |
onTextChanged.close(); | |
} | |
static Stream<SearchState> _search(String term, GithubApi api) => term.isEmpty | |
? Stream.value(SearchNoTerm()) | |
: Rx.fromCallable(() => api.search(term)) | |
.map((result) => | |
result.isEmpty ? SearchEmpty() : SearchPopulated(result)) | |
.startWith(SearchLoading()) | |
.onErrorReturn(SearchError()); | |
} |
import 'package:flutter/material.dart'; | |
import 'empty_result_widget.dart'; | |
import 'github_api.dart'; | |
import 'search_bloc.dart'; | |
import 'search_error_widget.dart'; | |
import 'search_intro_widget.dart'; | |
import 'search_loading_widget.dart'; | |
import 'search_result_widget.dart'; | |
import 'search_state.dart'; | |
// The View in a Stream-based architecture takes two arguments: The State Stream | |
// and the onTextChanged callback. In our case, the onTextChanged callback will | |
// emit the latest String to a Stream<String> whenever it is called. | |
// | |
// The State will use the Stream<String> to send new search requests to the | |
// GithubApi. | |
class SearchScreen extends StatefulWidget { | |
final GithubApi api; | |
const SearchScreen({Key? key, required this.api}) : super(key: key); | |
@override | |
SearchScreenState createState() { | |
return SearchScreenState(); | |
} | |
} | |
class SearchScreenState extends State<SearchScreen> { | |
late final SearchBloc bloc; | |
@override | |
void initState() { | |
super.initState(); | |
bloc = SearchBloc(widget.api); | |
} | |
@override | |
void dispose() { | |
bloc.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return StreamBuilder<SearchState>( | |
stream: bloc.state, | |
initialData: SearchNoTerm(), | |
builder: (BuildContext context, AsyncSnapshot<SearchState> snapshot) { | |
final state = snapshot.requireData; | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('RxDart Github Search'), | |
centerTitle: true, | |
), | |
body: Column( | |
children: [ | |
Padding( | |
padding: const EdgeInsets.all(12), | |
child: _buildSearchBar(), | |
), | |
Expanded( | |
child: AnimatedSwitcher( | |
duration: const Duration(milliseconds: 300), | |
child: _buildChild(state), | |
), | |
), | |
], | |
), | |
); | |
}, | |
); | |
} | |
Widget _buildSearchBar() { | |
return Container( | |
padding: const EdgeInsets.all(10), | |
child: Row( | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
const SizedBox( | |
width: 10, | |
), | |
const Icon( | |
Icons.search, | |
color: Colors.white, | |
), | |
const SizedBox( | |
width: 10, | |
), | |
Expanded( | |
child: TextField( | |
textAlignVertical: TextAlignVertical.center, | |
textInputAction: TextInputAction.search, | |
style: const TextStyle( | |
fontSize: 18.0, | |
fontFamily: 'Hind', | |
decoration: TextDecoration.none, | |
), | |
decoration: const InputDecoration.collapsed( | |
border: InputBorder.none, | |
hintText: 'Search Github...', | |
), | |
onChanged: bloc.onTextChanged.add, | |
), | |
), | |
], | |
), | |
decoration: BoxDecoration( | |
borderRadius: BorderRadius.circular(20), | |
color: Theme.of(context).cardColor, | |
), | |
); | |
} | |
Widget _buildChild(SearchState state) { | |
if (state is SearchNoTerm) { | |
return const SearchIntro(); | |
} else if (state is SearchEmpty) { | |
return const EmptyWidget(); | |
} else if (state is SearchLoading) { | |
return const LoadingWidget(); | |
} else if (state is SearchError) { | |
return const SearchErrorWidget(); | |
} else if (state is SearchPopulated) { | |
return SearchResultWidget(items: state.result.items); | |
} | |
throw Exception('${state.runtimeType} is not supported'); | |
} | |
} |
Notice the extra wiring I would need to implement and the fact that one would have to master RxDart to do it. And yes BLoC can be implemented without using the package BLoC as it's just re-naming MVVM basically.
Conclusion
There are 3 basic steps of state management in flutter apps:
1. Move from MVC to MVVM
2. Dependency Inject the View-Model and its dependencies
3. Use reactive observers like the MobX package to not only
provide the asynchronous observers but also auto-wire them.
The benefits are:
1. Reactive MVVM without having to learn the complexity of RxDart and BLoC.
2. Fully Transparent Functional Reactive Programming whereas BLoC and RxDart
combination is FRP but not TFRP.
Note: The reason I call the stores view-models has to do with how flutter stateful widgets operate as when we remove the business logic to lift-up state the big-C controller becomes a little-c controller. It is easier to then say its a view-model than to claim it is a big-C controller. In MobX js docs it will say controller as in JS we do not have a framework forcing a MV app architecture in the UI component formations.