Examples

Todo App

Build a todo app step by step with Shard and persistence.

Overview

This guide walks you through building a todo app that:

  • Saves todos across app restarts using PersistentShard
  • Keeps only the todo list in storage (not loading state or search query)
  • Uses debounce for search and ShardSelector for efficient rebuilds

We'll build it in clear steps: models → persistence → shard → UI.


Step 1: Define the models

Start with the data your app needs.

Todo – a single item with id, title, completed, and createdAt. Add copyWith, toJson, and fromJson so we can update it and persist it.

class Todo {
  final String id;
  final String title;
  final bool completed;
  final DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    this.completed = false,
    DateTime? createdAt,
  }) : createdAt = createdAt ?? DateTime.now();

  Todo copyWith({
    String? id,
    String? title,
    bool? completed,
    DateTime? createdAt,
  }) {
    return Todo(
      id: id ?? this.id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
      createdAt: createdAt ?? this.createdAt,
    );
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'title': title,
        'completed': completed,
        'createdAt': createdAt.toIso8601String(),
      };

  factory Todo.fromJson(Map<String, dynamic> json) => Todo(
        id: json['id'] as String,
        title: json['title'] as String,
        completed: json['completed'] as bool? ?? false,
        createdAt: json['createdAt'] != null
            ? DateTime.parse(json['createdAt'] as String)
            : null,
      );
}

TodoStatus – whether we're loading, loaded, or in error. Only the UI uses this; we don't persist it.

enum TodoStatus { loading, loaded, error }

TodoState – the full state of the screen: status, list of todos, search query, and optional error message. We will persist only the todos list.

class TodoState {
  final TodoStatus status;
  final List<Todo> todos;
  final String searchQuery;
  final String? errorMessage;

  const TodoState({
    this.status = TodoStatus.loading,
    required this.todos,
    this.searchQuery = '',
    this.errorMessage,
  });

  TodoState copyWith({
    TodoStatus? status,
    List<Todo>? todos,
    String? searchQuery,
    String? errorMessage,
  }) =>
      TodoState(
        status: status ?? this.status,
        todos: todos ?? this.todos,
        searchQuery: searchQuery ?? this.searchQuery,
        errorMessage: errorMessage,
      );
}

Step 2: Set up persistence

We need two things: something that reads/writes strings (storage) and something that converts List<Todo> to/from that string (serializer).

Serializer – implements StateSerializer<List<Todo>>. We persist only the list, not the full TodoState.

import 'dart:convert';
import 'package:shard/shard.dart';
import '../models/todo.dart';

class TodoListSerializer implements StateSerializer<List<Todo>> {
  @override
  String serialize(List<Todo> data) =>
      jsonEncode(data.map((t) => t.toJson()).toList());

  @override
  List<Todo> deserialize(String data) {
    final json = jsonDecode(data) as List<dynamic>;
    return json.map((t) => Todo.fromJson(t as Map<String, dynamic>)).toList();
  }
}

Storage – implements StateStorage. Here we use SharedPreferences; you can swap in Hive, SQLite, etc.

import 'package:shared_preferences/shared_preferences.dart';
import 'package:shard/shard.dart';

class SharedPreferencesStorage implements StateStorage {
  final SharedPreferences _prefs;
  SharedPreferencesStorage(this._prefs);

  static Future<SharedPreferencesStorage> create() async {
    final prefs = await SharedPreferences.getInstance();
    return SharedPreferencesStorage(prefs);
  }

  @override
  Future<void> save(String key, String value) async =>
      await _prefs.setString(key, value);

  @override
  Future<String?> load(String key) async => _prefs.getString(key);

  @override
  Future<void> delete(String key) async => await _prefs.remove(key);
}

Step 3: Create the shard

Use PersistentShard<TodoState, List<Todo>> so the full state is TodoState but only List<Todo> is saved.

What to implement:

  1. persistenceKey – storage key (e.g. 'todos').
  2. toPersistence(state) – return state.todos so only the list is persisted.
  3. onLoadComplete(data) – when storage returns (or null), set status to loaded and update todos.

Constructor: pass storageFactory (async init), serializer, and options like autoSave, autoLoad, and debounceDuration.

import 'package:shard/shard.dart';
import '../models/todo.dart';
import '../models/todo_state.dart';
import '../serializers/todo_list_serializer.dart';
import '../storage/shared_preferences_storage.dart';

class PersistentTodoShard extends PersistentShard<TodoState, List<Todo>> {
  @override
  String get persistenceKey => 'todos';

  PersistentTodoShard()
      : super(
          const TodoState(status: TodoStatus.loading, todos: []),
          storageFactory: () => SharedPreferencesStorage.create(),
          serializer: TodoListSerializer(),
          autoSave: true,
          autoLoad: true,
          debounceDuration: const Duration(milliseconds: 500),
        );

  @override
  List<Todo> toPersistence(TodoState state) => state.todos;

  @override
  void onLoadComplete(List<Todo>? data) {
    emit(state.copyWith(status: TodoStatus.loaded, todos: data ?? []));
  }

  @override
  void onLoadError(Object error, StackTrace? stackTrace) {
    super.onLoadError(error, stackTrace);
    emit(state.copyWith(
      status: TodoStatus.error,
      errorMessage: error.toString(),
    ));
  }
}

Actions – update state with emit(state.copyWith(...)). Persistence runs automatically when state changes (autoSave).

// Inside PersistentTodoShard

void addTodo(String title) {
  if (title.trim().isEmpty) return;
  final newTodo = Todo(
    id: DateTime.now().millisecondsSinceEpoch.toString(),
    title: title.trim(),
  );
  emit(state.copyWith(todos: [...state.todos, newTodo]));
}

void removeTodo(String id) {
  emit(state.copyWith(
    todos: state.todos.where((todo) => todo.id != id).toList(),
  ));
}

void toggleTodo(String id) {
  emit(state.copyWith(
    todos: state.todos.map((todo) {
      if (todo.id == id) return todo.copyWith(completed: !todo.completed);
      return todo;
    }).toList(),
  ));
}

void clearCompleted() {
  emit(state.copyWith(
    todos: state.todos.where((todo) => !todo.completed).toList(),
  ));
}

void updateSearchQuery(String query) {
  debounce('search', () => emit(state.copyWith(searchQuery: query)),
      duration: const Duration(milliseconds: 500));
}

Clear all (logout)

clearPersistence() deletes the persisted slice from storage, but it does not reset in-memory state. Pair it with an emit(...) so the screen also clears. Because emit is @protected, this must live as a method on the shard (not in your widget):

// Inside PersistentTodoShard — wipe persisted todos (e.g. on logout) and reset state:
Future<void> clearAll() async {
  await clearPersistence();
  emit(state.copyWith(status: TodoStatus.loaded, todos: []));
}

Call it from a logout button with context.read<PersistentTodoShard>().clearAll().


Step 4: Wire up the UI

Provide the shard at the top, then build the screen with ShardBuilder<PersistentTodoShard, TodoState> so the UI reacts to loading, error, and loaded state. Below is a full, copy-paste friendly example.

Main app – wrap the screen with ShardProvider:

import 'package:flutter/material.dart';
import 'package:shard/shard.dart';
import 'shard/persistent_todo_shard.dart';
import 'screens/todo_list_screen.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shard Todo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: ShardProvider<PersistentTodoShard>(
        create: () => PersistentTodoShard(),
        child: const TodoListScreen(),
      ),
    );
  }
}

Todo list screen – use ShardBuilder<PersistentTodoShard, TodoState> and switch on state.status (loading / error / loaded). Call actions with context.read<PersistentTodoShard>():

import 'package:flutter/material.dart';
import 'package:shard/shard.dart';
import '../models/todo.dart';
import '../models/todo_state.dart';
import '../shard/persistent_todo_shard.dart';

class TodoListScreen extends StatelessWidget {
  const TodoListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shard Todo'),
        actions: [
          ShardSelector<PersistentTodoShard, TodoState, int>(
            selector: (state) => state.todos.length,
            builder: (context, count) => Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
              child: Center(child: Text('$count todos')),
            ),
          ),
        ],
      ),
      body: ShardBuilder<PersistentTodoShard, TodoState>(
        builder: (context, state) {
          if (state.status == TodoStatus.loading) {
            return const Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 16),
                  Text('Loading todos...'),
                ],
              ),
            );
          }

          if (state.status == TodoStatus.error) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.error_outline, size: 64, color: Colors.red.shade300),
                  const SizedBox(height: 16),
                  Text('Error: ${state.errorMessage}', textAlign: TextAlign.center),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => context.read<PersistentTodoShard>().retry(),
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }

          return SafeArea(
            child: Column(
              children: [
                Padding(
                  padding: const EdgeInsets.all(16),
                  child: TextField(
                    decoration: const InputDecoration(
                      hintText: 'Search todos...',
                      prefixIcon: Icon(Icons.search),
                      border: OutlineInputBorder(),
                    ),
                    onChanged: (value) =>
                        context.read<PersistentTodoShard>().updateSearchQuery(value),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  child: Row(
                    children: [
                      Text('Active: ${state.todos.where((t) => !t.completed).length}'),
                      const Spacer(),
                      TextButton(
                        onPressed: () =>
                            context.read<PersistentTodoShard>().clearCompleted(),
                        child: const Text('Clear completed'),
                      ),
                    ],
                  ),
                ),
                Expanded(
                  child: state.todos.isEmpty
                      ? const Center(child: Text('No todos yet. Add one below.'))
                      : ListView.builder(
                          itemCount: state.todos.length,
                          itemBuilder: (context, index) {
                            final todo = state.todos[index];
                            return ListTile(
                              title: Text(
                                todo.title,
                                style: TextStyle(
                                  decoration: todo.completed
                                      ? TextDecoration.lineThrough
                                      : null,
                                ),
                              ),
                              leading: Checkbox(
                                value: todo.completed,
                                onChanged: (_) =>
                                    context.read<PersistentTodoShard>().toggleTodo(todo.id),
                              ),
                              trailing: IconButton(
                                icon: const Icon(Icons.delete_outline),
                                onPressed: () =>
                                    context.read<PersistentTodoShard>().removeTodo(todo.id),
                              ),
                            );
                          },
                        ),
                ),
                Padding(
                  padding: const EdgeInsets.all(16),
                  child: TextField(
                    decoration: const InputDecoration(
                      hintText: 'What needs to be done? (press Enter to add)',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (title) {
                      context.read<PersistentTodoShard>().addTodo(title);
                    },
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

Summary:

  • ShardBuilder<PersistentTodoShard, TodoState> – one builder that receives full state; you branch on state.status (loading / error / loaded) and use state.todos for the list.
  • ShardSelector – used in the app bar only for the count so the bar doesn’t rebuild when e.g. a single todo title changes.
  • context.read<PersistentTodoShard>() – used in callbacks (add, toggle, remove, clear, search, retry) so actions run without registering a build.

Step 5: Add search and filters (optional)

  • Search: In your text field's onChanged, call updateSearchQuery(value). Because it's wrapped in debounce, the shard won't update on every keystroke.
  • Filters: Keep a local enum (e.g. All / Active / Completed) in your StatefulWidget and filter state.todos in the builder before passing to the list. No need to put filter state in the shard unless you want it persisted.

What we used

ConceptWhere
PersistentShard<T, K>Only List<Todo> (K) is persisted; full state is TodoState (T).
toPersistence / onLoadCompleteMap state ↔ persisted data and handle first load.
DebounceSearch input updates state after a short delay.
ShardSelectorRebuild only when the selected value (e.g. count) changes.
context.readCall shard actions from callbacks without rebuilding.

Data flow (high level)

  1. App start – Shard is created with TodoState(loading, []). Storage loads; when done, onLoadComplete(todos) runs and we emit(loaded, todos).
  2. User adds/edits/deletes – Actions call emit(state.copyWith(...)). Shard runs toPersistence(state), serializes, and saves to storage. Listeners (e.g. ShardBuilder) rebuild.

Next Steps

Next: Infinite Scroll to see throttle in action.