Keeping widgets in sync with your data
This is a follow-up to my last post on using proxy objects.
This time we will:
Improve error handling
Show how to display a proxy in multiple places at once
Demonstrate how to use Commands to write elegant code
Improving the Error Handling of the isLiked
Property
Interestingly, while implementing the demo code for today’s post, I realized that our previous solution for optimistic updates with undo in case of an error has a serious flaw. Let's take a second look at the code.
bool get isLiked => _likeOverride ?? _target!.isLiked;
bool? _likeOverride;
/// optimistic UI update
Future<void> like(BuildContext context) async {
_likeOverride = true;
notifyListeners();
try {
await di<ApiClient>().likePost(_target!.id);
} catch (e) {
_likeOverride = null;
notifyListeners();
}
}
At first glance, this looks fine. If you rarely encounter errors when calling your API or if you frequently update your data from the backend, you might not notice the problem. Imagine the following sequence:
Dto.isLiked == false
→Proxy.isLiked == false
like → success →
_likeOverride == true
→Proxy.isLiked == true
unlike → fail →
_likeOverride == null
→Proxy.isLiked == false
Just clearing _likeOverride
loses the last successful state change. Luckily for a bool value undoing th
Future<void> like(BuildContext context) async {
_likeOverride = true;
notifyListeners();
try {
await di<ApiClient>().likePost(_target!.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to like post'),
),
);
}
_likeOverride = !_likeOverride;
notifyListeners();
}
}
We will only clear _likeOverride
when we update the proxy's target from our API.
Showing Proxies in more than one place
We want to implement the above behavior. We aim to have two feeds of posts: one showing all our posts and the other showing only posts about dogs. Additionally, any changes to posts in one feed should automatically update in all other feeds displaying that post.
To achieve this, we use the same PostFeedView
twice in a row and pass two different DataSource
objects to each. One DataSource
contains all posts, while the other contains only posts about dogs. Proxies of posts that should appear in both feeds are added to both DataSources
. When reloading the data of a DataSource
, we dispose all its proxies because they are ChangeNotifiers
. This helps avoid memory leaks by disposing any proxy that is no longer used. But what if that proxy is still part of another DataSource
displaying the post? If we simply dispose the proxy, the still active PostCard
widget would no longer update when the data changes.
Reference counting: the unsung hero
To solve this, we add a reference counter to every PostProxy
.
When we load a new
PostDto
for the first time, we create a new proxy and store it in our proxy repository.If we load a DTO that already has a proxy in our repository, we update the target of the proxy.
When we add the proxy to any
DataSource
, we increase the reference counter.When we remove a proxy from a
DataSource
, we decrement the reference counter.If the reference counter reaches 0, we dispose the proxy and remove it from the registry.
If we would add a details view for a post, we would increment the reference count before pushing the details page and decrement it when popping it
Let’s see the needed parts (some lines left out for better clarity):
class PostProxy extends ChangeNotifier {
PostDto? _target;
int referenceCount = 0;
PostProxy(this._target);
//setter for the target
set target(PostDto value) {
_upDateTarget(value);
}
void _upDateTarget(PostDto postDto) {
_likeOverride = null;
_target = postDto;
notifyListeners();
}
void updateFromApi() {
di<ApiClient>().getPost(_target!.id).then((postDto) {
_upDateTarget(postDto);
});
}
class PostManagerImplementation extends PostManager {
/// repository for already proxied posts
final Map<int, PostProxy> _proxyRepository = {};
@override
PostProxy createProxy(PostDto postDto) {
if (_proxyRepository.containsKey(postDto.id)) {
final existingProxy = _proxyRepository[postDto.id]!;
existingProxy.target = postDto;
existingProxy.referenceCount++;
return existingProxy;
}
final newProxy = PostProxy(postDto);
newProxy.referenceCount++;
_proxyRepository[postDto.id] = newProxy;
return newProxy;
}
@override
void releaseProxies(List<PostProxy> proxies) {
for (var proxy in proxies) {
releaseProxy(proxy);
}
}
@override
void releaseProxy(PostProxy proxy) {
proxy.referenceCount--;
if (proxy.referenceCount == 0) {
_proxyRepository.remove(proxy.id);
proxy.dispose();
}
}
}
class FeedDataSource {
FeedDataSource({this.filter});
/// select which posts to show in this feed
final bool Function(PostDto postDto)? filter;
final _posts = <PostProxy>[];
final ValueNotifier<int> _postsCount = ValueNotifier(0);
ValueListenable<int> get postsCount => _postsCount;
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
ValueListenable<bool> get isLoading => _isLoading;
PostProxy getPostAtIndex(int index) {
assert(index >= 0 && index < _posts.length);
return _posts[index];
}
void updateData() async {
_isLoading.value = true;
final postDtos = await di<ApiClient>().getPosts();
/// decrement the reference count of all proxies
/// and release the ones that are not anywhere else
di<PostManager>().releaseProxies(_posts);
_posts.clear();
for (var postDto in postDtos) {
if (filter != null && !filter!(postDto)) {
continue;
}
_posts.add(di<PostManager>().createProxy(postDto));
}
_isLoading.value = false;
_postsCount.value = _posts.length;
}
}
Check out the full source on https://github.com/escamoteur/proxy_pattern_demo/tree/reference_counting_proxy
Adding flutter_commands
and functional_listener
If you've followed along so far, you've already grasped the main message of this post. The following section shows how we can add more functionality without much additional code.
As we can see above, we needed a separate ValueNotifier
_isLoading
to display a loading spinner while the DataSource
loads new data. We would need to do this for every async call that should show a loading state. Often, we also want to control when a certain action can be executed or prevent triggering another refresh while one is still running. Additionally, the way we currently display the snackbar in case of an error is far from ideal and repetitive.
By using my flutter_command
and functional_listener
packages, we can make our lives much easier. You will see that it fits perfectly into the pattern of watch_it
that you have already seen.
Commands
are objects that wrap a function and offer many observable properties. We will explore only a few here; please refer to the readme for details. The most notable ones are:
isExecuting
- will betrue
while the wrapped function runs.canExecute
- shows if a command can be executed at the moment and can ideally be used to activate or deactivate buttons. Its state is!isExecuting && restriction
- restriction is an inputListenable<bool>
that you can pass when creating a command.errors
- aListenable
where all exceptions that might be thrown by the wrapped function will be published. This allows you to keep the wrapped function free of anytry-catch
blocks and lets you observe errors from outside the command.
functional_listener
- provides filter and merging functions for ValueListenable
, allowing you to create logic networks to express behavior in a clear way. You'll understand this better when we look at the different parts of the final app version. You can find the full source at https://github.com/escamoteur/proxy_pattern_demo/tree/using_flutter_commands
class PostProxy extends ChangeNotifier {
--- lot left out ---
/// create a combined ValueListenable based on the updateDataCommands
/// and local updateFromApiCommand
late ValueListenable<bool> commandRestrictions = di<PostManager>()
.updateFromApiIsExecuting
.combineLatest(updateFromApiCommand.isExecuting, (a, b) => a || b);
late final updateFromApiCommand = Command.createAsyncNoParamNoResult(
() async {
final postDto = await di<ApiClient>().getPost(_target!.id);
_updateTarget(postDto);
},
// block the command if any updateDataCommand is executing
restriction: di<PostManager>().updateFromApiIsExecuting,
);
late final likeCommand = Command.createAsyncNoParamNoResult(
() async {
/// optimistic UI update
_likeOverride = true;
notifyListeners();
await di<ApiClient>().likePost(_target!.id);
},
// block the command if we update from the api
restriction: commandRestrictions,
// we want that the error is handled locally and globally in TheAppImplementation
errorFilter: const ErrorHandlerLocalAndGlobal(),
)..errors.listen(
(e, _) {
// reverse the optimistic UI update
_likeOverride = !_likeOverride!;
notifyListeners();
},
);
--- more left out ---
By passing commandRestrictions
to the like and unlike commands as restriction
, we automatically disable these commands whenever an update call to the API occurs.
To show this in the UI, we define our own CommandIconButton
.
class CommandIconButton extends WatchingWidget {
const CommandIconButton({
required this.command,
required this.icon,
});
final Command command;
final IconData icon;
@override
Widget build(BuildContext context) {
final canExecute = watch(command.canExecute).value;
return IconButton(
onPressed: canExecute ? command.execute : null,
icon: Icon(icon),
);
}
}
The full PostCard
now looks like:
class PostCard extends WatchingWidget {
const PostCard({
super.key,
required this.post,
});
final PostProxy post;
@override
Widget build(BuildContext context) {
/// watch the post to rebuild the widget when the post changes
watch(post);
final bool isLoading = watch(post.updateFromApiCommand.isExecuting).value;
return Card.outlined(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: !isLoading
? Image.network(post.imageUrl)
: const Center(child: CircularProgressIndicator()),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(post.title),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (post.isLiked)
CommandIconButton(
icon: Icons.favorite,
command: post.unlikeCommand,
)
else
CommandIconButton(
icon: Icons.favorite_border,
command: post.likeCommand,
),
const SizedBox(width: 8),
CommandIconButton(
command: post.updateFromApiCommand,
icon: Icons.refresh,
),
],
),
],
),
);
}
}
In a real project I would probably convert the like/unlike Command to a toggleLike Command
As you can see, we no longer have snackbar code inside the PostCard
. Commands enable configurable error routing, as seen in the PostProxy
. Locally, we have handlers to reset the _optimisticLike
, while all exceptions are forwarded to the global error handler of the Command class:
class TheAppImplementation extends TheApp {
TheAppImplementation() {
Command.globalExceptionHandler = (e, s) {
_errorMessages.add(e.error.toString());
};
}
final StreamController<String> _errorMessages =
StreamController<String>.broadcast();
@override
Stream<String> get errorMessagesStream => _errorMessages.stream;
}
To display the snackbar, we add a StreamHandler
to our HomePage
. This is the right way to show errors from the data layer without storing a navigator key somewhere:
class HomePage extends WatchingWidget {
@override
Widget build(BuildContext context) {
/// handler to display error messages
registerStreamHandler(
select: (TheApp app) => app.errorMessagesStream,
handler: (context, snapShot, _) {
if (snapShot.hasData) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(snapShot.data!),
));
}
});
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: PostFeedView(dataSource: di<PostManager>().postsFeed)),
Expanded(
child: PostFeedView(dataSource: di<PostManager>().dogPostsFeed)),
],
);
}
}
Bonus - using an UndoableCommand
To undo optimistic data changes for a boolean property, you can easily define an error handler and negate the latest value. However, if you do optimistic updates for other types, this method won't work because you won't know the original value before the change in case of an error. For this, you can use another type of Command called the UndoableCommand. The function you wrap inside the command receives an undo stack of the type you want to undo when the Command is executed. The function can then push any state it might need to undo onto this stack. If an exception occurs during command execution, the undo
function of the command receives this same undo stack, giving it access to the original data.
late final toggleLikeCommand = Command.createUndoableNoParamNoResult<bool>(
(undoStack) async {
/// save current state
undoStack.push(isLiked);
_likeOverride = !isLiked;
notifyListeners();
if (_likeOverride!) {
await di<ApiClient>().likePost(_target!.id);
} else {
await di<ApiClient>().unlikePost(_target!.id);
}
},
undo: (undoStack, reason) {
_likeOverride = undoStack.pop();
notifyListeners();
},
// block the command if we update from the api
restriction: commandRestrictions,
// we want that the error is handled locally and globally in TheAppImplementation
errorFilter: const ErrorHandlerGlobalIfNoLocal(),
);
I also changed the command to a toggleLike
version which makes its use even easier.
You can find this version here: https://github.com/escamoteur/proxy_pattern_demo/tree/using_flutter_commands
I hope this gives you an idea of what you can achieve by adding Commands
to your app. Have fun exploring the demo app's code and comparing the different implementations of the four branches.
Subscribe to my newsletter
Read articles from Thomas Burkhart directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by