Offline-First
Build an offline-first application using persistence, optimistic updates, and background synchronization. This recipe demonstrates reading from a local cache, writing optimistically, and syncing with a remote server when connectivity is available.
Storage Setup
First, implement a StorageAdapter backed by persistent storage (e.g., SharedPreferences, Hive, or SQLite). For this example, we use a simple adapter:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_reacton/flutter_reacton.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// StorageAdapter backed by SharedPreferences.
class SharedPrefsStorage implements StorageAdapter {
final SharedPreferences _prefs;
SharedPrefsStorage(this._prefs);
@override
String? read(String key) => _prefs.getString(key);
@override
Future<void> write(String key, String value) =>
_prefs.setString(key, value);
@override
Future<void> delete(String key) => _prefs.remove(key);
@override
bool containsKey(String key) => _prefs.containsKey(key);
@override
Future<void> clear() => _prefs.clear();
}Data Model with Serializer
class Note {
final String id;
final String title;
final String content;
final DateTime updatedAt;
final bool isSynced;
const Note({
required this.id,
required this.title,
required this.content,
required this.updatedAt,
this.isSynced = true,
});
Note copyWith({
String? title,
String? content,
DateTime? updatedAt,
bool? isSynced,
}) {
return Note(
id: id,
title: title ?? this.title,
content: content ?? this.content,
updatedAt: updatedAt ?? this.updatedAt,
isSynced: isSynced ?? this.isSynced,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'content': content,
'updatedAt': updatedAt.toIso8601String(),
'isSynced': isSynced,
};
factory Note.fromJson(Map<String, dynamic> json) => Note(
id: json['id'] as String,
title: json['title'] as String,
content: json['content'] as String,
updatedAt: DateTime.parse(json['updatedAt'] as String),
isSynced: json['isSynced'] as bool? ?? true,
);
}
/// Serializer for List<Note> for persistence.
class NotesSerializer implements Serializer<List<Note>> {
@override
String serialize(List<Note> value) {
return jsonEncode(value.map((n) => n.toJson()).toList());
}
@override
List<Note> deserialize(String data) {
final list = jsonDecode(data) as List;
return list.map((e) => Note.fromJson(e as Map<String, dynamic>)).toList();
}
}Reacton Definitions
/// All notes, persisted to local storage.
final notesReacton = reacton<List<Note>>(
[],
name: 'notes',
options: ReactonOptions(
persistKey: 'notes_v1',
serializer: NotesSerializer(),
),
);
/// Notes that haven't been synced to the server.
final unsyncedNotesReacton = computed(
(read) => read(notesReacton).where((n) => !n.isSynced).toList(),
name: 'unsyncedNotes',
);
/// Count of unsynced notes (for UI badge).
final unsyncedCountReacton = computed(
(read) => read(unsyncedNotesReacton).length,
name: 'unsyncedCount',
);
/// Whether a sync operation is in progress.
final isSyncingReacton = reacton(false, name: 'isSyncing');
/// Last sync timestamp.
final lastSyncReacton = reacton<DateTime?>(null, name: 'lastSync');
/// Connectivity status.
final isOnlineReacton = reacton(true, name: 'isOnline');Simulated API
class NotesApi {
/// Upload a note to the server. Returns the server-confirmed note.
static Future<Note> upsertNote(Note note) async {
await Future.delayed(const Duration(milliseconds: 500));
// Simulate occasional failures
if (DateTime.now().millisecond % 10 == 0) {
throw Exception('Network error');
}
return note.copyWith(isSynced: true);
}
/// Fetch all notes from the server.
static Future<List<Note>> fetchNotes() async {
await Future.delayed(const Duration(seconds: 1));
// In a real app, this would return the server's notes
return [];
}
}Offline-First Operations
/// Add a new note with optimistic local-first write.
void addNote(ReactonStore store, String title, String content) {
final note = Note(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
content: content,
updatedAt: DateTime.now(),
isSynced: false, // Mark as unsynced
);
// Write to local store immediately (optimistic)
store.update(notesReacton, (notes) => [note, ...notes]);
// Attempt to sync in the background
_syncNote(store, note);
}
/// Update an existing note optimistically.
void updateNote(ReactonStore store, String noteId, {String? title, String? content}) {
store.update(notesReacton, (notes) => notes.map((n) {
if (n.id == noteId) {
return n.copyWith(
title: title,
content: content,
updatedAt: DateTime.now(),
isSynced: false, // Mark as unsynced
);
}
return n;
}).toList());
// Find the updated note and sync it
final updated = store.get(notesReacton).firstWhere((n) => n.id == noteId);
_syncNote(store, updated);
}
/// Delete a note optimistically.
void deleteNote(ReactonStore store, String noteId) {
store.update(notesReacton, (notes) =>
notes.where((n) => n.id != noteId).toList(),
);
// In a real app, also send a delete request to the server
}
/// Attempt to sync a single note to the server.
Future<void> _syncNote(ReactonStore store, Note note) async {
final isOnline = store.get(isOnlineReacton);
if (!isOnline) return; // Will sync later when online
try {
final synced = await NotesApi.upsertNote(note);
// Update the note in the list to mark it as synced
store.update(notesReacton, (notes) => notes.map((n) {
if (n.id == synced.id) return synced;
return n;
}).toList());
} catch (e) {
// Note stays marked as unsynced -- will retry on next sync
debugPrint('Failed to sync note ${note.id}: $e');
}
}
/// Sync all unsynced notes to the server.
Future<void> syncAll(ReactonStore store) async {
final isOnline = store.get(isOnlineReacton);
if (!isOnline) return;
store.set(isSyncingReacton, true);
final unsynced = store.get(unsyncedNotesReacton);
for (final note in unsynced) {
await _syncNote(store, note);
}
store.batch(() {
store.set(isSyncingReacton, false);
store.set(lastSyncReacton, DateTime.now());
});
}
/// Full refresh: fetch from server and merge with local changes.
Future<void> fullRefresh(ReactonStore store) async {
store.set(isSyncingReacton, true);
try {
// First, push local changes
await syncAll(store);
// Then, pull server state
final serverNotes = await NotesApi.fetchNotes();
// Merge: keep local unsynced notes, add server notes that are missing locally
final localNotes = store.get(notesReacton);
final localIds = localNotes.map((n) => n.id).toSet();
final merged = [
...localNotes, // Keep all local notes (including unsynced)
...serverNotes.where((n) => !localIds.contains(n.id)), // Add new server notes
];
store.set(notesReacton, merged);
} catch (e) {
debugPrint('Full refresh failed: $e');
} finally {
store.batch(() {
store.set(isSyncingReacton, false);
store.set(lastSyncReacton, DateTime.now());
});
}
}UI Implementation
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final storage = SharedPrefsStorage(prefs);
final store = ReactonStore(storageAdapter: storage);
runApp(ReactonScope(store: store, child: const NotesApp()));
// Attempt to sync on startup
syncAll(store);
}
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Offline Notes',
theme: ThemeData(colorSchemeSeed: Colors.amber, useMaterial3: true),
home: const NotesPage(),
);
}
}
class NotesPage extends StatelessWidget {
const NotesPage({super.key});
@override
Widget build(BuildContext context) {
final notes = context.watch(notesReacton);
final unsyncedCount = context.watch(unsyncedCountReacton);
final isSyncing = context.watch(isSyncingReacton);
final isOnline = context.watch(isOnlineReacton);
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
actions: [
if (unsyncedCount > 0)
Badge(
label: Text('$unsyncedCount'),
child: IconButton(
icon: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () => syncAll(context.reactonStore),
),
)
else
IconButton(
icon: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () => fullRefresh(context.reactonStore),
),
Icon(
isOnline ? Icons.wifi : Icons.wifi_off,
color: isOnline ? Colors.green : Colors.red,
),
const SizedBox(width: 8),
],
),
body: notes.isEmpty
? const Center(child: Text('No notes yet. Tap + to create one.'))
: ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) {
final note = notes[index];
return ListTile(
title: Text(note.title),
subtitle: Text(
note.content,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!note.isSynced)
const Icon(Icons.cloud_off, size: 16, color: Colors.orange),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
deleteNote(context.reactonStore, note.id),
),
],
),
onTap: () => _editNote(context, note),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _createNote(context),
child: const Icon(Icons.add),
),
);
}
void _createNote(BuildContext context) {
final titleController = TextEditingController();
final contentController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('New Note'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
autofocus: true,
),
TextField(
controller: contentController,
decoration: const InputDecoration(labelText: 'Content'),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (titleController.text.isNotEmpty) {
addNote(
context.reactonStore,
titleController.text,
contentController.text,
);
Navigator.pop(ctx);
}
},
child: const Text('Create'),
),
],
),
);
}
void _editNote(BuildContext context, Note note) {
final titleController = TextEditingController(text: note.title);
final contentController = TextEditingController(text: note.content);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Edit Note'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: contentController,
decoration: const InputDecoration(labelText: 'Content'),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
updateNote(
context.reactonStore,
note.id,
title: titleController.text,
content: contentController.text,
);
Navigator.pop(ctx);
},
child: const Text('Save'),
),
],
),
);
}
}Key Concepts
Persistence with ReactonOptions
The notesReacton is configured with persistKey and a custom Serializer. The store automatically reads the persisted value on initialization and writes it back on changes (when a PersistenceMiddleware is configured, or you can handle it manually).
final notesReacton = reacton<List<Note>>(
[],
options: ReactonOptions(
persistKey: 'notes_v1',
serializer: NotesSerializer(),
),
);Optimistic Updates
Writes are applied to the local store immediately, without waiting for the server response. The isSynced: false flag marks notes that need to be synced:
- User creates/updates a note
- Note is written to local store immediately (UI updates instantly)
- A background sync attempt is made
- On success:
isSyncedis set totrue - On failure: note stays
isSynced: falsefor later retry
Sync Queue
The unsyncedNotesReacton computed reacton automatically tracks which notes need syncing. The syncAll function iterates through all unsynced notes and attempts to push them to the server.
Merge Strategy
On full refresh, local and server notes are merged:
- Local notes are always kept (including unsynced changes)
- Server notes that don't exist locally are added
- In a production app, you would add conflict resolution logic (e.g., last-write-wins based on
updatedAt)
Visual Sync Status
A cloud icon next to unsynced notes provides visual feedback. A badge on the sync button shows the count of pending changes.
What's Next
- Counter App -- Start with the basics
- Pagination -- Infinite scroll with QueryReacton
- Form Validation -- Complex forms with validation