This guide walks you through building a todo app that:
We'll build it in clear steps: models → persistence → shard → UI.
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,
);
}
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);
}
Use PersistentShard<TodoState, List<Todo>> so the full state is TodoState but only List<Todo> is saved.
What to implement:
'todos').state.todos so only the list is persisted.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));
}
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().
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:
state; you branch on state.status (loading / error / loaded) and use state.todos for the list.onChanged, call updateSearchQuery(value). Because it's wrapped in debounce, the shard won't update on every keystroke.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.| Concept | Where |
|---|---|
| PersistentShard<T, K> | Only List<Todo> (K) is persisted; full state is TodoState (T). |
| toPersistence / onLoadComplete | Map state ↔ persisted data and handle first load. |
| Debounce | Search input updates state after a short delay. |
| ShardSelector | Rebuild only when the selected value (e.g. count) changes. |
| context.read | Call shard actions from callbacks without rebuilding. |
TodoState(loading, []). Storage loads; when done, onLoadComplete(todos) runs and we emit(loaded, todos).emit(state.copyWith(...)). Shard runs toPersistence(state), serializes, and saves to storage. Listeners (e.g. ShardBuilder) rebuild.Next: Infinite Scroll to see throttle in action.