Flutter (10): State Management

Time: Column:Mobile & Frontend views:234

10.1 Introduction

In reactive programming frameworks, "state management" is an eternal topic. Whether it's in React/Vue (both are web development frameworks that support reactive programming) or Flutter, the problems discussed and the solutions are consistent. So, if you're familiar with state management in React/Vue, you can skip this section. Let's get back to the main topic: who should manage the state of a StatefulWidget? The Widget itself? Its parent Widget? Both? Or another object? The answer is: it depends! Here are the most common methods of managing state:

  • The Widget manages its own state.

  • The Widget's parent manages its state.

  • Mixed management (both parent and child Widgets manage the state).

How do you decide which method to use? Here are some official guidelines to help you decide:

  1. If the state is user data, like the selected state of a checkbox or the position of a slider, it's best managed by the parent Widget.

  2. If the state is related to the appearance of the UI, such as color or animations, it's best managed by the Widget itself.

  3. If a state is shared among different Widgets, it's best managed by their common parent Widget.

Managing the state within a Widget offers better encapsulation, while managing it in the parent Widget provides more flexibility. Sometimes, if you're unsure about how to manage the state, it's recommended to manage it in the parent Widget (since flexibility is often more important).

Next, we will explain the different methods of state management by creating three simple examples: TapboxA, TapboxB, and TapboxC. These examples have similar functionality—creating a box that changes its background color between green and gray when clicked. The state _active determines the color: green when true and gray when false, as shown in Figure:

2-8.8e70e140.png

In the following examples, we will use GestureDetector to detect tap events. We will cover the details of GestureDetector in the chapter on "Event Handling."


10.2 Widget Manages Its Own State

We will create TapboxA, where its corresponding class _TapboxAState:

  • Manages the state of TapboxA.

  • Defines _active, a boolean value that determines the current color of the box.

  • Defines a function _handleTap(), which updates _active when the box is tapped and calls setState() to update the UI.

  • Implements all interactive behavior of the widget.

// TapboxA manages its own state.

//------------------------- TapboxA ----------------------------------

class TapboxA extends StatefulWidget {
  TapboxA({Key? key}) : super(key: key);

  @override
  _TapboxAState createState() => _TapboxAState();
}

class _TapboxAState extends State<TapboxA> {
  bool _active = false;

  void _handleTap() {
    setState(() {
      _active = !_active;
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        child: Center(
          child: Text(
            _active ? 'Active' : 'Inactive',
            style: TextStyle(fontSize: 32.0, color: Colors.white),
          ),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: _active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
      ),
    );
  }
}

10.3 Parent Widget Manages the Child Widget's State

For the parent Widget, managing the state and telling its child Widget when to update is often a good practice. For example, IconButton is a stateless Widget because we assume the parent Widget should know if the button was pressed and respond accordingly.

In the following example, TapboxB passes its state to its parent through a callback, and the state is managed by the parent component, which is a StatefulWidget. Since TapboxB doesn't manage any state, it is a StatelessWidget.

ParentWidgetState class:

  • Manages the _active state for TapboxB.

  • Implements _handleTapboxChanged(), the method called when the box is tapped.

  • Calls setState() to update the UI when the state changes.

TapboxB class:

  • Extends StatelessWidget because all the state is managed by its parent.

  • Notifies the parent when a tap is detected.

// ParentWidget manages the state for TapboxB.

//------------------------ ParentWidget --------------------------------

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _active = false;

  void _handleTapboxChanged(bool newValue) {
    setState(() {
      _active = newValue;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: TapboxB(
        active: _active,
        onChanged: _handleTapboxChanged,
      ),
    );
  }
}

//------------------------- TapboxB ----------------------------------

class TapboxB extends StatelessWidget {
  TapboxB({Key? key, this.active: false, required this.onChanged})
      : super(key: key);

  final bool active;
  final ValueChanged<bool> onChanged;

  void _handleTap() {
    onChanged(!active);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        child: Center(
          child: Text(
            active ? 'Active' : 'Inactive',
            style: TextStyle(fontSize: 32.0, color: Colors.white),
          ),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
      ),
    );
  }
}

10.4 Mixed State Management

In some cases, mixed management can be useful. In this scenario, the Widget manages some internal state, while the parent Widget manages other external states.

In the following TapboxC example, when the finger is pressed down, a dark green border appears around the box, and when the finger is lifted, the border disappears. After the tap is completed, the box's color changes. TapboxC exports its _active state to its parent, but manages its own _highlight state. This example has two state objects: _ParentWidgetStateC and _TapboxCState.

_ParentWidgetStateC class:

  • Manages the _active state.

  • Implements _handleTapboxChanged(), called when the box is tapped.

  • Calls setState() to update the UI when the box is tapped and the _active state changes.

_TapboxCState object:

  • Manages the _highlight state.

  • GestureDetector listens to all tap events. When the user taps down, it adds a highlight (dark green border); when the user releases, the highlight is removed.

  • Updates the _highlight state when pressed down, lifted, or canceled, and calls setState() to update the UI.

  • Passes the state change to the parent when the box is tapped.

//---------------------------- ParentWidget ----------------------------

class ParentWidgetC extends StatefulWidget {
  @override
  _ParentWidgetCState createState() => _ParentWidgetCState();
}

class _ParentWidgetCState extends State<ParentWidgetC> {
  bool _active = false;

  void _handleTapboxChanged(bool newValue) {
    setState(() {
      _active = newValue;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: TapboxC(
        active: _active,
        onChanged: _handleTapboxChanged,
      ),
    );
  }
}

//----------------------------- TapboxC ------------------------------

class TapboxC extends StatefulWidget {
  TapboxC({Key? key, this.active: false, required this.onChanged})
      : super(key: key);

  final bool active;
  final ValueChanged<bool> onChanged;
  
  @override
  _TapboxCState createState() => _TapboxCState();
}

class _TapboxCState extends State<TapboxC> {
  bool _highlight = false;

  void _handleTapDown(TapDownDetails details) {
    setState(() {
      _highlight = true;
    });
  }

  void _handleTapUp(TapUpDetails details) {
    setState(() {
      _highlight = false;
    });
  }

  void _handleTapCancel() {
    setState(() {
      _highlight = false;
    });
  }

  void _handleTap() {
    widget.onChanged(!widget.active);
  }

  @override
  Widget build(BuildContext context) {
    // Add a green border when pressed down, and remove it when released  
    return GestureDetector(
      onTapDown: _handleTapDown, // Handle press down
      onTapUp: _handleTapUp, // Handle release
      onTap: _handleTap,
      onTapCancel: _handleTapCancel,
      child: Container(
        child: Center(
          child: Text(
            widget.active ? 'Active' : 'Inactive',
            style: TextStyle(fontSize: 32.0, color: Colors.white),
          ),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: widget.active ? Colors.lightGreen[700] : Colors.grey[600],
          border: _highlight
              ? Border.all(
                  color: Colors.teal[700],
                  width: 10.0,
                )
              : null,
        ),
      ),
    );
  }
}

An alternative implementation could export the highlight state to the parent component while keeping the _active state as internal. However, if you're going to provide this TapBox to others, it may not make much sense. Developers usually care about whether the box is in an "Active" state and may not care how the highlight is managed. Therefore, TapBox should handle these details internally.


10.5 Global State Management

When an application requires syncing state across components (including across routes), the methods mentioned above become difficult to manage. For example, if we have a settings page where we can set the language of the application, we would expect that when the language state changes, all components relying on the app's language rebuild themselves. However, these components that rely on the app's language are not located with the settings page, making it difficult to manage this with the above methods. In such cases, the correct approach is to handle communication between distant components using a global state manager. Currently, there are two main methods:

  1. Implement a global event bus that maps the language state change to an event. Then, in the initState method of components that rely on the app's language, subscribe to this language change event. When the user switches languages in the settings page, we trigger the language change event, and subscribed components will receive the notification. After receiving the notification, they can call setState(...) to rebuild themselves.

  2. Use some state management packages, such as Provider or Redux. You can check out their detailed information on pub.