Flutter: StatefulWidget and Interaction

State is a Widget's memory — without State, an interface is like an amnesiac who doesn't recognize you each time.

📋 Prerequisites: You need to have completed the following first

1. What You Will Learn


2. A Real Story About an Interaction Dilemma

(1) The Pain: Clicks Not Responding

Bob encountered a strange bug on the ShopApp cart page: users clicked the "-" button, the quantity went from 1 to 0, but the UI still displayed 1. He directly modified the variable _count = _count - 1 without calling setState — the Widget didn't know the state changed, so naturally it wouldn't rebuild the UI.

(2) The setState Solution

StatefulWidget's core principle: changing state doesn't equal changing UI. You must notify the framework via setState that "I've changed, please rebuild."

DART
import 'package:flutter/material.dart';

// WRONG: Direct mutation, UI not updated
void _decrement() {
  _count = _count - 1; // State changed, but UI doesn't know
}

// RIGHT: setState triggers rebuild
void _decrement() {
  setState(() {
    _count = _count - 1; // Framework rebuilds this widget
  });
}
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

(3) The Result: UI and State Always in Sync

After adding setState, the cart quantity updated in real time and the total price recalculated automatically. From then on, Bob remembered: when you change data, you must call setState.


3. StatefulWidget Lifecycle

100%
stateDiagram-v2
    [*] --> createState
    createState --> initState
    initState --> didChangeDependencies
    didChangeDependencies --> build
    build --> didUpdateWidget: setState / parent rebuild
    didUpdateWidget --> build
    build --> deactivate: removed from tree
    deactivate --> dispose
    dispose --> [*]
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

(1) Lifecycle Callbacks

Callback Trigger Timing Purpose
createState Framework creates State Initialize State object
initState State inserted into tree One-time initialization (subscriptions, controllers)
didChangeDependencies Dependent InheritedWidget changes Read theme, locale, etc.
build Every time UI needs rendering Build Widget tree
didUpdateWidget Parent Widget rebuilds Compare old/new Widget and respond
setState Developer calls explicitly Mark dirty, trigger rebuild
deactivate State removed from tree Temporary removal (can be reinserted)
dispose State permanently destroyed Release resources, cancel subscriptions

▶ Example

TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

: Lifecycle tracking

DART
import 'package:flutter/material.dart';

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

  @override
  State<LifecycleTracker> createState() => _LifecycleTrackerState();
}

class _LifecycleTrackerState extends State<LifecycleTracker> {
  int _buildCount = 0;

  @override
  void initState() {
    super.initState();
    debugPrint('initState called');
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    debugPrint('didChangeDependencies called');
  }

  @override
  void didUpdateWidget(covariant LifecycleTracker oldWidget) {
    super.didUpdateWidget(oldWidget);
    debugPrint('didUpdateWidget called');
  }

  @override
  Widget build(BuildContext context) {
    _buildCount++;
    debugPrint('build #$_buildCount');
    return Text('Build count: $_buildCount');
  }

  @override
  void dispose() {
    debugPrint('dispose called');
    super.dispose();
  }
}
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

4. setState Mechanism In Depth

(1) setState Workflow

Step Action
1 Call setState(fn)
2 Execute fn, update state variables
3 Mark Element as dirty
4 Next frame, Framework calls build
5 Diff old/new Widget trees, update RenderObject

▶ Example

TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

: Cart quantity selector

DART
import 'package:flutter/material.dart';

class QuantitySelector extends StatefulWidget {
  final int initialValue;
  final ValueChanged<int> onChanged;

  const QuantitySelector({
    super.key,
    this.initialValue = 1,
    required this.onChanged,
  });

  @override
  State<QuantitySelector> createState() => _QuantitySelectorState();
}

class _QuantitySelectorState extends State<QuantitySelector> {
  late int _count;

  @override
  void initState() {
    super.initState();
    _count = widget.initialValue;
  }

  @override
  void didUpdateWidget(covariant QuantitySelector oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.initialValue != widget.initialValue) {
      _count = widget.initialValue;
    }
  }

  void _increment() {
    setState(() {
      _count = (_count + 1).clamp(1, 99);
    });
    widget.onChanged(_count);
  }

  void _decrement() {
    setState(() {
      _count = (_count - 1).clamp(1, 99);
    });
    widget.onChanged(_count);
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          IconButton(icon: const Icon(Icons.remove), onPressed: _decrement),
          Text('$_count', style: const TextStyle(fontSize: 18)),
          IconButton(icon: const Icon(Icons.add), onPressed: _increment),
        ],
      ),
    );
  }
}
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

5. Event Handling

(1) GestureDetector vs InkWell

Widget Click Effect Ripple Use Case
GestureDetector No visual feedback No Custom gestures (long press/drag/double tap)
InkWell Material ripple Yes Card/list item clicks
IconButton Icon + ripple Yes Toolbar actions
ElevatedButton Button style Yes Primary actions

▶ Example

TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

: GestureDetector gesture recognition

DART
import 'package:flutter/material.dart';

// Simplified class definition
class Product {
  final String name;
  final double price;
  final String imageUrl;
  final int id;
  const Product({required this.name, required this.price, required this.imageUrl, required this.id});
}

GestureDetector(
  onTap: () => debugPrint('Tapped'),
  onDoubleTap: () => debugPrint('Double tapped'),
  onLongPress: () => debugPrint('Long pressed'),
  onHorizontalDragEnd: (details) {
    // Swipe to add/remove from cart
    if (details.primaryVelocity! < 0) {
      addToCart(product); // Swipe left
    } else {
      removeFromCart(product); // Swipe right
    }
  },
  child: ProductCard(product: product),
)
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

▶ Example

TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

: InkWell product card click

DART
import 'package:flutter/material.dart';

// Product class definition shown above

InkWell(
  onTap: () => Navigator.pushNamed(context, '/product/${product.id}'),
  borderRadius: BorderRadius.circular(12),
  child: Card(
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
    child: Padding(
      padding: const EdgeInsets.all(12),
      child: Row(
        children: [
          Image.network(product.imageUrl, width: 60, height: 60),
          const SizedBox(width: 12),
          Expanded(child: Text(product.name)),
          Text('$${product.price.toStringAsFixed(2)}'),
        ],
      ),
    ),
  ),
)
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

6. Input Interaction: TextField

(1) TextEditingController

Feature Method
Get text controller.text
Set text controller.text = 'new'
Listen for changes controller.addListener(fn)
Select text controller.selection
Clear input controller.clear()

▶ Example

TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

: Search bar implementation

DART
import 'package:flutter/material.dart';

class SearchBar extends StatefulWidget {
  final ValueChanged<String> onSearch;

  const SearchBar({super.key, required this.onSearch});

  @override
  State<SearchBar> createState() => _SearchBarState();
}

class _SearchBarState extends State<SearchBar> {
  final _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      widget.onSearch(_controller.text);
    });
  }

  @override
  void dispose() {
    _controller.dispose(); // Important: prevent memory leak
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
        hintText: 'Search products...',
        prefixIcon: const Icon(Icons.search),
        suffixIcon: _controller.text.isNotEmpty
            ? IconButton(
                icon: const Icon(Icons.clear),
                onPressed: () {
                  _controller.clear();
                  widget.onSearch('');
                },
              )
            : null,
        border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
      ),
    );
  }
}
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

7. Complete Example: ShopApp Cart Page

DART
import 'package:flutter/material.dart';

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

  @override
  State<CartPage> createState() => _CartPageState();
}

class _CartPageState extends State<CartPage> {
  final List<CartItem> _items = [
    CartItem(name: 'Pro Laptop', price: 1299.99, quantity: 1),
    CartItem(name: 'Wireless Mouse', price: 29.99, quantity: 2),
    CartItem(name: 'USB-C Hub', price: 49.99, quantity: 1),
  ];

  double get _total => _items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
  int get _itemCount => _items.fold(0, (sum, item) => sum + item.quantity);

  void _updateQuantity(int index, int newQty) {
    setState(() {
      if (newQty <= 0) {
        _items.removeAt(index);
      } else {
        _items[index] = _items[index].copyWith(quantity: newQty);
      }
    });
  }

  void _removeItem(int index) {
    setState(() => _items.removeAt(index));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Cart ($_itemCount items)')),
      body: _items.isEmpty
          ? const Center(child: Text('Your cart is empty'))
          : Column(
              children: [
                Expanded(
                  child: ListView.separated(
                    itemCount: _items.length,
                    separatorBuilder: (_, __) => const Divider(),
                    itemBuilder: (context, index) {
                      final item = _items[index];
                      return ListTile(
                        leading: const Icon(Icons.shopping_bag),
                        title: Text(item.name),
                        subtitle: Text('$${item.price.toStringAsFixed(2)} each'),
                        trailing: Row(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            IconButton(icon: const Icon(Icons.remove_circle_outline),
                              onPressed: () => _updateQuantity(index, item.quantity - 1)),
                            Text('${item.quantity}', style: const TextStyle(fontSize: 16)),
                            IconButton(icon: const Icon(Icons.add_circle_outline),
                              onPressed: () => _updateQuantity(index, item.quantity + 1)),
                            IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red),
                              onPressed: () => _removeItem(index)),
                          ],
                        ),
                      );
                    },
                  ),
                ),
                // Total bar
                Container(
                  padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(color: Colors.grey[100],
                    border: Border(top: BorderSide(color: Colors.grey[300]!))),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
                        children: [
                          const Text('Total', style: TextStyle(fontSize: 12)),
                          Text('$${_total.toStringAsFixed(2)}',
                            style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                        ]),
                      ElevatedButton(
                        onPressed: _items.isEmpty ? null : () {},
                        style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16)),
                        child: const Text('Checkout'),
                      ),
                    ],
                  ),
                ),
              ],
            ),
    );
  }
}

class CartItem {
  final String name;
  final double price;
  final int quantity;
  const CartItem({required this.name, required this.price, required this.quantity});
  CartItem copyWith({String? name, double? price, int? quantity}) =>
      CartItem(name: name ?? this.name, price: price ?? this.price, quantity: quantity ?? this.quantity);
}
TEXT
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.

❓ FAQ

Q Can setState be called inside initState?
A No. When initState executes, the Widget hasn't finished building yet. If you need to update after initialization, use WidgetsBinding.instance.addPostFrameCallback.
Q Why use a closure in setState even for a single variable assignment?
A The closure clearly marks which state changed, helping the framework optimize. It also makes it easier to trace state changes during debugging.
Q GestureDetector's onTap not working?
A Check if the child has a gesture competitor that absorbs events (e.g., ListView scrolling). Use behavior: HitTestBehavior.opaque to ensure the entire area is clickable.
Q When should TextEditingController be disposed?
A Call controller.dispose() in the State's dispose method, otherwise it will cause a memory leak.
Q Why does the UI not change after setState?
A Check if you modified the variable outside the setState closure. Only changes within the closure trigger a rebuild.
Q Do multiple setState calls cause multiple rebuilds?
A No. Flutter coalesces multiple setState calls within a single frame and only rebuilds once.

📖 Summary


📝 Exercises

  1. Basic (difficulty ⭐): Create a counter page with "add/subtract/reset" buttons and a centered number display.
  2. Intermediate (difficulty ⭐⭐): Implement a search bar that filters a list in real time as you type (using setState + TextEditingController).
  3. Challenge (difficulty ⭐⭐⭐): Implement a complete shopping cart: support quantity adjustment, item removal, automatic total calculation, and show "Cart is empty" for the empty state.

← Previous | Next →

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏