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.
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:
I figured my implementation for the React.js to Flutter port of the hook would need to get a few things right:
update(state, 2 -> 3)
could not be confused with update(state, 4 -> 5)
such that updating or "resolving" one could inadvertently influence the other.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.
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}"),
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();
}
},
);
}
}
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! βοΈ