Skip to content

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:

dart
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 ​

dart
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 ​

dart
/// 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 ​

dart
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 ​

dart
/// 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 ​

dart
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).

dart
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:

  1. User creates/updates a note
  2. Note is written to local store immediately (UI updates instantly)
  3. A background sync attempt is made
  4. On success: isSynced is set to true
  5. On failure: note stays isSynced: false for 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 ​

Released under the MIT License.