State Lifting, Callbacks and Modular Refactoring
In this hands-on workshop you will build Part 2 (of 2) of the In-Memory Contacts Agenda app.
In Week 11 (Session 22) you built the first working version by concentrating all local memory, text controllers, mutation methods and visual presentation in a single monolithic file (agenda_page.dart). Although that approach let you validate the interaction quickly, it showed problems of coupling and lack of modularity.
In this session we will carry out an architectural refactoring of that same code, applying the principles of declarative frontend design in Flutter:
- State Lifting: The state lives in the host page (
AgendaPage) and the child components receive it through their constructor (props). - Functions as Parameters (Callbacks): Child components report events upward (
onAgregar,onEliminar) without knowing the parent or the persistence layer. - Immutable Models with
copyWith: Predictable state updates without destructive in-memory mutations. - Prop Drilling Analysis: Understand up to which depth of nesting this technique is healthy and which signs tell you it is starting to create architectural friction.
1. Diagnosing the Monolith: Why Refactor?
In the previous session, the agenda_page.dart file contained more than 150 lines of code accumulating three unrelated responsibilities:
- State Ownership: Declaration of
List<Contacto> _contactos, instantiation of_nombreCtrland_telefonoCtrl, and direct execution of_contactos.add()and_contactos.removeWhere(). - Form Presentation: Drawing of decorated cards,
TextFieldfields, empty-text validation and display ofSnackBarmessages. - Directory Presentation: List mapping (
.map()), circular avatars and action buttons for deletion.
This concentration causes three direct limitations:
- Violation of the Single Responsibility Principle (SRP): A change in the look of the add button forces you to modify the same file where the list and the deletion logic live.
- Impossibility of Reuse: If on another screen of the app we only wanted to show the contact list, we could not extract it without dragging along the form and its controllers.
- Hard to Maintain: The
build()method becomes enormous and hard to audit visually.
2. State Lifting
Flutter follows the principle of Unidirectional Data Flow:
Data goes down (Data Down): A parent widget supplies information to its descendants by passing it as arguments in their constructors.
Events go up (Events Up): A child widget notifies that something happened in the interface by executing a callback function provided by its parent.
When two sibling widgets need to interact (the form that creates a contact and the list that must show it immediately), neither of them should hold a direct reference to the other.
To solve this need, we lift the state (State Lifting) to their closest common ancestor (AgendaPage). Walk through the contact book's component tree: first you will see why a component cannot hand data to its sibling, and then how lifting solves it, with data flowing down and events flowing up (phases 3 and 4 come back in section 5):
In this scheme:
AgendaPageis theStatefulWidgetthat holds the_contactoslist and centralizes the calls tosetState().ContactoFormis a specialized component whose only job is to capture text, validate fields and emit a ready-madeContactoobject.ContactoListis a purely visual component (StatelessWidget) that receives the list and notifies when the user taps delete.
3. Functions as Parameters: Decoupled Callbacks
In Dart, functions are first-class citizens. This means a function can be assigned to a variable, stored in a list or passed as an argument to a class constructor.
Types of Callbacks in Flutter
| Signature Type | Dart Declaration | When to Use It |
|---|---|---|
VoidCallback | void Function() | For events that carry no data (e.g. refresh, cancel, close dialog). |
ValueChanged<T> | void Function(T value) | For events that carry a single primitive value (e.g. a String, an int, a bool). |
| Custom Signature | void Function(Contacto contacto) | For domain events that emit complete entities or specific models. |
The Child Reports Without Knowing the Parent
Notice how the child widget defines its contract:
class ContactoForm extends StatefulWidget {
final void Function(Contacto nuevoContacto) onAgregar;
const ContactoForm({
super.key,
required this.onAgregar,
});
// ...
}
When the user presses the button, the form simply executes:
// The child does not know whether the parent saves to SQLite, memory or Supabase:
// It only fulfills its contract of announcing that a new contact is ready.
widget.onAgregar(nuevo);
4. Immutable Models and the copyWith Method
In a StatefulWidget, setState() always marks the Element as dirty, whether you changed the list or created a new one. Even so, mutating shared objects in memory is a frequent source of bugs, which is why models are designed to be immutable:
- Predictability: if a
Contactonever changes after it is created, none of the components receiving it through their constructor can alter it without its owner knowing. All changes go through a single place:AgendaPage. - Change detection: when a child receives an object through its constructor, comparing it (in
didUpdateWidget, or in state-management tools such as Provider, Riverpod or BLoC that you will see later) boils down to asking whether it is another reference. If you mutate the original object instead of creating a new one, that comparison no longer detects the change. - Fewer subtle bugs: sharing one mutable object between two screens lets one modify it "underneath" the other.
To work with immutable models we apply the Immutability with copyWith pattern:
class Contacto {
final String id;
final String nombre;
final String telefono;
const Contacto({
required this.id,
required this.nombre,
required this.telefono,
});
Contacto copyWith({
String? id,
String? nombre,
String? telefono,
}) {
return Contacto(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
telefono: telefono ?? this.telefono,
);
}
}
Why Is copyWith So Valuable?
If in a later version you let the user edit the phone number of an existing contact, instead of performing a destructive mutation (contacto.telefono = '300...'), you create a new object cloned immutably:
final contactoActualizado = contactoOriginal.copyWith(
telefono: '310 999 0011',
);
And in the page list, you replace the element by producing a new collection:
// Note: to reassign the list, _contactos is declared without final (List<Contacto> _contactos = [...])
setState(() {
_contactos = _contactos.map((c) {
return c.id == idObjetivo ? c.copyWith(telefono: nuevoTelefono) : c;
}).toList();
});
5. Prop Drilling: What It Is, How Far It Is Acceptable and When It Hurts
Prop Drilling happens when we pass data or callbacks through multiple intermediate levels of widgets that do not need them for themselves, but only to forward them to a deeper descendant widget.
The difference shows up in the contact book's tree. With AgendaPage → ContactoList → ContactoCard each level uses what it receives. But if the team adds a summary with the total number of contacts three levels further down (PanelResumen → BarraResumen → ContadorChip), the data has to cross two widgets that do not use it. Move through the Prop drilling and La salida ("The way out") phases:
- Healthy Zone (1 to 2 levels)
- Critical Zone (Painful Prop Drilling)
In this 1 to 2 level scenario (AgendaPage ContactoList):
- Each level consumes what it receives: The list needs the contacts to run
.map()and draw them on screen. - Direct traceability: Any developer can Ctrl + Click on the constructor and know for certain where each piece of data comes from.
- Zero dependencies: It requires no external libraries or global context setup.
class ContactoList extends StatelessWidget {
final List<Contacto> contactos;
final void Function(String id) onEliminar;
const ContactoList({
super.key,
required this.contactos,
required this.onEliminar,
});
Widget build(BuildContext context) {
// ContactoList CONSUMES the contactos property directly
return Column(
children: contactos.map((c) {
return Card(
child: ListTile(
title: Text(c.nombre),
subtitle: Text(c.telefono),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => onEliminar(c.id),
),
),
);
}).toList(),
);
}
}
In this scenario of 3 or more levels with pass-through widgets:
- Pass-Through widgets: Intermediate components such as
DashboardShellorSectionTabBodydo not useuserRolein their ownbuild(), but are forced to declare it in their constructor just to hand it to the next child. - Fragile refactors: If the type of
userRolechanges, you must edit 3 or 4 intermediate files that have no business relationship with that data.
class DashboardShell extends StatelessWidget {
// Pass-through widget: receives properties it NEVER uses in its own build
final String userRole;
final VoidCallback onLogout;
const DashboardShell({
super.key,
required this.userRole,
required this.onLogout,
});
Widget build(BuildContext context) {
return Scaffold(
body: SectionTabBody(
userRole: userRole, // Mandatory forwarding with no local benefit
onLogout: onLogout,
),
);
}
}
When you find constructors bloated with properties that the widget does not use in its own build(), it is the exact moment to migrate to Global State Management / Dependency Injection tools such as Riverpod, Provider or BLoC.
6. Hands-On Workshop: Step-by-Step Refactoring
Next we take literally the same code from the previous workshop and break it down into independent modules:
lib/
├── models/
│ └── contacto.dart
├── components/
│ ├── contacto_form.dart
│ └── contacto_list.dart
├── pages/
│ └── agenda_page.dart
├── screens/
│ └── main_screen.dart
└── main.dart
Update the Model with copyWith
We keep the Contacto class with its final fields, remove unnecessary helper functions and add the copyWith method:
class Contacto {
final String id;
final String nombre;
final String telefono;
const Contacto({
required this.id,
required this.nombre,
required this.telefono,
});
Contacto copyWith({
String? id,
String? nombre,
String? telefono,
}) {
return Contacto(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
telefono: telefono ?? this.telefono,
);
}
}
Extract the Form into ContactoForm
We move the form to its own file. It keeps its text controllers as local ephemeral UI state, performs the validation and notifies the parent by executing widget.onAgregar:
import 'package:flutter/material.dart';
import '../models/contacto.dart';
class ContactoForm extends StatefulWidget {
final void Function(Contacto nuevoContacto) onAgregar;
const ContactoForm({
super.key,
required this.onAgregar,
});
State<ContactoForm> createState() => _ContactoFormState();
}
class _ContactoFormState extends State<ContactoForm> {
late final TextEditingController _nombreCtrl;
late final TextEditingController _telefonoCtrl;
void initState() {
super.initState();
_nombreCtrl = TextEditingController();
_telefonoCtrl = TextEditingController();
}
void dispose() {
_nombreCtrl.dispose();
_telefonoCtrl.dispose();
super.dispose();
}
void _enviarFormulario() {
final nombre = _nombreCtrl.text.trim();
final telefono = _telefonoCtrl.text.trim();
if (nombre.isEmpty || telefono.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Enter both the name and the phone number.'),
backgroundColor: Colors.orange,
),
);
return;
}
final nuevoContacto = Contacto(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre,
telefono: telefono,
);
widget.onAgregar(nuevoContacto);
_nombreCtrl.clear();
_telefonoCtrl.clear();
FocusScope.of(context).unfocus();
}
Widget build(BuildContext context) {
return Card(
elevation: 0,
color: const Color(0xFFF5F3FF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFDDD6FE)),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Register New Contact',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6B21A8),
),
),
const SizedBox(height: 12),
TextField(
controller: _nombreCtrl,
decoration: const InputDecoration(
labelText: 'Name',
prefixIcon: Icon(Icons.person_outline),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _telefonoCtrl,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Phone',
prefixIcon: Icon(Icons.phone_outlined),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _enviarFormulario,
icon: const Icon(Icons.person_add),
label: const Text('Add Contact'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6750A4),
),
),
],
),
),
);
}
}
Extract the List into ContactoList
We extract the contact list as a StatelessWidget. It receives the list as a parameter, handles the empty view and draws each card with its delete button:
import 'package:flutter/material.dart';
import '../models/contacto.dart';
class ContactoList extends StatelessWidget {
final List<Contacto> contactos;
final void Function(String id) onEliminar;
const ContactoList({
super.key,
required this.contactos,
required this.onEliminar,
});
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Active Directory',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
Chip(
label: Text('${contactos.length} contacts'),
backgroundColor: const Color(0xFFE2E8F0),
),
],
),
const SizedBox(height: 12),
if (contactos.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: Text(
'No contacts registered in the agenda.',
style: TextStyle(color: Colors.grey),
),
),
)
else
...contactos.map((contacto) {
return Card(
margin: const EdgeInsets.only(bottom: 8.0),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFE0E7FF),
child: Text(
contacto.nombre.isNotEmpty ? contacto.nombre[0].toUpperCase() : '?',
style: const TextStyle(
color: Color(0xFF4338CA),
fontWeight: FontWeight.bold,
),
),
),
title: Text(
contacto.nombre,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(contacto.telefono),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
tooltip: 'Delete contact',
onPressed: () => onEliminar(contacto.id),
),
),
);
}),
],
);
}
}
Simplify AgendaPage as the State Host
AgendaPage keeps the in-memory contact list (_contactos) and the mutation methods (_agregarContacto, _eliminarContacto). Its build() method now simply coordinates the two child components:
import 'package:flutter/material.dart';
import '../models/contacto.dart';
import '../components/contacto_form.dart';
import '../components/contacto_list.dart';
class AgendaPage extends StatefulWidget {
const AgendaPage({super.key});
State<AgendaPage> createState() => _AgendaPageState();
}
class _AgendaPageState extends State<AgendaPage> {
// 1. In-memory state of the agenda
final List<Contacto> _contactos = [
const Contacto(
id: '1',
nombre: 'María Camila Gómez',
telefono: '312 456 7890',
),
const Contacto(
id: '2',
nombre: 'Alejandro Soto',
telefono: '300 123 9988',
),
const Contacto(
id: '3',
nombre: 'Daniela Valderrama',
telefono: '315 789 1122',
),
];
// 2. Method to add a contact (executed by the ContactoForm callback)
void _agregarContacto(Contacto nuevo) {
setState(() {
_contactos.add(nuevo);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Contact "${nuevo.nombre}" added successfully.'),
backgroundColor: const Color(0xFF10B981),
),
);
}
// 3. Method to delete a contact (executed by the ContactoList callback)
void _eliminarContacto(String id) {
setState(() {
_contactos.removeWhere((c) => c.id == id);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Contact removed from the list.'),
duration: Duration(seconds: 2),
),
);
}
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Form component (reports upward with a callback)
ContactoForm(onAgregar: _agregarContacto),
const SizedBox(height: 24),
// List component (receives contacts as a prop and the delete callback)
ContactoList(
contactos: _contactos,
onEliminar: _eliminarContacto,
),
],
),
);
}
}
Define MainScreen with Scaffold
Following the Screen vs. Page architectural convention, MainScreen hosts the Scaffold and the top navigation bar:
import 'package:flutter/material.dart';
import '../pages/agenda_page.dart';
class MainScreen extends StatelessWidget {
const MainScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Contacts Agenda'),
centerTitle: true,
elevation: 2,
),
body: const AgendaPage(),
);
}
}
Entry Point in main.dart
main.dart starts the application by directly mounting MainScreen:
import 'package:flutter/material.dart';
import 'screens/main_screen.dart';
void main() {
runApp(const AgendaApp());
}
class AgendaApp extends StatelessWidget {
const AgendaApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Contacts Agenda',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4)),
useMaterial3: true,
),
home: const MainScreen(),
);
}
}
7. Architecture Comparison: Before vs. After
| Technical Criterion | Monolithic Version (Week 11) | Refactored Version (Week 12) |
|---|---|---|
| State Location | Captive and tangled inside agenda_page.dart. | Lifted and owned by AgendaPage. |
| Form Responsibility | Mixed with list drawing and direct mutation. | Autonomous ContactoForm component focused only on input capture. |
| List Responsibility | Code scattered inside the same build(). | Modular ContactoList component that only draws. |
| Communication Between Components | Nonexistent (everything accesses private _ variables). | Explicit contracts through constructors and typed callbacks. |
| Code Reuse | 0% (impossible to use the list or the form in isolation). | High (you can instantiate ContactoForm or ContactoList anywhere). |
| Maintainability | Difficult (a single file with multiple responsibilities). | Excellent (small modules of fewer than 70 lines each). |