Recipes
Optimistic Updates
Update the UI instantly while mutations are in flight, and roll back safely on failure: with no extra libraries.
An optimistic update applies a local state change before server confirmation. If the request fails, the previous data is restored.
Qora provides three primitives that make this pattern straightforward:
| Method | Description |
|---|---|
client.getQueryData<T>(key) | Snapshot the current data (T?), or null if not in Success state |
client.setQueryData<T>(key, data) | Push data directly into the cache: all subscribed widgets update instantly |
client.restoreQueryData<T>(key, snapshot) | Restore a previously snapshotted data value; null removes the entry |
The Pattern
update_user.dart
Future<void> updateUserName(BuildContext context, int userId, String newName) async {
final client = context.qora;
final key = ['users', userId];
// 1. Snapshot the current data for rollback
final previousUser = client.getQueryData<User>(key);
try {
// 2. Apply the optimistic update: UI reflects the change instantly
if (previousUser != null) {
client.setQueryData<User>(key, previousUser.copyWith(name: newName));
}
// 3. Fire the mutation
final updatedUser = await UserApi.updateUser(userId, newName);
// 4. Confirm with the authoritative server response
client.setQueryData<User>(key, updatedUser);
// 5. Invalidate related queries (e.g. the list that shows the name)
client.invalidateWhere((key) => key.firstOrNull == 'users');
} catch (error) {
// 6. Rollback to the pre-mutation data
client.restoreQueryData<User>(key, previousUser);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update: $error')),
);
}
}
}
Step-by-step
| Step | Call | Effect |
|---|---|---|
| 1 | getQueryData(key) | Returns the current T? data value, or null if not in cache |
| 2 | setQueryData(key, optimisticData) | Cache updated → all active QoraBuilder widgets rebuild |
| 3 | UserApi.updateUser(...) | Network request in flight |
| 4 | setQueryData(key, serverData) | Cache confirmed with authoritative data from the server |
| 5 | invalidateWhere(...) | Related queries (e.g. the user list) will refetch |
| 6 | restoreQueryData(key, previousUser) | Cache reverted → UI snaps back to the pre-mutation state |
Full Example: Edit Profile Screen
screens/edit_profile_screen.dart
class EditProfileScreen extends StatefulWidget {
final int userId;
const EditProfileScreen({super.key, required this.userId});
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
late final TextEditingController _nameController;
bool _isSaving = false;
@override
void initState() {
super.initState();
final currentUser = context.qora.getQueryData<User>(['users', widget.userId]);
_nameController = TextEditingController(text: currentUser?.name ?? '');
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _save() async {
if (_isSaving) return;
setState(() => _isSaving = true);
final client = context.qora;
final key = ['users', widget.userId];
final previousUser = client.getQueryData<User>(key);
try {
// Optimistic update
if (previousUser != null) {
client.setQueryData<User>(
key,
previousUser.copyWith(name: _nameController.text),
);
}
final updated = await UserApi.updateUser(
widget.userId,
_nameController.text,
);
client.setQueryData<User>(key, updated);
client.invalidateWhere((k) => k.firstOrNull == 'users');
if (mounted) Navigator.pop(context);
} catch (error) {
client.restoreQueryData<User>(key, previousUser);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Save failed: $error')),
);
setState(() => _isSaving = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit Profile'),
actions: [
TextButton(
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
),
);
}
}
When the user taps Save:
- The
UserDetailScreen(which usesQoraBuilder<User>) immediately reflects the new name: no spinner, no delay. - The network request fires in the background.
- If it succeeds, the server response confirms the data.
- If it fails, the name reverts to what it was before the tap.
Invalidating Related Queries
After a successful mutation you typically want to invalidate related queries so stale list views refresh. Use invalidateWhere with a predicate:
update_user.dart
// Invalidate the specific user AND the users list
client.invalidateWhere((key) => key.firstOrNull == 'users');
// More targeted: only the list, not the individual entry
client.invalidateWhere((key) => key.length == 1 && key.firstOrNull == 'users');
// Or invalidate the exact key
client.invalidate(['users']);
Active QoraBuilder widgets for any matching key receive Loading(previousData: data) and immediately re-fetch. Because the previous data is carried through, there is no flash of empty content.
Why restoreQueryData Instead of setQueryData?
Both methods write data into the cache. The difference is null handling:
setQueryData<T>(key, data): requires a non-nullTvalue; always produces aSuccessstate.restoreQueryData<T>(key, snapshot): acceptsT?; whensnapshotisnull, callsremoveQuery(key)instead of writing. This makes rollback safe when the query had no prior data: no orphanedSuccessstate is left behind.
rollback.dart
// Correct: handles the null case (query had no prior data)
client.restoreQueryData<User>(key, previousUser); // null → removeQuery
// Note: Equivalent only when previousUser is guaranteed non-null
if (previousUser != null) {
client.setQueryData<User>(key, previousUser);
}
Best Practices
Do
update.dart
// Always snapshot before mutating
final previousUser = client.getQueryData<User>(key);
// Apply optimistic update only when data exists
if (previousUser != null) {
client.setQueryData(key, optimisticData);
}
// Always restore on any error path
} catch (_) {
client.restoreQueryData(key, previousUser);
}
Don't
update.dart
// Don't skip the snapshot: you won't be able to roll back
client.setQueryData(key, optimisticData);
await api.update(data); // if this throws, no rollback possible
// Don't ignore the error: always inform the user
} catch (_) {} // Bad: Silent failure is worse than no optimistic update
Next Steps
- Quick Start: List and detail screens without mutations
- QoraClient API:
setQueryData,getQueryData,restoreQueryDatareference - Query States: Understanding the state machine that makes rollback predictable