Bryn Flow

🐦 Build a Todo App with setState and ListView.builder

A complete todo app using Flutter's simplest state management — setState — plus efficient list rendering and local persistence with shared_preferences.

Beginner ⏱️ ~35 minutes 🧰 Dart, Flutter, setState, shared_preferences
← Flutter Track

What You'll Build

A todo list: a text field plus "Add" button, a scrollable list built with ListView.builder, tap-to-toggle-complete, swipe-to-delete, and persistence via shared_preferences so the list survives an app restart.

Prerequisites

Step by Step

1 Define the Todo model

// lib/todo.dart
class Todo {
  String title;
  bool done;

  Todo({required this.title, this.done = false});

  Map<String, dynamic> toJson() => {'title': title, 'done': done};

  factory Todo.fromJson(Map<String, dynamic> json) =>
      Todo(title: json['title'], done: json['done']);
}

toJson/fromJson let the list be serialized to a string for storage, since shared_preferences only stores primitives and string lists.

2 Set up the StatefulWidget and load saved todos

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'todo.dart';

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

class TodoApp extends StatelessWidget {
  const TodoApp({super.key});
  @override
  Widget build(BuildContext context) =>
      MaterialApp(home: const TodoScreen(), debugShowCheckedModeBanner: false);
}

class TodoScreen extends StatefulWidget {
  const TodoScreen({super.key});
  @override
  State<TodoScreen> createState() => _TodoScreenState();
}

class _TodoScreenState extends State<TodoScreen> {
  List<Todo> _todos = [];
  final _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _loadTodos();
  }

  Future<void> _loadTodos() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getStringList('todos') ?? [];
    setState(() {
      _todos = saved.map((s) => Todo.fromJson(jsonDecode(s))).toList();
    });
  }

  Future<void> _saveTodos() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setStringList(
      'todos',
      _todos.map((t) => jsonEncode(t.toJson())).toList(),
    );
  }

  // step 3 methods go here
  // step 4 build() goes here
}

3 Add, toggle, and delete methods

  void _addTodo() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    setState(() {
      _todos.insert(0, Todo(title: text));
      _controller.clear();
    });
    _saveTodos();
  }

  void _toggleTodo(int index) {
    setState(() => _todos[index].done = !_todos[index].done);
    _saveTodos();
  }

  void _deleteTodo(int index) {
    setState(() => _todos.removeAt(index));
    _saveTodos();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

Every mutating method calls setState to trigger a rebuild, then _saveTodos() to persist — keeping the in-memory list and disk copy always in sync.

4 Build the UI

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My Todos')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(hintText: 'Add a task...'),
                    onSubmitted: (_) => _addTodo(),
                  ),
                ),
                IconButton(icon: const Icon(Icons.add), onPressed: _addTodo),
              ],
            ),
          ),
          Expanded(
            child: _todos.isEmpty
                ? const Center(child: Text('No tasks yet — add one above'))
                : ListView.builder(
                    itemCount: _todos.length,
                    itemBuilder: (context, index) {
                      final todo = _todos[index];
                      return Dismissible(
                        key: ValueKey(todo.title + index.toString()),
                        onDismissed: (_) => _deleteTodo(index),
                        background: Container(color: Colors.red),
                        child: CheckboxListTile(
                          title: Text(
                            todo.title,
                            style: todo.done
                                ? const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey)
                                : null,
                          ),
                          value: todo.done,
                          onChanged: (_) => _toggleTodo(index),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

Dismissible gives swipe-to-delete for free — wrapping each row and calling the delete method in onDismissed.

5 Run it

Run flutter run, add a few tasks, tap a checkbox to complete one, swipe another to delete it, then restart the app — your remaining todos should still be there with their completed state intact.

Final Working Code

Two files — lib/todo.dart and lib/main.dart (steps 2–4 combined in order) — form the complete app exactly as shown; nothing was trimmed for the walkthrough.

What to Try Next

Want more Flutter walkthroughs?

Head back to the Flutter track for the full topic list, roadmap, and practice quiz.

Back to Flutter Track