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:

MethodDescription
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

StepCallEffect
1getQueryData(key)Returns the current T? data value, or null if not in cache
2setQueryData(key, optimisticData)Cache updated → all active QoraBuilder widgets rebuild
3UserApi.updateUser(...)Network request in flight
4setQueryData(key, serverData)Cache confirmed with authoritative data from the server
5invalidateWhere(...)Related queries (e.g. the user list) will refetch
6restoreQueryData(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:

  1. The UserDetailScreen (which uses QoraBuilder<User>) immediately reflects the new name: no spinner, no delay.
  2. The network request fires in the background.
  3. If it succeeds, the server response confirms the data.
  4. If it fails, the name reverts to what it was before the tap.

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-null T value; always produces a Success state.
  • restoreQueryData<T>(key, snapshot): accepts T?; when snapshot is null, calls removeQuery(key) instead of writing. This makes rollback safe when the query had no prior data: no orphaned Success state 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, restoreQueryData reference
  • Query States: Understanding the state machine that makes rollback predictable
Copyright © 2026