Keeping widgets in sync with your data

Thomas BurkhartThomas Burkhart
9 min read

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:

  1. Dto.isLiked == falseProxy.isLiked == false

  2. like → success → _likeOverride == trueProxy.isLiked == true

  3. unlike → fail → _likeOverride == nullProxy.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 be true 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 input Listenable<bool> that you can pass when creating a command.

  • errors - a Listenable where all exceptions that might be thrown by the wrapped function will be published. This allows you to keep the wrapped function free of any try-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.

0
Subscribe to my newsletter

Read articles from Thomas Burkhart directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thomas Burkhart
Thomas Burkhart