πŸ‘Ύ Check out the results of my summer hackathon, Covehack! πŸ‘Ύ

Building an optimistic update hook for Flutter apps

Posted March 26, 2024 β€’ 9 min read (edited: March 28, 2024)
banner image

MotivationπŸ”—

TLDR: Package link.

I watched this video by Web Dev Simplified about a new-and-upcoming React.js hook that is currently only available in React's Canary and experimental channels. It got me thinking about how much I use optimistic updates in Flutter apps I make, and how I always have to "reinvent the wheel" when implementing this type of logic. So, I aimed to port this hook over from React.js to Dart in a reusable component β€” perhaps even publishing it in the process.

What is optimistic updating?πŸ”—

Optimistic updating is a strategy utilized in application development that enhances a user's experience by making interfaces feel fluid and responsive. This technique involves updating the user interface immediately to reflect a desired change before confirming that the change has been successfully applied on the application's backend or server. The initial changes are coined as "optimistic" because they operate under the assumption that most actions, like submitting a vote or changing a setting, will succeed without errors.

Here's a contrived example:

  1. A user likes a post on Instagram.
  2. The like-count on the user's phone instantaneously and optimistically increments by one. This makes them happy, as they get instant feedback for their action β€” this is good UX.
  3. The "like" is then sent to Instagram's server, where it may take some time to process.
    • If the "like" is successfully added to their database, nothing happens.
    • If the database screams "error", then the server sends back a message to the user saying "reverse that initial optimistic like-count update".
  4. This ensures that in most cases, the like-count is updated instantly with no issue, but rarely when adding the "like" to the database fails, the optimistically updated count is reverted properly on the user's device.

Planning my implementationπŸ”—

I figured my implementation for the React.js to Flutter port of the hook would need to get a few things right:

  • Easy to use.
    • Not too much boilerplate code.
  • Ability to later "resolve" initial optimistic state updates to a new value after a sync or async delay.
    • To a new value: 2 -> (async or sync delay) -> later setting as 3.
    • To the same value: 2 -> (async or sync delay) -> confirming it should be 2.
    • Rejecting it all-together: 2 -> (async or sync delay) -> rejecting initial update.
  • Different updates would need to be kept track of.
    • Pseudo-code: update(state, 2 -> 3) could not be confused with update(state, 4 -> 5) such that updating or "resolving" one could inadvertently influence the other.

How I fulfilled these requirements (public-facing API)πŸ”—

Creating the useOptimistic hook in Flutter involved addressing the three main requirements I had identified: ease of use, the ability to resolve updates, and distinguishing between different updates. Below is how I approached each...

Ease of use

To ensure ease of use, I designed the API of the hook to be straightforward and intuitive. Developers can integrate it with minimal boilerplate code, making it accessible for projects of any size. By abstracting the complexity of managing optimistic updates behind a simple interface, developers can focus more on building features rather than the intricacies of state management:

// simple example where: new state = old state + 1
Resolver r = useOptimistic.fn(
  1, // the [newValue] passed to the functions below
  todo: (currentState, newValue) => currentState + newValue, // "forward" state update
  undo: (currentState, oldValue) => currentState - oldValue,  // "backwards" state update
);

Resolving updates

The ability to resolve updates after an asynchronous delay is a critical feature of the useOptimistic hook. This is facilitated through the Resolver class, which offers accept, acceptAs, and reject methods to retroactively alter the state after an initial optimistic update.

Resolver r = useOptimistic.fn( ... );

r.acceptAs(2); // "even though I may have wanted to +1 the state optimistically, now make it +2"
r.accept(); // "accept whatever update I initially optimistically wanted"
r.reject(); // "revert my initial optimistic update"

These methods allow developers to confirm an update, adjust it to a new value, or revert it entirely based on the outcome of asynchronous operations such as API calls. This functionality ensures that the UI can remain responsive and accurate, reflecting the eventual consistency of the application's state.

Distinguishing between different updates

To manage and distinguish between different updates, each optimistic updater is associated with a unique identifier (UUID). This allows the hook to track and manipulate updates independently, preventing cross-contamination between unrelated state changes. The structured management of updates ensures that the application's state remains consistent and predictable, even when multiple optimistic updates are initiated concurrently.

Full programmatic usage exampleπŸ”—

Here is how one actually uses the package.

1. Initialize the hook

final UseOptimistic<int> useOptimistic = UseOptimistic<int>(initialState: 0)

2. Ensure your widget listens to its state changes

@override
void initState() {
  super.initState();
  useOptimistic.addListener(() => setState(() => debugPrint("state changed to: ${useOptimistic.state}")));
}

3. Ensure the hook will be disposed when your widget is

@override
void dispose() {
  super.dispose();
  useOptimistic.dispose();
}

4. Update your state optimistically

TextButton(
  onPressed: () async {

    Resolver r = useOptimistic.fn(
      1, // the [newValue] passed to functions below
      todo: (currentState, newValue) => currentState + newValue,
      undo: (currentState, oldValue) => currentState - oldValue,
    );

    // simulating an API call
    await Future.delayed(const Duration(seconds: 1));

    // three mutually exclusive ways to deal with result
    r.acceptAs(2); // [undo] original function, then [todo] with new value
    r.accept(); // accept the original optimistic update
    r.reject(); // reject the original optimistic update and [undo] original function

  },
  child: const Text("optimistically add 1"),
),

You can call useOptimistic.fn( ... ) multiple times with different todo and undo functions and it'll execute the proper todo/undo associated with fn at the moment you called it. This means you can call multiple separate useOptimistic.fn( ... )s safely together. It does not mean you can have a single fn like this (pseudo-code): useOptimistic.fn( if x todo: () => {...} else todo: () => {...} ) that has conditionally rendered todo/undo functions.

5. Listen to the state

Text("current value: ${useOptimistic.state}"),

Detailed internal technical implementationπŸ”—

The package has a very simple structure:

.
β”œβ”€β”€ src # all code contained here
β”‚ └── hook.dart # hook code
└── use_optimistic.dart # export file (good practice)

Specifically, the export file (use_optimistic.dart) looked like:

library  use_optimistic;

export  'src/hook.dart';

And, the actual nitty-gritty code (hook.dart) looked like this:

import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';

/// key to store the original value of the state
const String _originalKey = "OG";

/// key to store the updated value of the state
const String _updatedKey = "UP";

/// [Resolver] class to handle the [accept], reject and acceptAs methods
class Resolver<T> {
  /// [reject] is used to reject the optimistic update; it will call the [undo] function to
  /// revert the original [todo] call of the optimistic update
  final void Function() reject;

  /// [accept] is used to accept the optimistic update; you don't TECHNICALLY need to [accept] an optimistic update, but
  /// it's good practice to do so and saves memory
  final void Function() accept;

  /// [acceptAs] is used to update the value of the state to a new value; it may be used if you assume optimistically
  /// a value should be +1 for example, but after the server responds, it really should be +2
  ///
  /// it will revert the state to the original value and then update it to the new value
  final void Function(T newValue) acceptAs;

  Resolver(
      {required this.reject, required this.accept, required this.acceptAs});
}

/// [UseOptimistic] class to handle state and the optimistic updates
class UseOptimistic<T> extends ChangeNotifier {
  /// current state of the state
  T _state;

  /// initial state of the state
  final T initialState;

  /// pending updates to the state
  final Map<String, Map<String, T>> _pendingUpdates = {};

  /// getter for the current state
  T get state => _state;

  /// setter for the current state
  ///
  /// calling this updates the state and notifies the listeners
  set state(T newState) {
    _state = newState;
    notifyListeners();
  }

  /// clear the pending updates queue
  ///
  /// this means that all the pending updates will be removed
  /// such that if you [reject], [accept], or [acceptAs] them, they
  /// will not have any effect on the state
  void clearQueue() => _pendingUpdates.clear();

  /// resets the state to the [initialState] and
  /// notifies the listeners
  void reset() {
    _state = initialState;
    notifyListeners();
  }

  UseOptimistic({required this.initialState}) : _state = initialState;

  /// optimistic update function
  ///
  /// this function takes in the new [value], the [todo] function, and the [undo] function
  ///
  /// the [todo] function is called when the state is updated
  ///
  /// the [undo] function is called when the state is rejected
  ///
  /// returns a [Resolver] object that has the [accept], [reject], and [acceptAs] methods
  Resolver<T> fn(T newValue,
      {required T Function(T currentState, T newValue) todo,
      required T Function(T currentState, T oldValue) undo}) {
    final String id = const Uuid().v4();
    _pendingUpdates[id] = {_originalKey: newValue, _updatedKey: newValue};

    _state = todo(_state, newValue);
    notifyListeners();

    return Resolver(
      reject: () {
        if (_pendingUpdates.containsKey(id)) {
          var originalValue = _pendingUpdates[id]?[_originalKey];
          if (originalValue != null) {
            _state = undo(_state, originalValue);
          }
          _pendingUpdates.remove(id);
          notifyListeners();
        }
      },
      accept: () {
        _pendingUpdates.remove(id);
        notifyListeners();
      },
      acceptAs: (T updatedValue) {
        if (_pendingUpdates.containsKey(id)) {
          var originalValue = _pendingUpdates[id]?[_originalKey];
          if (originalValue != null) {
            _state = undo(_state, originalValue);
          }
          _state = todo(_state, updatedValue);
          _pendingUpdates[id] = {
            _originalKey: updatedValue,
            _updatedKey: updatedValue
          };
          notifyListeners();
        }
      },
    );
  }
}

PublishingπŸ”—

Finally, I elected to publish the package to the Flutter package manager as use_optimistic. Additionally, for those more interested, here is the project's GitHub repository. Star it if you like it! ⭐️