Intermediate · Lesson 11 · 24 min read
Build a Flutter Notes App
Build a small notes app end to end: model the data, manage CRUD state, search, navigate, validate edits and plan persistence.
Updated September 24, 2026
What you will learn
- Model a note with immutable data and a stable id
- Keep CRUD and filtering in one testable store
- Build list, search and edit screens with Flutter's SDK
- Validate input and preserve the difference between create and edit
- Know what changes when in-memory state becomes persisted state
A project is where separate Flutter skills become one application. In this lesson we will build a small notes app with a list screen, search, create and edit flows, deletion, validation and a clean state boundary. The first version stores data in memory so we can focus on the UI and the behaviour; persistence is a deliberate next step rather than a distraction on day one.
What we are building
- A home screen that shows notes ordered by their last edit.
- Search across both note titles and note bodies.
- A single editor used for creating and updating notes.
- Delete with one clear state change and no duplicated list logic.
- A store that can later be backed by SQLite, Drift or another repository.
Start with a data model
A note has an identity, editable content and a timestamp. Keep it as a value object: screens receive notes and create updated copies instead of mutating fields behind the store's back.
class Note {
const Note({
required this.id,
required this.title,
required this.body,
required this.updatedAt,
});
final String id;
final String title;
final String body;
final DateTime updatedAt;
Note copyWith({
String? title,
String? body,
DateTime? updatedAt,
}) {
return Note(
id: id,
title: title ?? this.title,
body: body ?? this.body,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}The id never changes when a note is edited. That is what lets the store find the existing item instead of accidentally adding a second copy. A timestamp belongs on the model too, because sorting by edit order is a product decision, not a widget concern.
Put CRUD in one store
The widgets should not each own a different copy of the notes list. A small ChangeNotifier gives the app one source of truth and makes the state easy to replace with another implementation later.
import 'package:flutter/foundation.dart';
import 'note.dart';
class NotesStore extends ChangeNotifier {
final List<Note> _notes = [];
List<Note> get notes => List.unmodifiable(_notes);
List<Note> search(String query) {
final needle = query.trim().toLowerCase();
if (needle.isEmpty) return notes;
return _notes
.where(
(note) =>
note.title.toLowerCase().contains(needle) ||
note.body.toLowerCase().contains(needle),
)
.toList(growable: false);
}
void add({required String title, required String body}) {
_notes.insert(
0,
Note(
id: DateTime.now().microsecondsSinceEpoch.toString(),
title: title,
body: body,
updatedAt: DateTime.now(),
),
);
notifyListeners();
}
void update(Note updated) {
final index = _notes.indexWhere((note) => note.id == updated.id);
if (index == -1) return;
_notes[index] = updated;
_notes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
notifyListeners();
}
void delete(String id) {
_notes.removeWhere((note) => note.id == id);
notifyListeners();
}
}Create the app shell
Create one store above the screens and dispose it with the app shell. AnimatedBuilder listens to the notifier without adding another package, which keeps this project runnable with only the Flutter SDK.
import 'package:flutter/material.dart';
import 'notes/notes_store.dart';
import 'notes/notes_home.dart';
void main() {
runApp(const NotesApp());
}
class NotesApp extends StatefulWidget {
const NotesApp({super.key});
@override
State<NotesApp> createState() => _NotesAppState();
}
class _NotesAppState extends State<NotesApp> {
final store = NotesStore();
@override
void dispose() {
store.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: NotesHome(store: store),
);
}
}Render the list and search
The home screen owns only view state: the current search query. Notes live in the store. When the store changes, AnimatedBuilder rebuilds the list; when the query changes, only the screen's local state changes.
import 'package:flutter/material.dart';
import 'note.dart';
import 'note_editor.dart';
import 'notes_store.dart';
class NotesHome extends StatefulWidget {
const NotesHome({super.key, required this.store});
final NotesStore store;
@override
State<NotesHome> createState() => _NotesHomeState();
}
class _NotesHomeState extends State<NotesHome> {
String query = '';
Future<void> openEditor([Note? note]) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NoteEditor(store: widget.store, note: note),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: SearchBar(
hintText: 'Search notes',
leading: const Icon(Icons.search),
onChanged: (value) => setState(() => query = value),
),
),
),
),
body: AnimatedBuilder(
animation: widget.store,
builder: (context, child) {
final notes = widget.store.search(query);
if (notes.isEmpty) {
return Center(
child: Text(
query.isEmpty
? 'No notes yet. Create your first one.'
: 'No notes match “$query”.',
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: notes.length,
separatorBuilder: (_, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final note = notes[index];
return Card(
child: ListTile(
title: Text(
note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
note.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => openEditor(note),
trailing: IconButton(
tooltip: 'Delete note',
icon: const Icon(Icons.delete_outline),
onPressed: () => widget.store.delete(note.id),
),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: openEditor,
icon: const Icon(Icons.add),
label: const Text('New note'),
),
);
}
}A search field is local UI state because it does not describe the notes themselves. The list is derived from store.search(query) rather than stored as a second mutable list. Derived state should be calculated at the edge, otherwise it eventually becomes stale.
Use one editor for create and edit
Create and edit look different in the navigation bar, but they are the same form. Passing an optional note lets one screen handle both flows without duplicating validation and layout.
import 'package:flutter/material.dart';
import 'note.dart';
import 'notes_store.dart';
class NoteEditor extends StatefulWidget {
const NoteEditor({
super.key,
required this.store,
this.note,
});
final NotesStore store;
final Note? note;
@override
State<NoteEditor> createState() => _NoteEditorState();
}
class _NoteEditorState extends State<NoteEditor> {
late final TextEditingController titleController;
late final TextEditingController bodyController;
String? titleError;
bool get isEditing => widget.note != null;
@override
void initState() {
super.initState();
titleController = TextEditingController(text: widget.note?.title ?? '');
bodyController = TextEditingController(text: widget.note?.body ?? '');
}
@override
void dispose() {
titleController.dispose();
bodyController.dispose();
super.dispose();
}
void save() {
final title = titleController.text.trim();
final body = bodyController.text.trim();
if (title.isEmpty) {
setState(() => titleError = 'Give the note a title');
return;
}
if (isEditing) {
widget.store.update(
widget.note!.copyWith(
title: title,
body: body,
updatedAt: DateTime.now(),
),
);
} else {
widget.store.add(title: title, body: body);
}
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Edit note' : 'New note'),
actions: [
IconButton(
onPressed: save,
tooltip: 'Save note',
icon: const Icon(Icons.check),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: titleController,
autofocus: !isEditing,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) {
if (titleError != null) setState(() => titleError = null);
},
decoration: InputDecoration(
labelText: 'Title',
errorText: titleError,
),
),
const SizedBox(height: 16),
Expanded(
child: TextField(
controller: bodyController,
expands: true,
maxLines: null,
textAlignVertical: TextAlignVertical.top,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Write your note',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
),
),
],
),
),
);
}
}Test the behaviour before persistence
Because the store has no widget dependencies, its most important behaviour can be tested quickly. These tests protect the rules that will still matter when storage is added.
import 'package:flutter_test/flutter_test.dart';
import 'package:notes/notes/notes_store.dart';
void main() {
test('adds notes and searches title and body', () {
final store = NotesStore();
store.add(title: 'Flutter layout', body: 'Constraints go down.');
store.add(title: 'Shopping', body: 'Buy coffee.');
expect(store.notes, hasLength(2));
expect(store.search('constraint'), hasLength(1));
expect(store.search('SHOPPING'), hasLength(1));
});
test('updates and deletes by stable id', () {
final store = NotesStore();
store.add(title: 'Draft', body: 'First version');
final note = store.notes.single;
store.update(note.copyWith(title: 'Published'));
expect(store.notes.single.title, 'Published');
store.delete(note.id);
expect(store.notes, isEmpty);
});
}Add persistence as a separate boundary
When this in-memory version feels right, add a repository instead of scattering database calls through widgets. The store can load an initial list, call the repository after each mutation, and expose loading or error state if those operations are asynchronous.
abstract interface class NotesRepository {
Future<List<Note>> load();
Future<void> save(List<Note> notes);
}
class NotesStore extends ChangeNotifier {
NotesStore(this.repository);
final NotesRepository repository;
// The add/update/delete methods stay the same.
// Only their persistence step changes.
}For a small local-only app, a simple key-value store may be enough. For queries, migrations, relationships or larger data, use a database-oriented package. The important design choice is that the UI should not know which one you picked.
Official references
Continue with the primary Flutter and Dart documentation for this topic.
Key takeaways
- Build a vertical slice before adding infrastructure: model, state, list, editor and tests.
- Keep one source of truth for notes and derive filtered results from the current query.
- Use stable ids for updates and immutable copies for edits.
- Validate and normalise input at the editor boundary.
- Introduce persistence behind a repository instead of coupling widgets to storage.
Practice
Make the notes app feel finished
Add an undo action after deleting a note, show the last-edited time on each card, and prevent accidental data loss when leaving an editor with unsaved changes. Then replace the in-memory store with a repository that saves and loads notes.
Show hints
- Keep the deleted Note object long enough to offer a SnackBarAction(label: 'Undo', onPressed: ...).
- Compare the controller values with the original note before popping the editor.
- Start persistence with JSON conversion tests before connecting a real database.
Lesson 11
Build a To-Do App from Scratch
Turn widgets, lists, text input and local state into a complete first Flutter app with add, complete, filter and delete flows.
Lesson 7
Declarative Routing with go_router
Move past Navigator.push: typed routes, deep links, auth redirects and a bottom navigation bar where every tab keeps its own history.
Lesson 3
Forms, Validation and Input UX
Build forms with Form and TextFormField, write reusable validators, and get keyboard, focus and submission behaviour right.