Skip to content
AgileLogics
7 min read

Stateful Widgets and User Input

Prerequisites: Flutter fundamentals

A StatelessWidget can't change. When a widget has data that changes — a counter, a form field, a toggle — use a StatefulWidget.

A counter

import 'package:flutter/material.dart';

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: Text('Count: $_count', style: Theme.of(context).textTheme.headlineLarge),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        tooltip: 'Add one',
        child: const Icon(Icons.add),
      ),
    );
  }
}
  • A stateful widget comes in two parts: the widget (configuration) and a State object that holds the changing data and survives rebuilds.
  • setState tells Flutter the data changed, so it calls build again. Changing _count without setState updates the variable but not the screen.
  • onPressed: _increment passes a function (a tear-off) to the button.

Lifecycle

State has lifecycle methods:

  • initState() — runs once when the widget is inserted: start listening to streams, create controllers.
  • dispose() — runs when it's removed: cancel subscriptions, dispose controllers. Forgetting this causes memory leaks.

Text input

class NameForm extends StatefulWidget {
  const NameForm({super.key});

  @override
  State<NameForm> createState() => _NameFormState();
}

class _NameFormState extends State<NameForm> {
  final _controller = TextEditingController();
  String _greeting = '';

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

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          TextField(
            controller: _controller,
            decoration: const InputDecoration(labelText: 'Your name'),
          ),
          const SizedBox(height: 8),
          FilledButton(
            onPressed: () => setState(() => _greeting = 'Hello, ${_controller.text}!'),
            child: const Text('Greet'),
          ),
          Text(_greeting),
        ],
      ),
    );
  }
}

Lists

ListView.builder builds rows lazily, only for the items on screen, so it handles long lists efficiently:

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ListTile(
    title: Text(items[index]),
    onTap: () => print('Tapped ${items[index]}'),
  ),
)

Keep local state local

setState is perfect for state that belongs to one widget — a text field, an expanded panel, an animation. When several screens share data, move it out of widgets into a state management solution — the next lesson.