Home Introduction to Flutter UI Learn About Stateful Widgets

🔄 Stateful Widgets — Make Your App Come Alive!

⏱️ 35-40 minutes 📊 Intermediate — Game-changing concept! 📦 2 Projects included 🏷️ StatefulWidget, setState, State, Rebuild

🧒 Why Won't My App Update? (The Magic Painting Analogy!)

🖼️

Your App Is Currently a "Painting" — Not a "TV Screen"

Right now, your Birdle app is like a beautiful painting on the wall. You can type guesses and press Enter, but the tiles never change. The game logic runs behind the scenes, but the screen doesn't update!

That's because your GamePage is a StatelessWidget — it's painted once and stays frozen forever. To make it update, you need to convert it into a StatefulWidget — like turning a painting into a TV screen that can show new images!

🎯
By the end of this lesson:
  • ✅ You'll understand when widgets need to be stateful
  • ✅ You'll convert GamePage from StatelessWidget to StatefulWidget
  • ✅ You'll use setState to update the UI when data changes
  • ✅ Your Birdle tiles will actually change colors when you guess!
🆚

StatelessWidget vs StatefulWidget — The Key Difference

🖼️

StatelessWidget

Like a painting. Once it's drawn, it never changes. All the data comes from outside (constructor parameters).

Example: Your Tile widget — given a letter and hitType, it always looks the same.

📺

StatefulWidget

Like a TV screen. It can update and show new content when data changes. It has a memory (called State).

Example: Your GamePage — needs to show new guesses and change tile colors.

🧠
Golden Rule: If your widget's appearance or data needs to change over time, use a StatefulWidget. If it's always the same once created, use a StatelessWidget. Start with StatelessWidget, and only convert to StatefulWidget when you actually need it to update.
🧬

A StatefulWidget Has TWO Classes Working Together

Unlike a StatelessWidget (which is just one class), a StatefulWidget is actually two classes that work as a team:

📋

1. The Widget (GamePage)

The ID card — it tells Flutter: "I'm a GamePage widget." It's still immutable (can't change) and just creates the State object.

class GamePage extends StatefulWidget

🧠

2. The State (_GamePageState)

The brain and memory — this is where all the changeable data lives. It has the build method and can call setState to update the UI.

class _GamePageState extends State<GamePage>

The underscore _ before _GamePageState makes it private — only the GamePage widget can use its own State. No one else can mess with it!

1 The Basic StatefulWidget Pattern

Before we convert GamePage, let's see the bare-bones structure of a StatefulWidget so you recognize the pattern.

Step 1.1 The Minimum StatefulWidget Code

Here's the simplest possible StatefulWidget — it doesn't do anything yet, but it shows the structure:

🔍 Anatomy of a StatefulWidget

The basic pattern
// PART 1: The Widget (immutable ID card)
class ExampleWidget extends StatefulWidget {
    ExampleWidget({super.key});

    @override
    State<ExampleWidget> createState() => _ExampleWidgetState();
    //      ↑ This is the ONLY job of the widget class:
    //        create and return a State object
}

// PART 2: The State (brain with memory)
class _ExampleWidgetState extends State<ExampleWidget> {
    // ↑ Underscore _ = private. Only ExampleWidget can use this.

    @override
    Widget build(BuildContext context) {
        return Container();  // Your UI goes here
    }
}

Key things to notice:

  • Two classes — The widget class and the state class. They're a team!
  • createState() — The widget's only job. It returns the State object.
  • build() — Now lives in the State class, not the widget class.
  • The underscore _ — Makes the State class private to this file.
  • The State class uses State<ExampleWidget> — it's tied to its widget.

2 Convert GamePage to a StatefulWidget

Now let's actually convert your GamePage. Follow these steps carefully — this is the most important transformation in the whole course!

Step 2.1 BEFORE: Your Current StatelessWidget GamePage

Here's what your GamePage probably looks like right now (or similar):

Current StatelessWidget version
class GamePage extends StatelessWidget {  // ← STATELESS
    GamePage({super.key});

    final Game _game = Game();  // ← Game logic (mutable data!)

    @override
    Widget build(BuildContext context) {
        return Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
                children: [
                    for (var guess in _game.guesses)
                        Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                                for (var letter in guess)
                                    Padding(
                                        padding: const EdgeInsets.symmetric(
                                            horizontal: 2.5, vertical: 2.5),
                                        child: Tile(letter.char, letter.type),
                                    )
                            ],
                        ),
                    GuessInput(
                        onSubmitGuess: (guess) {
                            print(guess);  // Temporary!
                        },
                    ),
                ],
            ),
        );
    }
}

Step 2.2 AFTER: Converted to StatefulWidget

Here's the exact same widget, converted to a StatefulWidget. Compare it line by line:

Converted to StatefulWidget
// PART 1: The Widget (just an ID card now)
class GamePage extends StatefulWidget {  // ← CHANGED to StatefulWidget
    GamePage({super.key});

    @override
    State<GamePage> createState() => _GamePageState();  // ← NEW: creates State
}

// PART 2: The State (brain with memory)
class _GamePageState extends State<GamePage> {  // ← NEW: State class
    final Game _game = Game();  // ← MOVED from widget to State

    @override
    Widget build(BuildContext context) {  // ← MOVED to State class
        return Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
                children: [
                    for (var guess in _game.guesses)
                        Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                                for (var letter in guess)
                                    Padding(
                                        padding: const EdgeInsets.symmetric(
                                            horizontal: 2.5, vertical: 2.5),
                                        child: Tile(letter.char, letter.type),
                                    )
                            ],
                        ),
                    GuessInput(
                        onSubmitGuess: (guess) {
                            print(guess);  // Still temporary! We'll fix this next.
                        },
                    ),
                ],
            ),
        );
    }
}

What changed (3 things):

  1. GamePage now extends StatefulWidget — It's no longer frozen.
  2. Added createState() method — Returns a new _GamePageState object.
  3. Created _GamePageState class — Contains the Game object and the build method. Everything that was in the widget class moved here.
VS Code Shortcut! In VS Code, you can place your cursor on StatelessWidget, press Ctrl + . (or Cmd + . on Mac), and select "Convert to StatefulWidget". VS Code does the whole conversion automatically! But it's important to understand what's happening, so try doing it manually first.

3 Why Does This Matter? — The setState Magic

Converting to StatefulWidget alone doesn't fix anything. The real power comes from setState — the method that tells Flutter: "Hey! Something changed! Redraw the screen!"

Step 3.1 The Problem: Data Changes, But Screen Doesn't

Imagine this scenario without setState:

❌ WITHOUT setState — UI won't update!
onSubmitGuess: (guess) {
    _game.guess(guess);  // ← Game data changes internally...
    // BUT Flutter doesn't know it needs to redraw!
    // The screen stays frozen. User sees nothing happen.
},

This is like changing the channel on your TV but never turning the screen on. The channel changed, but you can't see it!

Step 3.2 The Solution: Wrap Changes in setState()

Now wrap the game logic in setState():

✅ WITH setState — UI updates!
onSubmitGuess: (String guess) {
    setState(() {                    // ← "Hey Flutter! Watch me change stuff!"
        _game.guess(guess);            // ← Actually evaluate the guess
    });                                // ← Flutter now redraws the screen!
},

What setState does (in plain English):

  1. Runs your code — It executes whatever function you give it (like _game.guess(guess)).
  2. Marks the widget as "dirty" — Tells Flutter: "This widget's data changed. It needs to be redrawn."
  3. Schedules a rebuild — Flutter calls your build() method again, and this time the UI reflects the new data.
Try it now! Update your onSubmitGuess callback to use setState as shown above. Hot reload, type a legal 5-letter word (like "aback" or "abbey"), and press Enter. The tiles should now show your guess with colors! 🎉
⚠️
Important rules of setState:
  • Only change state variables inside setState (things that affect the UI).
  • Don't do heavy work (like HTTP requests) inside setState — it should be fast.
  • Never call setState inside build() — that creates an infinite loop!
  • setState only affects the widget that called it and its children.

4 Visualizing the setState Flow

Let's trace exactly what happens when a user submits a guess, from keyboard press to screen update.

Step 4.1 The Complete Flow — Step by Step

🔄
  1. User types "ABACK" and presses Enter
  2. GuessInput.onSubmitted fires → calls onSubmitGuess("aback")
  3. onSubmitGuess runs setState() → tells Flutter "watch this!"
  4. _game.guess("aback") executes → game logic evaluates each letter:
    • If 'a' matches → HitType.hit (green)
    • If 'b' is in word but wrong spot → HitType.partial (yellow)
    • If 'c' isn't in word → HitType.miss (gray)
  5. Game saves the guess in its internal list
  6. setState finishes → Flutter marks GamePage as "dirty"
  7. Flutter calls build() again → the for-loop reads _game.guesses
  8. Tiles are created with new colors → screen updates!
🔄 setState() called GamePage marked "dirty"
🔨 build() runs again Rebuilds entire widget tree
🎨 Tiles get new colors Green 🟩 / Yellow 🟨 / Gray ⬜
Screen updates! User sees their guess

📝 What You Learned Today

You just learned the most important concept for making interactive Flutter apps!

When to Use StatefulWidget

Use StatefulWidget when your widget's appearance or data needs to change during its lifetime. Start with StatelessWidget and convert only when needed.

Converted GamePage

Refactored GamePage into two classes: GamePage (the widget) and _GamePageState (the state with memory and build method).

Used setState to Update UI

Called setState(() { _game.guess(guess); }) — Flutter now knows to rebuild the widget tree and show new tile colors.

Your App is Now Interactive!

Users can type guesses, submit them, and see the tiles update with the correct colors. The Birdle game works!

🧠 Test Yourself!

Let's check your understanding of StatefulWidgets:

Q1 When should you use a StatefulWidget instead of a StatelessWidget?

Q2 What happens if you change data in a State object without calling setState?

📦 Project 1: Build a Tap Counter App

Practice StatefulWidget and setState by building a classic counter app from scratch!

Project 1 Beginner

Objective: Create a Button That Counts Taps

📋 Requirements:

  1. Create a new StatefulWidget called CounterPage
  2. It should have a State class with an int _count = 0; variable
  3. The build method should return a Column with:
    • A Text widget showing: "You've tapped the button X times" (where X is _count)
    • A SizedBox(height: 20)
    • An ElevatedButton that says "Tap me!"
  4. When the button is pressed, call setState(() { _count++; })
  5. Center the Column on screen

🎯 Expected Output:

A centered counter that increments every time you tap the button. The text updates automatically!

💡
Hint: The key line is onPressed: () { setState(() { _count++; }); } — without setState, the count changes but the screen never updates!

📦 Project 2: Build a Color-Changing Container

Use setState to dynamically change a widget's appearance — a pattern you'll use constantly!

Project 2 Beginner

Objective: Tap to Change Background Color

📋 Requirements:

  1. Create a StatefulWidget called ColorChanger
  2. It should have a List of Colors: [Colors.red, Colors.blue, Colors.green, Colors.orange, Colors.purple]
  3. Have an int _currentIndex = 0; state variable
  4. The build method should return:
    • A Container with width: 200, height: 200
    • The Container's color should be _colors[_currentIndex]
    • Add child: Center(child: Text('Tap me!')) inside the Container
  5. Wrap the Container in a GestureDetector — when tapped, call setState to increment _currentIndex (use % _colors.length to cycle through colors)

🎯 Expected Output:

A colored square that cycles through red → blue → green → orange → purple → red... each time you tap it.

💡
Hints:
  • Use GestureDetector(onTap: () { setState(() { _currentIndex = (_currentIndex + 1) % _colors.length; }); }, child: Container(...))
  • The % (modulo) operator wraps around — when index reaches 5, it goes back to 0
  • Use BoxDecoration(color: _colors[_currentIndex]) instead of the Container's color property if you want to add border radius

🚀 What's Next?

Your app is now interactive! In the next lesson, you'll learn:

  • Implicit Animations — Make your tile color changes smooth and animated
  • Use AnimatedContainer to automatically animate property changes
  • Add polish and delight to your Birdle game
🎉

Lesson Complete!

You converted your app to use StatefulWidget and setState — the foundation of ALL interactive Flutter apps. Your Birdle game now responds to user guesses with colored tiles!

Click the button above to track your progress!