Dart Foundations: Build a Notes App
Build a Notes app in Dart/Flutter, experiencing why immutable models matter.
Introduction
30 Second Summary
As a Java engineer, your instinct is to mutate objects in place. That works fine in imperative UI frameworks. But reactive frameworks like Flutter rebuild their entire UI tree from state, and mutable models create subtle bugs that only surface when you need to compare or snapshot state.
In this project, you will build a Notes app in Flutter, deliberately using mutable models first, hitting a real reference-aliasing bug when you add undo, then refactoring to idiomatic immutable Dart patterns that fix it.
What You'll Build
You'll run a Notes app in Chrome where you add, delete, and favorite notes, then tap Undo and watch it correctly roll back through every previous state.
By the end of this project, you'll have:
- A scrollable list of notes that updates reactively as you add, delete, and toggle favorites.
- A working undo system that correctly restores previous state, after you diagnose and fix the reference-aliasing bug that breaks it.
- A Note model class using idiomatic Dart patterns: const constructor, final fields, copyWith, and functional list operations (map, where, spread) instead of imperative mutation.
- Secret Mission: Search and filtering via Dart extension methods on List<Note>, demonstrating composable type-safe behavior without utility classes.
Are there any prerequisites?
This project assumes you have Java programming experience and understand OOP concepts. You also need VS Code, Flutter 3.35.6, and Dart 3.9.2 installed on macOS.
Before We Start
Before writing any code, let's commit to what you're building and why the approach matters. You'll experience a real mutable-state bug firsthand, then fix it with immutable Dart patterns.
Create the Flutter Project and Explore the Scaffold
You need a running app before you can learn anything about Dart or Flutter. The fastest way to get there is to scaffold a project and launch it in the browser.
Flutter's default counter app template is itself a teaching tool. It demonstrates StatefulWidget, setState, and the widget tree in action. You will read through it before replacing it in the next step.
In this step, get ready to:
- Scaffold a new Flutter project and open it in VS Code.
- Explore the generated file structure and identify key Flutter patterns in the default counter app.
- Run the app in Chrome and test hot reload.
Scaffold the Flutter project
Flutter's CLI generates a complete project structure including platform folders, a dependency manifest, and a starter app. Project names must follow lowercase_with_underscores naming.
- Press Cmd+Space to open Spotlight Search.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Flutter project by running this command:
flutter create notes_app
You will see a list of generated files scroll past, ending with a message that your application code is in notes_app/lib/main.dart.
Seeing 'flutter: command not found'?
Make sure the Flutter SDK is on your PATH. Run flutter doctor to verify your installation. If it still fails, open a new terminal window so your shell reloads its PATH.
help me fix Flutter not being found on my PATH on macOS
- Move into the project folder and open it in VS Code by running these commands:
cd notes_app
code .
VS Code opens with the notes_app project loaded in the Explorer sidebar.
Explore the generated project structure
Before writing your own code, orient yourself in what flutter create produced. Each folder and file has a purpose you will use later.
- In the VS Code Explorer sidebar, look at the top-level folders and files.
- Identify lib/main.dart (your app code lives here).
- Identify pubspec.yaml (dependencies and project metadata, similar to a pom.xml or build.gradle).
- Identify test/ (widget tests) and the platform folders: ios/, android/, web/, macos/, linux/, windows/.
- Click on lib/main.dart to open it in the editor.
Read through main.dart and find these four things:
- The runApp(const MyApp()) call inside main() at the top. This is the entry point that mounts your widget tree.
- The MyApp class, which extends StatelessWidget. It configures the app theme and routes to the home page.
- The MyHomePage class, which extends StatefulWidget. It holds mutable state (the counter value).
- The setState(() { _counter++; }) call inside _incrementCounter. This tells Flutter to rebuild the widget tree with the new value.
What is the StatefulWidget pattern?
A StatefulWidget splits into two classes: the widget itself (immutable config) and its State object (mutable data). In Java terms, think of the widget as a final class holding constructor args, and the State as the mutable inner class that holds instance variables.
When you call setState(), Flutter marks the widget as dirty and rebuilds its build() method. The entire UI is declared fresh every frame, not imperatively updated like Swing or Android Views.
Run the app in Chrome and test hot reload
Flutter compiles to JavaScript for web targets, giving you a fast iteration cycle with no emulator needed. Hot reload injects updated code without losing widget state.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key).
- Launch the app in Chrome by running this command:
flutter run -d chrome
A Chrome window opens with the default counter app. You should see a purple app bar titled "Flutter Demo Home Page" and a large 0 in the center.
- Click the floating + button a few times. The counter increments with each tap.
Chrome didn't open or the page is blank?
Make sure Chrome is installed and set as an available device. Run flutter devices in a new terminal tab to confirm Chrome appears in the list. If the page loads blank, try a hard refresh with Cmd+Shift+R.
help me get flutter run -d chrome working on macOS
Now test hot reload. Hot reload pushes code changes to the running app instantly without restarting or losing state.
- Switch back to VS Code and open lib/main.dart.
- Find this line inside the MyHomePage constructor:
home: const MyHomePage(title: 'Flutter Demo Home Page'),
- Change 'Flutter Demo Home Page' to 'My Counter App', so the line looks like:
home: const MyHomePage(title: 'My Counter App'),
Before you press anything, predict: will the counter reset to zero after hot reload, or keep its current value?
- Save the file with Cmd+S.
- Switch to the terminal where flutter run is active and press r to trigger hot reload.
The app bar now reads "My Counter App" and your counter value is still intact. That is stateful hot reload in action.
Why does hot reload preserve state?
Hot reload re-executes build() methods with the new code but keeps the existing State objects alive. Only a hot restart (pressing R instead of r) destroys State and rebuilds from scratch.
- Undo the title change by pressing Cmd+Z in main.dart until the title reads 'Flutter Demo Home Page' again, then save with Cmd+S.
Why revert the change?
The next step replaces this entire file with your Notes app code. Reverting keeps the scaffold in its canonical state so the replacement is clean.
Your environment is ready and you have seen Flutter's core loop: edit code, hot reload, see the result instantly. Next up, you will build your own Note model class and display it in a list.
Build the Note Model and Display a Static List
Before you can build CRUD operations, you need two things: a data model that represents a note, and a screen that displays notes in a list.
In this step, you'll write a Note class using Dart's named parameters (the equivalent of Java constructor overloading), then replace the default counter app with a scrollable list of hardcoded notes.
In this step, get ready to:
- Create a mutable Note model with Dart named parameters.
- Replace the counter app with a Notes app shell.
- Display a static list of notes using ListView.builder.
Create the Note model class
Every note in your app needs a shape: an id, a title, content, a favorite flag, and a creation timestamp. In Java you would write a POJO with getters, setters, and constructor overloads. In Dart, named parameters with the required keyword replace all of that in a single constructor.
- Create a new file at lib/models/note.dart by right-clicking the lib folder in VS Code's Explorer sidebar, selecting New File, and typing models/note.dart.
- VS Code creates the models folder automatically.
- Paste the following code into note.dart:
// A mutable Note class (the "Java POJO" approach)
class Note {
String id;
String title;
String content;
bool isFavorite;
DateTime createdAt;
// Named parameters replace Java's constructor overloading
Note({
required this.id,
required this.title,
required this.content,
this.isFavorite = false,
required this.createdAt,
});
}
What does this code do?
This is Dart's equivalent of a Java POJO with a builder-style constructor.
- Public fields (no private keyword needed) replace Java's getter/setter boilerplate.
- required on named parameters forces callers to provide those values at construction time.
- this.isFavorite = false gives a default value, so callers can omit it. Java achieves the same thing by overloading constructors.
- Save lib/models/note.dart.
- Confirm the file exists by checking the Explorer sidebar. You should see lib > models > note.dart in the file tree.
Don't see the models folder?
- Make sure you right-clicked the lib folder specifically, not the project root. The file path must be lib/models/note.dart, not models/note.dart at the top level.
- If you accidentally created the file in the wrong location, delete it and try again from the lib folder.
Why isn't my note.dart file showing up in the right place?
Replace the counter app with your Notes shell
The counter app served its purpose. Now you need an app shell that points to a Notes list screen instead.
- Open lib/main.dart in the editor.
- Select all the existing code (Cmd+A on macOS) and replace it with the following:
import 'package:flutter/material.dart';
import 'screens/note_list_screen.dart';
void main() {
runApp(const NotesApp());
}
// A StatelessWidget that configures the MaterialApp
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const NoteListScreen(),
);
}
}
What does this code do?
- NotesApp is a StatelessWidget because it holds no mutable state. It configures the MaterialApp (theme, title, home screen) and never changes.
- The const constructor tells Dart this widget is compile-time constant. Flutter can skip rebuilding it entirely.
- home: const NoteListScreen() points to the screen you are about to create.
- Save lib/main.dart.
- Your editor will show a red underline on the import 'screens/note_list_screen.dart' line because that file does not exist yet. That is expected.
Build the note list screen
The screen needs a StatefulWidget because the list of notes will change later when you add CRUD operations. For now, you will hardcode three sample notes and render them in a scrollable list.
- Create a new file at lib/screens/note_list_screen.dart by right-clicking the lib folder, selecting New File, and typing screens/note_list_screen.dart.
- Paste the following code at the top of the file to define the widget and your sample data:
import 'package:flutter/material.dart';
import '../models/note.dart';
// StatefulWidget because the note list will change over time
class NoteListScreen extends StatefulWidget {
const NoteListScreen({super.key});
@override
State<NoteListScreen> createState() => _NoteListScreenState();
}
class _NoteListScreenState extends State<NoteListScreen> {
// Hardcoded sample notes (mutable list for now)
List<Note> _notes = [
Note(id: '1', title: 'First Note', content: 'Hello from note 1', createdAt: DateTime.now()),
Note(id: '2', title: 'Second Note', content: 'Hello from note 2', createdAt: DateTime.now()),
Note(id: '3', title: 'Third Note', content: 'Hello from note 3', isFavorite: true, createdAt: DateTime.now()),
];
What does this code do?
- NoteListScreen splits into two classes: the widget (immutable config) and its State (mutable data). This is the same pattern you saw in the counter app with MyHomePage and _MyHomePageState.
- _notes holds three sample Note objects. The underscore prefix makes the field private to this file (Dart's equivalent of Java's private keyword).
- Notice the third note passes isFavorite: true while the others rely on the default false. Named parameters let you skip anything with a default.
- Directly below the _notes list (still inside the _NoteListScreenState class), add the build method that renders the UI:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Notes'),
),
// ListView.builder creates items lazily (like RecyclerView)
body: ListView.builder(
itemCount: _notes.length,
itemBuilder: (context, index) {
final note = _notes[index];
return ListTile(
title: Text(note.title),
subtitle: Text(note.content),
trailing: Icon(
note.isFavorite ? Icons.star : Icons.star_border,
color: note.isFavorite ? Colors.amber : null,
),
);
},
),
);
}
}
What does this code do?
- Scaffold provides Material Design page structure (app bar, body, floating action button slot).
- ListView.builder is Dart's equivalent of Android's RecyclerView. It creates list items lazily, only building widgets that are visible on screen.
- Each ListTile shows the note's title, content as a subtitle, and a star icon that is filled amber when isFavorite is true.
- The ternary note.isFavorite ? Icons.star : Icons.star_border is declarative UI: you describe what to show for each state, and Flutter figures out the rendering.
- Save lib/screens/note_list_screen.dart.
Before you reload the app, what do you expect to see? Three notes in a list, or something else?
- Switch to the terminal where flutter run is still running and press r to hot reload.
You should see three notes listed vertically. The first two have an outlined star icon. The third (Third Note) has a filled amber star because its isFavorite is true.
Seeing a red error screen instead of notes?
- Check that both files are saved. An unsaved note_list_screen.dart means the import in main.dart cannot resolve.
- Verify your file paths match exactly: lib/screens/note_list_screen.dart and lib/models/note.dart. A typo in the folder name causes an import failure.
- If hot reload shows Hot reload was rejected, press R (capital R) for a full hot restart instead. Structural changes like adding new files sometimes require a restart.
Why is my Notes app showing a red error screen after hot reload?
✔️ Awesome, I've got everything!
Great. Double check all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is each file you should have at this point.
lib/models/note.dart:
class Note {
String id;
String title;
String content;
bool isFavorite;
DateTime createdAt;
Note({
required this.id,
required this.title,
required this.content,
this.isFavorite = false,
required this.createdAt,
});
}
lib/main.dart:
import 'package:flutter/material.dart';
import 'screens/note_list_screen.dart';
void main() {
runApp(const NotesApp());
}
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const NoteListScreen(),
);
}
}
lib/screens/note_list_screen.dart (scroll up to confirm your two chunks match):
- Lines 1-18: imports, NoteListScreen StatefulWidget, _NoteListScreenState class opening with the _notes list.
- Lines 19-40: the build method returning a Scaffold with AppBar and ListView.builder, closing the class with }.
Your data model is in place and your notes are rendering. Next up, you will wire the list to real CRUD operations so you can add, delete, and favorite notes on the fly.
Add CRUD Operations with Mutable State
Your Notes app currently displays a static list of hardcoded notes. To build something useful, you need the ability to create, delete, and favorite notes on the fly.
In this step, you will add all three operations using the mutable approach a Java engineer would instinctively reach for. Find the object, mutate its fields, tell the framework to re-render. This is deliberately the "POJO plus setter" pattern. It works perfectly here.
In this step, get ready to:
- Add methods for creating, deleting, and favoriting notes.
- Wire each method to a UI interaction (tap, long-press, icon press).
- Verify all three CRUD operations work correctly in Chrome.
Add note creation and deletion methods
Flutter's StatefulWidget rebuilds its entire widget tree whenever you call setState. You mutate your data, call setState, and the framework handles re-rendering.
- In lib/screens/note_list_screen.dart, find the _NoteListScreenState class.
- Replace the entire _notes field (including the 3 hardcoded notes) and add these two methods directly below it:
List<Note> _notes = [];
void _addNote() {
final now = DateTime.now();
final newNote = Note(
id: now.millisecondsSinceEpoch.toString(),
title: 'Note ${_notes.length + 1}',
content: 'Created at ${now.hour}:${now.minute}',
createdAt: now,
);
setState(() {
_notes.add(newNote);
});
}
void _deleteNote(String id) {
setState(() {
_notes.removeWhere((note) => note.id == id);
});
}
What does this code do?
- _notes starts empty so you can test adding fresh notes from scratch.
- _addNote creates a new Note with a timestamp-based ID, pushes it onto the list with .add(), then calls setState to trigger a rebuild.
- _deleteNote removes the note whose ID matches using removeWhere. Same pattern: mutate the list, call setState.
Now you need to update the build method to wire these methods to the UI and handle the empty-list state.
- Replace the entire existing build method with this version:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Notes'),
),
body: _notes.isEmpty
? const Center(child: Text('No notes yet. Tap + to add one.'))
: ListView.builder(
itemCount: _notes.length,
itemBuilder: (context, index) {
final note = _notes[index];
return ListTile(
title: Text(note.title),
subtitle: Text(note.content),
trailing: Icon(
note.isFavorite ? Icons.star : Icons.star_border,
color: note.isFavorite ? Colors.amber : null,
),
onLongPress: () => _deleteNote(note.id),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _addNote,
child: const Icon(Icons.add),
),
);
}
What's happening here?
- The ternary _notes.isEmpty ? ... : ... shows a placeholder message when there are no notes. This is declarative UI: you describe what the UI looks like for each state, and Flutter picks the right one.
- onLongPress on the ListTile fires _deleteNote with the note's ID when the learner long-presses the row.
- The FloatingActionButton calls _addNote on press. Flutter renders this as the circular + button in the bottom-right corner.
- Save note_list_screen.dart.
- Press r in the terminal where flutter run is active to hot reload.
- You should see the empty state message "No notes yet. Tap + to add one." in the center of the screen.
- Tap the + button in the bottom-right corner. A new note should appear in the list.
- Long-press that note. It should disappear from the list and the empty state message should return.
Notes not appearing or disappearing?
- Check that _notes.add(newNote) is inside the setState(() { }) callback. Code outside that callback mutates state without triggering a rebuild.
- Make sure you replaced the old _notes initialization entirely. If the hardcoded notes are still there, you may have two list declarations.
- If hot reload shows a red error screen, try a full restart by pressing R (capital R) in the terminal.
Help me debug why my notes aren't appearing when I tap the + button.
Add the toggle favorite method
This is the operation that will matter most in the next step. The Java instinct is to find the object and mutate its field directly. In Flutter, this works as long as you call setState afterwards, because the framework rebuilds everything regardless of what changed.
- Add the _toggleFavorite method below _deleteNote by pasting this code:
void _toggleFavorite(String id) {
setState(() {
final note = _notes.firstWhere((n) => n.id == id);
note.isFavorite = !note.isFavorite;
});
}
Why does direct mutation work here?
The line note.isFavorite = !note.isFavorite directly mutates the object's field. In Java, this is standard practice. In Flutter, it works because setState triggers a full widget-tree rebuild regardless of what you changed.
The problem surfaces when you need to compare current state with previous state (for undo, change detection, or equality checks). That is the next step.
Now wire _toggleFavorite to the star icon by replacing the plain Icon widget with an IconButton.
- In the build method, find the trailing property inside the ListTile:
trailing: Icon(
note.isFavorite ? Icons.star : Icons.star_border,
color: note.isFavorite ? Colors.amber : null,
),
- Replace it with this IconButton that wraps the same icon and calls _toggleFavorite on press:
trailing: IconButton(
icon: Icon(
note.isFavorite ? Icons.star : Icons.star_border,
color: note.isFavorite ? Colors.amber : null,
),
onPressed: () => _toggleFavorite(note.id),
),
- Save note_list_screen.dart and press r in the terminal to hot reload.
- Tap the + button to add a few notes.
- Tap the star icon on any note. The star should fill in with amber color. Tap it again and it should return to an outline.
Star icon not responding to taps?
- Make sure you replaced trailing: Icon(...) with trailing: IconButton(...). A plain Icon widget does not handle taps.
- Verify that onPressed: () => _toggleFavorite(note.id) uses the arrow syntax. Missing the () => means the method runs immediately during build instead of on tap.
Help me fix my IconButton so the star icon toggles on tap.
✔️ Awesome, I've got everything!
Great. Make sure you have saved note_list_screen.dart.
ⓧ I'd like to double check the full code
Scroll up and confirm you have each of these sections in order inside _NoteListScreenState:
- List<Note> _notes = []; (empty list, no hardcoded notes).
- The _addNote() method that creates a Note and calls _notes.add inside setState.
- The _deleteNote(String id) method that calls _notes.removeWhere inside setState.
- The _toggleFavorite(String id) method that finds the note and directly mutates note.isFavorite.
- The build method with empty-state ternary, IconButton trailing, onLongPress, and FloatingActionButton.
Confirm all operations work together
Before you move on, predict what will happen if you add three notes, favorite the second one, then delete the first one. Will the second note still be favorited after the delete?
- Test the full sequence: tap + three times to add three notes.
- Tap the star on the second note to favorite it.
- Long-press the first note to delete it.
- Confirm the second note (now first in the list) still shows an amber star.
Everything works. The mutable approach is not broken here because setState rebuilds the entire widget tree unconditionally. It does not compare old state vs new state.
When does mutation break?
The bug surfaces when you need to snapshot state for comparison. An undo system, a change-detection algorithm, or a state-management library that checks equality will all break if every "snapshot" is just another reference to the same mutable objects.
In Java, imperative UI redraws hide this problem because you manually update each widget. In Flutter's declarative model, you hit it the moment you try to compare past and present.
Your Notes app now has fully working CRUD operations. Every mutation followed by setState rebuilds the UI and everything looks correct. Next up, you will add an undo feature and discover exactly where this mutable approach falls apart.
Add Undo and Hit the Reference-Aliasing Bug
Your CRUD operations work perfectly. But what happens when a user accidentally deletes a note or toggles the wrong favorite? You need undo.
The natural instinct from Java is to save a "snapshot" of your list before each mutation, then restore the last snapshot on undo. In this step, you'll implement exactly that approach and discover a critical bug that lurks inside it.
In this step, get ready to:
- Add a history list that stores snapshots before each operation.
- Wire an undo button that restores the previous snapshot.
- Test undo and diagnose why it fails.
Add the history field, snapshot logic, and undo method
In Java, you would save a reference to a list before mutating it. That way you can roll back later. The same instinct applies here: store _notes into a history list before each mutation.
- In note_list_screen.dart, find the line List<Note> _notes = [];.
- Add the history field on the line directly below it:
final List<List<Note>> _history = [];
- Next, add the _undo method after _toggleFavorite by copying this code:
void _undo() {
if (_history.isNotEmpty) {
setState(() {
// Restore the last saved snapshot
_notes = _history.removeLast();
});
}
}
What does this code do?
The _history field is a list of lists. Each entry is meant to be a snapshot of _notes at a point in time.
The _undo method pops the most recent snapshot and assigns it back to _notes inside setState, triggering a UI rebuild.
Now you need each mutation method to save a snapshot before it changes anything.
- In _addNote, find the setState(() { line. The current body looks like this:
setState(() {
_notes.add(newNote);
});
- Add _history.add(_notes); as the first line inside setState, so it now looks like this:
setState(() {
_history.add(_notes);
_notes.add(newNote);
});
- Do the same in _deleteNote. Find its setState body:
setState(() {
_notes.removeWhere((note) => note.id == id);
});
- Add _history.add(_notes); above the removeWhere call:
setState(() {
_history.add(_notes);
_notes.removeWhere((note) => note.id == id);
});
- Finally, do the same in _toggleFavorite. Find its setState body:
setState(() {
final note = _notes.firstWhere((n) => n.id == id);
note.isFavorite = !note.isFavorite;
});
- Add _history.add(_notes); as the first line inside setState:
setState(() {
_history.add(_notes);
final note = _notes.firstWhere((n) => n.id == id);
note.isFavorite = !note.isFavorite;
});
Why the same line in all three methods?
Every method that changes the list needs to save the "before" state first. The pattern is identical each time: save the reference, then mutate. This is the Java equivalent of List<Note> snapshot = notes; before calling a setter.
Wire the undo button in the AppBar
- In the build method, find the AppBar widget. It currently looks like this:
appBar: AppBar(
title: const Text('My Notes'),
),
- Replace it with this version that adds an undo button in the actions list:
appBar: AppBar(
title: const Text('My Notes'),
actions: [
IconButton(
// Disabled (null) when history is empty
onPressed: _history.isNotEmpty ? _undo : null,
icon: const Icon(Icons.undo),
),
],
),
What does this code do?
The onPressed: _history.isNotEmpty ? _undo : null pattern is Flutter's way of disabling a button. Passing null to onPressed grays out the icon.
When _history has entries, the button becomes tappable and calls _undo.
- Save note_list_screen.dart and hot reload by pressing r in your terminal.
- Look at the AppBar in Chrome. You should see a grayed-out undo arrow icon to the right of "My Notes".
Don't see the undo icon?
- Make sure the actions property is inside the AppBar() constructor, not outside it.
- Check that you have a comma after the closing ] of actions and before the ) that closes AppBar.
- If hot reload shows errors, check the terminal for the specific line number. Help me fix my AppBar actions in Flutter
Test undo and observe the bug
The undo infrastructure is in place. Time to test it.
- In Chrome, tap the + button three times to add three notes.
- Tap the star icon on the first note to favorite it.
Before you tap undo, predict: what do you expect to happen? The last action was toggling a favorite, so undo should restore the note to un-favorited. Right?
- Tap the undo button (the arrow icon that should now be active).
Nothing changes. The star stays filled. Tap undo again. Still nothing meaningful happens. The list might flicker or jump, but it never actually rolls back to a previous state.
- Try a different sequence: add a note, then immediately tap undo. The new note stays in the list.
Undo is completely broken. Every "snapshot" in your history shows the current state of the list, not the state at the time you saved it. This is the designed obstacle for this step.
Why does this happen?
When you write _history.add(_notes), you are not saving a copy of the list. You are saving a reference to the same List object that _notes points to.
When you later call _notes.add(newNote) or _notes.removeWhere(...), you are mutating that same object. Every entry in _history is a reference to the same list, so they all reflect whatever the current state is.
In Java, List<Note> snapshot = notes has the exact same bug. Java's imperative UI redraws mask it because you never compare previous vs current state. In Flutter's reactive model, the bug is immediately visible because undo depends on having a genuinely independent previous state.
Diagnose the bug with print statements
To prove the reference aliasing is real, you can instrument the code with a print statement that exposes the snapshot contents.
- In _addNote, find the setState(() { block. Add a print statement after _notes.add(newNote); so the block looks like this:
setState(() {
_history.add(_notes);
_notes.add(newNote);
// Temporary debug line
print('History length: ${_history.length}, first snapshot notes: ${_history.first.length}');
});
- Save and hot reload.
- In Chrome, tap + three times to add three notes.
- Check the terminal where flutter run is running. You will see the print output.
Watch the numbers carefully. After adding the third note, the output shows something like:
History length: 1, first snapshot notes: 1
History length: 2, first snapshot notes: 2
History length: 3, first snapshot notes: 3
The first snapshot's length keeps growing. If it were a true snapshot from before the first add, it would always show 0 notes. Instead it shows the current count because every snapshot is the same object.
What does this prove?
The first history entry was saved when _notes had 0 items. Yet it now reports 3 items. That is only possible if _history[0] and _notes are the same List instance in memory.
The reference was never a copy. This is the fundamental problem with mutable state in a reactive framework. The next step fixes it with immutability.
- Remove the print line you just added. It was only for diagnosis.
✔️ Awesome, I've got everything!
Double check you have saved note_list_screen.dart with the debug print removed.
ⓧ I'd like to double check the full code
Your note_list_screen.dart should contain these elements in order. Scroll up and confirm each is present:
- The List<Note> _notes = []; field followed by final List<List<Note>> _history = [];.
- The _addNote method with _history.add(_notes); as the first line inside setState.
- The _deleteNote method with _history.add(_notes); as the first line inside setState.
- The _toggleFavorite method with _history.add(_notes); as the first line inside setState.
- The _undo method that calls _history.removeLast() inside setState.
- The AppBar with an actions list containing the undo IconButton.
- No leftover print statement (you removed it after diagnosis).
The lib/models/note.dart and lib/main.dart files are unchanged from the previous step.
You have experienced the core problem: saving a reference is not saving a snapshot. Every entry in _history points to the same mutable list, so they all reflect whatever the current state is. Next, you will fix this with immutability.
Refactor to Immutable Model and Fix Undo
In the last step, you saw the reference-aliasing bug destroy your undo feature. Every history snapshot pointed to the same mutated list, so "undo" just showed you the current state again.
The fix is immutability. When objects cannot be changed after creation, every reference is automatically a valid point-in-time snapshot. This is why Flutter's entire ecosystem is built around immutable state.
In this step, get ready to:
- Refactor the Note class to be fully immutable with a const constructor, copyWith, and equality overrides.
- Replace all mutable list operations with functional alternatives (map, where, spread).
- Verify that undo correctly restores previous states.
Make the Note class immutable
In Java, you would reach for a record (JDK 16+) to get immutable data with generated equals/hashCode. Dart gives you the same guarantees through final fields and const constructors, plus explicit control over which fields are replaceable via a copyWith method.
- Open lib/models/note.dart in VS Code.
- Replace the entire file content with this immutable class declaration:
class Note {
// All fields are final — they cannot be reassigned after construction
final String id;
final String title;
final String content;
final bool isFavorite;
final DateTime createdAt;
// const constructor allows compile-time constant creation
const Note({
required this.id,
required this.title,
required this.content,
this.isFavorite = false,
required this.createdAt,
});
}
What changed from the mutable version?
Every field now has the final keyword. Once a Note is created, its values are locked. The const constructor tells Dart this object can be created at compile time if all arguments are constants.
The required keyword on named parameters replaces Java's method overloading entirely. One constructor handles all cases.
- Save note.dart and press r in your terminal to hot reload.
You should see a compile error pointing at note_list_screen.dart on the line note.isFavorite = !note.isFavorite. Dart is enforcing your immutability. The field is final, so direct assignment is now illegal.
Not seeing a compile error?
Make sure all five fields have the final keyword. If you accidentally left one as a plain String id without final, Dart won't complain about assigning to it.
help me understand why my Note fields aren't enforcing immutability
Now you need a way to create modified copies of a Note without mutating the original. This is what copyWith provides.
- In note.dart, add the following copyWith method inside the class, below the constructor and above the closing }:
// Creates a new Note with selectively overridden fields
// Null parameters keep their original value via the ?? operator
Note copyWith({
String? id,
String? title,
String? content,
bool? isFavorite,
DateTime? createdAt,
}) {
return Note(
id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content,
isFavorite: isFavorite ?? this.isFavorite,
createdAt: createdAt ?? this.createdAt,
);
}
What does copyWith do?
Each parameter is nullable (String?). If you pass a value, the new Note uses it. If you pass null (or omit the parameter), the ?? operator falls back to the current value.
Java records (JDK 16+) give you immutable data with generated equals/hashCode, but lack copyWith. Dart's approach gives you explicit control over which fields are replaceable.
- Below copyWith, add equality overrides and a toString method before the closing } of the class:
// Value equality — two Notes with identical fields are equal
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Note &&
runtimeType == other.runtimeType &&
id == other.id &&
title == other.title &&
content == other.content &&
isFavorite == other.isFavorite &&
createdAt == other.createdAt;
// Consistent with == — same fields produce same hash
@override
int get hashCode =>
id.hashCode ^
title.hashCode ^
content.hashCode ^
isFavorite.hashCode ^
createdAt.hashCode;
@override
String toString() => 'Note(id: $id, title: $title, isFavorite: $isFavorite)';
Why override operator == and hashCode?
By default, Dart uses reference equality (like Java's == on objects). Overriding operator == lets Flutter detect when a Note's data actually changed, which is critical for efficient widget rebuilds.
The ^ (XOR) operator combines field hashes into a single hashCode. This mirrors what Java's Objects.hash() does under the hood.
- Save note.dart and press r to hot reload.
You still see the compile error in note_list_screen.dart. The model is done and compiles cleanly. The remaining error tells you exactly what to fix next: the screen's mutable operations.
✔️ Awesome, I've got everything!
Confirm you have saved note.dart with all three sections: the class declaration with final fields, the copyWith method, and the equality/hashCode/toString overrides.
ⓧ I'd like to double check the full code
Scroll up and confirm your lib/models/note.dart has these sections in order:
- Class declaration with five final fields and a const constructor.
- The copyWith method returning a new Note with nullable parameters and ?? fallbacks.
- The operator == override comparing all five fields.
- The hashCode override using XOR across all fields.
- The toString override.
Refactor state operations to functional
The model is immutable. Now the screen code needs to stop mutating objects and lists in place. Instead, each operation creates a new list containing new objects.
- Open lib/screens/note_list_screen.dart.
- Find the _addNote method. Replace everything from void _addNote() through _toggleFavorite (keeping _undo untouched) with a new _saveSnapshot helper and the refactored _addNote:
// Creates an independent copy of the current list for history
void _saveSnapshot() {
_history.add(List.of(_notes));
}
void _addNote() {
final now = DateTime.now();
final newNote = Note(
id: now.millisecondsSinceEpoch.toString(),
title: 'Note ${_notes.length + 1}',
content: 'Created at ${now.hour}:${now.minute}',
createdAt: now,
);
setState(() {
_saveSnapshot();
// Spread operator creates a brand-new list
_notes = [..._notes, newNote];
});
}
What changed and why?
The _saveSnapshot method uses List.of(_notes) to create a shallow copy. Since Note is immutable (all fields final), a shallow copy is a complete snapshot. The Note objects themselves cannot change after creation.
In _addNote, the spread operator [..._notes, newNote] creates a brand-new list. The old _notes.add(newNote) mutated the existing list. Now each mutation creates a fresh reference.
- Directly below _addNote, replace the old _deleteNote and _toggleFavorite methods with these functional versions:
void _deleteNote(String id) {
setState(() {
_saveSnapshot();
// where() filters, returning a new list without the target
_notes = _notes.where((note) => note.id != id).toList();
});
}
void _toggleFavorite(String id) {
setState(() {
_saveSnapshot();
// map() transforms each element, returning a new list
_notes = _notes.map((note) {
if (note.id == id) {
return note.copyWith(isFavorite: !note.isFavorite);
}
return note;
}).toList();
});
}
What does this code do?
In _deleteNote, .where() is Dart's equivalent of Java's Stream.filter(). It returns only the elements that pass the test, producing a new list.
In _toggleFavorite, .map() transforms each element. When it finds the target note, it calls copyWith to create a new Note with the flipped favorite value. The original Note object is untouched.
- Save note_list_screen.dart and press r to hot reload.
The compile errors are gone. Your app is running again in Chrome.
Still seeing compile errors?
Check that you removed the old _addNote, _deleteNote, and _toggleFavorite methods entirely before pasting the new versions. Duplicate method names cause errors.
Also verify the _undo method is still present and unchanged between _saveSnapshot and _addNote.
help me fix compile errors after refactoring to immutable Note
✔️ Awesome, I've got everything!
Confirm you have saved note_list_screen.dart with _saveSnapshot, the refactored _addNote, _deleteNote, and _toggleFavorite.
ⓧ I'd like to double check the full code
Scroll up and confirm your lib/screens/note_list_screen.dart has these methods in order:
- The _saveSnapshot method using List.of(_notes).
- The _undo method (unchanged from the previous step).
- The _addNote method using the spread operator [..._notes, newNote].
- The _deleteNote method using .where().toList().
- The _toggleFavorite method using .map() with copyWith.
- The build method (unchanged, with _history.isNotEmpty ? _undo : null on the undo button).
Test undo and verify the fix
Before you test, think about what should happen now. Each call to _saveSnapshot creates an independent list copy. Future mutations create new objects and new lists. No snapshot can be corrupted by later operations.
What do you expect to happen when you add 3 notes, toggle a favorite, then press Undo twice?
- In Chrome, tap the + button three times to add three notes.
- Tap the star icon on any note to favorite it.
- Tap the undo button in the app bar.
The favorite is removed. The note reverts to its un-starred state. Tap undo again and the third note disappears entirely. Each undo restores the exact previous state because each snapshot is an independent list of immutable objects.
- Try a longer sequence: add notes, delete one with a long-press, favorite another, then tap undo multiple times.
Each undo reverses exactly one operation. The undo button greys out when history is empty.
Why does this fix the bug?
In the mutable version, _history.add(_notes) saved a reference to the same list. Every entry in history pointed to one object that kept changing. Now, List.of(_notes) creates a new list, and each mutation (spread, map, where) creates another new list.
No reference can be corrupted by future mutations because mutations create new objects instead of changing existing ones. This is the core principle behind every state management solution in Flutter.
Undo still not working?
If undo does nothing or shows stale state, check that _saveSnapshot() is called inside setState but before the list reassignment. The snapshot must capture the list before you replace it.
Also confirm that _addNote uses _notes = [..._notes, newNote] (reassignment) and not _notes.add(newNote) (mutation). The old .add() mutates the snapshot too.
help me debug my undo feature after refactoring to immutable state
Your Notes app now has a fully working undo system built on immutable data. Next up: the Secret Mission challenges you to add search and filtering using Dart extension methods.
Secret mission
Add Search with Extension Methods
Your Notes app works, but finding a specific note means scrolling through the whole list. In Java, you would reach for a static utility class or Stream operations. Dart has a cleaner alternative: extension methods that add behavior directly to types you do not own. Can you build a search and filtering system using a custom extension on List<Note>?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in Chrome using Flutter's built-in web server. There are no ongoing costs or cloud resources to worry about.
Resources you used:
- The notes_app project folder containing all Dart source files.
- The Flutter dev server process running in your terminal.
Keep everything running
No action needed. Choose this if you plan to continue with the next project in the series (Widget Lifecycle and BuildContext Deep Dive), which builds on this codebase.
- Your notes_app folder stays where it is.
- The Flutter dev server stays running in your terminal. Press q in the terminal when you want to stop it later.
Pause - I'll come back to this later
Stop the running process to free up your terminal, but keep all project files for later.
- In the terminal where flutter run is active, press q to quit the dev server.
- Your notes_app folder remains intact. Run flutter run -d chrome from inside it to pick up where you left off.
Delete - I don't want to use this again
Remove all project files and stop the running process entirely.
- In the terminal where flutter run is active, press q to quit the dev server.
- Navigate to the parent directory that contains notes_app and remove it by running:
rm -rf notes_app
- Confirm the folder is gone by running:
ls
You should no longer see notes_app in the output.
Nice Work!
Nice Work!
You just built a fully functional Notes app with add, delete, toggle favorite, and undo operations, all powered by idiomatic immutable Dart patterns.
You've learned how to:
- Experience a reference-aliasing bug firsthand and understand why mutable models break in Flutter's reactive rebuild cycle.
- Refactor to immutable Dart patterns using const constructors, final fields, copyWith, and operator == to fix state-snapshot bugs.
- Replace imperative mutation with functional list operations (map, where, spread) to produce new state instead of changing existing objects in place.
- Secret Mission: Added real-time search and favorites-first sorting via a custom Dart extension method on List<Note>, replacing the Java pattern of static utility classes with composable, type-safe behavior.
Ready to quiz yourself?