Brain
EngineeringFlutter

Widgets, State, and Lifecycle

State and setState, keys, BuildContext, the widget and app lifecycles, InheritedWidget, and const constructors.

State

State in Flutter refers to the data or information that can change over time and affects how a widget renders on the screen. When this data changes, Flutter needs to rebuild the UI to reflect those changes.

What counts as state

State is fundamental to creating dynamic and interactive applications. Here's what it encompasses:

  • Data that changes: Variables, user inputs, API responses, or any information that may change during the app's lifecycle
  • UI reactivity: When state changes, related parts of the UI update automatically
  • User interactions: Buttons pressed, form inputs, navigation, and other user actions often trigger state changes
  • Application context: Information about the current status of your application that needs to be maintained

Ephemeral state vs app state

Flutter generally recognizes two main types of state:

  1. Ephemeral (Local) State:

    • Contained within a single widget
    • Doesn't need to be shared with other parts of the app
    • Managed using StatefulWidget and its associated State class
    • Examples: current page in a PageView, animation status, or text field content
  2. App State:

    • Shared across multiple widgets or the entire app
    • Persists for longer durations
    • Typically managed using state management solutions
    • Examples: user authentication data, shopping cart items, or app preferences

Ways to manage state

Bloc, Cubit, and Riverpod are all built on streams, which are covered in streams. How state fits into a layered codebase is in architecture-and-testing.

Flutter offers several approaches to manage state:

  • StatefulWidget: For simple local state management
  • InheritedWidget: For passing data down the widget tree
  • Provider: A wrapper around InheritedWidget that simplifies state management
  • Bloc/Cubit: For reactive state management using streams
  • Riverpod: An improved version of Provider with additional features
  • GetX: A lightweight state management solution
  • Redux: For predictable state management with a unidirectional data flow
  • MobX: For reactive state management


Keys

Keys in Flutter are unique identifiers for widgets that help Flutter maintain and preserve widget state when the widget tree changes or when widgets are moved around.

What a key is

Keys are special objects assigned to widgets that act as unique identifiers. When Flutter rebuilds its widget tree, it uses these keys to identify which widgets should preserve their state and which should be rebuilt from scratch.

Keys are used in Flutter as identifiers for widgets, elements, and semantic nodes. GlobalKeys and LocalKeys are the subclasses of Key. Within the widget tree, keys are responsible for preserving the state of modified widgets. With keys, you can also reorganize and modify collections of widgets that have an equivalent type and defined state. The primary use of keys is to modify a widget tree that contains stateful widgets, not to modify a tree that is totally composed of stateless widgets.

Why keys matter

Keys are used to preserve the state of widgets and to ensure that widgets are properly identified and maintained across rebuilds.

  • Purpose of Keys:
    • Keys help Flutter's framework to differentiate between widgets that might otherwise appear identical. They are especially useful when working with lists or any collection of widgets where the position or order might change.
    • They help maintain the state of stateful widgets when their position in the widget tree changes. This is crucial for maintaining consistent behavior, especially in dynamic lists or collections where the widgets may be reordered or replaced.
  • Types of Keys:
    • ValueKey: A ValueKey uses a value to identify a widget. If the value changes, Flutter considers the widget as different.
    • ObjectKey: An ObjectKey uses an object's identity to determine the widget's uniqueness.
    • UniqueKey: A UniqueKey generates a unique key each time it is created, ensuring that the widget is always treated as unique.
  • Usage:
    • Lists: In a ListView or similar widget, you should use keys when the list items can change position or when items are added or removed. This helps Flutter manage the state of each item correctly.
    • State Management: Keys help preserve the state of widgets when the widget tree is rebuilt. For example, if you have a form with multiple input fields and need to rearrange or update them, using keys ensures that the input fields retain their values and state.

Example:

ListView(
  children: [
    Text('Item 1', key: ValueKey('item1')),
    Text('Item 2', key: ValueKey('item2')),
    // more items...
  ],
);

Here, ValueKey is used to uniquely identify each item, ensuring that the framework can keep track of them correctly.

Types of key

  1. ValueKey: A key that uses a value, such as a string or number, for identification.
ValueKey<String>("unique-id")
  1. ObjectKey: Uses an object's identity for uniqueness.
ObjectKey(myObject)
  1. UniqueKey: Generates a unique identifier each time it's created.
UniqueKey()
  1. GlobalKey: A key that's unique across the entire app (not just within a parent).
GlobalKey<FormState>()
  1. PageStorageKey: Used to preserve scroll position when a widget is removed and later added back.
PageStorageKey<String>("list-key")

When to use keys

Keys are particularly important in the following scenarios:

  1. When reordering widgets in a list: Without keys, Flutter might not correctly preserve state when items change order.

  2. When stateful widgets are moved in the tree: Keys help Flutter keep track of which state belongs to which widget.

  3. When widgets of the same type appear and disappear: Keys help Flutter identify which widgets are new and which have reappeared.

  4. When using GlobalKey: To access a widget's state from anywhere in the app.

Best practices

  1. Use keys sparingly, they add overhead.
  2. When using keys in lists, use something unique to the item (like an ID) as the key value.
  3. Choose the most appropriate key type for your use case.
  4. Only use GlobalKeys when absolutely necessary as they're more expensive.

Keys are an essential tool for ensuring your Flutter app behaves correctly when its widget structure changes, helping to maintain state across rebuilds and making your UI more predictable.


BuildContext and lifecycle

BuildContext is a widget's handle on its own position in the tree. The lifecycle methods are the callbacks that fire as that position is created, updated, and destroyed.

BuildContext

What BuildContext is

BuildContext is a reference to the location of a widget in the widget tree. Every widget receives its own BuildContext when it's built, and this context contains important information about the widget's position in the tree and provides access to various services and inherited widgets.

Key features

  1. Widget Location: Represents the widget's position in the widget tree
  2. Access to Inherited Widgets: Allows widgets to access data from ancestor widgets
  3. Service Locator: Provides access to services like theme data, media queries, and navigation
  4. Widget Identification: Helps Flutter identify which widget needs to be rebuilt when state changes

Common uses

// Accessing Theme data
final theme = Theme.of(context);
final primaryColor = theme.primaryColor;

// Accessing Screen dimensions
final screenSize = MediaQuery.of(context).size;
final screenWidth = screenSize.width;

// Navigation
Navigator.of(context).push(
  MaterialPageRoute(builder: (context) => SecondScreen()),
);

// Accessing inherited widgets like Provider
final userModel = Provider.of<UserModel>(context);

Things to watch out for

  1. Context Validity: A context is only valid as long as its widget is in the tree
  2. Async Operations: Never use context after an asynchronous gap unless you check if it's still mounted
  3. Context Dependency: Some Flutter widgets require a specific ancestor widget to be present in the tree

The lifecycles

Flutter apps have multiple layers of lifecycle management:

App lifecycle

The AppLifecycleState enum represents the application's current state on the device:

  • resumed: App is visible and responding to user input
  • inactive: App is not receiving user input (iOS only when app is in the foreground but not receiving events)
  • paused: App is not visible, not responding to user input, but running in the background
  • detached: App is in a suspended state (neither launched nor resumed)

You can listen to these changes using the WidgetsBindingObserver:

class MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        print("App is resumed and visible");
        break;
      case AppLifecycleState.inactive:
        print("App is inactive");
        break;
      case AppLifecycleState.paused:
        print("App is in background");
        break;
      case AppLifecycleState.detached:
        print("App is detached");
        break;
    }
  }
}

StatefulWidget lifecycle

StatefulWidgets have their own lifecycle methods that are called at different stages:

  1. createState():

    • Called when the StatefulWidget is inserted into the tree
    • Returns a State object for the widget
  2. initState():

    • Called once when the State object is created
    • Used for one-time initializations
    • Always call super.initState() first
  3. didChangeDependencies():

    • Called immediately after initState()
    • Called whenever the widget's dependencies change
    • Called when an InheritedWidget that this widget depends on changes
  4. build():

    • Called whenever the widget needs to be rebuilt
    • Returns the widget tree that represents the UI
    • Called after initState(), didChangeDependencies(), setState(), and didUpdateWidget()
  5. didUpdateWidget(oldWidget):

    • Called when the parent widget changes and this widget needs to be rebuilt
    • The oldWidget parameter contains the previous widget
    • Use to compare with widget to respond to changes
  6. setState():

    • Not a lifecycle method per se, but triggers a rebuild
    • Notifies the framework that the widget's state has changed
  7. deactivate():

    • Called when the State is removed from the tree, but might be reinserted
    • Happens during page navigation or when a widget is moved in the tree
  8. dispose():

    • Called when the State is removed from the tree permanently
    • Used for cleanup (canceling timers, closing streams, etc.)
    • Always call super.dispose() last
StatefulWidget
    ↓ createState()
State object created
    ↓ initState()
State initialized
    ↓ didChangeDependencies()
Dependencies updated
    ↓ build()
Widget builds UI
    ↓ setState() [multiple times]
State updates → rebuild() called
    ↓ deactivate()
State removed from tree temporarily
    ↓ dispose()
State destroyed, resources freed
class MyWidgetState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    print("Widget initialized");
    // Initialize controllers, listeners, etc.
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print("Dependencies changed");
    // Access inherited widgets safely here
  }

  @override
  Widget build(BuildContext context) {
    print("Widget built");
    return Container(); // Your widget UI
  }

  @override
  void didUpdateWidget(MyWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    print("Widget updated");
    if (widget.someProperty != oldWidget.someProperty) {
      // Handle property change
    }
  }

  @override
  void deactivate() {
    print("Widget deactivated");
    super.deactivate();
  }

  @override
  void dispose() {
    print("Widget disposed");
    // Clean up resources
    super.dispose();
  }
}

Route lifecycle

Flutter's navigation system also has lifecycle hooks for pages:

  • didPush: Called when a new route is pushed
  • didPop: Called when a route is popped
  • didPushNext: Called when a new route is pushed on top
  • didPopNext: Called when a route above is popped

Putting the lifecycles to use

  • Resource Management: Initialize resources in initState(), clean up in dispose()
  • Network Operations: Pause network operations when the app goes to background
  • User Session Management: Track user active status based on app lifecycle
  • Data Persistence: Save data when the app is paused or inactive
  • UI Updates: Refresh data when the app returns to the foreground

Understanding these lifecycle methods is crucial for building efficient Flutter applications that properly manage resources and respond to system events.


InheritedWidget

InheritedWidget shares data down a widget subtree without passing it through every constructor. Provider and most other state management packages are built on it.

Core concept

InheritedWidget is a special type of widget in Flutter's framework that allows descendant widgets to efficiently access data provided by an ancestor widget without explicitly passing it through each level of the widget tree.

Key features

  1. Data Propagation Down the Tree: Provides data to all descendants without manual passing through constructors
  2. Rebuild Optimization: Only widgets that explicitly depend on the data get rebuilt when it changes
  3. Context-Based Access: Data can be accessed using the BuildContext
  4. Dependency Tracking: Flutter tracks which widgets depend on which InheritedWidgets

Basic implementation

Here's how to create a custom InheritedWidget:

class MyInheritedData extends InheritedWidget {
  final int data;
  final Widget child;

  const MyInheritedData({
    Key? key,
    required this.data,
    required this.child,
  }) : super(key: key, child: child);

  static MyInheritedData? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<MyInheritedData>();
  }

  @override
  bool updateShouldNotify(MyInheritedData oldWidget) {
    return data != oldWidget.data;
  }
}

Core methods

  1. dependOnInheritedWidgetOfExactType: Establishes a dependency relationship that triggers rebuilds when data changes

    final myData = context.dependOnInheritedWidgetOfExactType<MyInheritedData>();
  2. getElementForInheritedWidgetOfExactType: Retrieves the data without establishing a dependency (no automatic rebuilds)

    final myDataElement = context.getElementForInheritedWidgetOfExactType<MyInheritedData>();
    final myData = myDataElement?.widget as MyInheritedData;
  3. findAncestorWidgetOfExactType: Alternative method to find widgets without dependency tracking

    final myData = context.findAncestorWidgetOfExactType<MyInheritedData>();

updateShouldNotify

This method determines when dependent widgets should rebuild:

@override
bool updateShouldNotify(MyInheritedData oldWidget) {
  // Compare old and new values to determine if dependents should rebuild
  return data != oldWidget.data;
}

Common usage pattern

The standard pattern involves three components:

  1. Data Class: Holds the actual data and possibly methods to manipulate it
  2. InheritedWidget: Provides access to the data through the widget tree
  3. State Management Widget: Typically a StatefulWidget that manages the data and rebuilds the InheritedWidget when needed

A generic InheritedWidget

For more flexible use cases, you can create a generic InheritedWidget:

class DataProvider<T> extends InheritedWidget {
  final T data;

  const DataProvider({
    Key? key,
    required this.data,
    required Widget child,
  }) : super(key: key, child: child);

  static T of<T>(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<DataProvider<T>>();
    assert(result != null, 'No DataProvider<$T> found in context');
    return result!.data;
  }

  @override
  bool updateShouldNotify(DataProvider<T> oldWidget) {
    return data != oldWidget.data;
  }
}

InheritedModel for finer grained dependencies

InheritedModel is a specialized version of InheritedWidget that allows for more selective rebuilds:

class UserModel extends InheritedModel<String> {
  final String name;
  final int age;

  const UserModel({
    Key? key,
    required this.name,
    required this.age,
    required Widget child,
  }) : super(key: key, child: child);

  static UserModel of(BuildContext context, {String? aspect}) {
    return InheritedModel.inheritFrom<UserModel>(context, aspect: aspect)!;
  }

  @override
  bool updateShouldNotify(UserModel oldWidget) {
    return name != oldWidget.name || age != oldWidget.age;
  }

  @override
  bool updateShouldNotifyDependent(UserModel oldWidget, Set<String> dependencies) {
    return (dependencies.contains('name') && name != oldWidget.name) ||
           (dependencies.contains('age') && age != oldWidget.age);
  }
}

// Usage:
// For name-dependent widgets:
final userName = UserModel.of(context, aspect: 'name').name;

// For age-dependent widgets:
final userAge = UserModel.of(context, aspect: 'age').age;

InheritedWidget vs Provider

InheritedWidget is the foundation upon which Provider and other state management solutions are built. Provider adds:

  1. Simplified API: Easier to use than raw InheritedWidget
  2. Disposal Logic: Automatic cleanup of resources
  3. Dependency Injection: More structured approach to providing dependencies
  4. Multiple Providers: Easy composition of multiple data sources
  5. Specialized Providers: Variants like ChangeNotifierProvider, StreamProvider, etc.

Performance considerations

  1. Minimize InheritedWidget Data: Keep data small and focused
  2. Be Selective with Dependencies: Only establish dependencies when needed
  3. Proper Tree Structure: Place InheritedWidgets at appropriate levels in the tree
  4. Immutable Data: Consider using immutable data structures

Common pitfalls

  1. Missing Assert Checks: Always verify the widget is found in context
  2. Overly Broad Updates: Improperly implemented updateShouldNotify can cause unnecessary rebuilds
  3. Misused Dependency Tracking: Using methods that don't track dependencies when you need rebuilds
  4. Overly Large InheritedWidgets: Putting too much data in a single InheritedWidget

Writing an InheritedWidget by hand is worth doing once, to see what Provider is doing for you. After that, reach for the package unless you need something it does not cover.


Why Container can't always be const

The Container widget in Flutter often cannot be declared with the const keyword due to several important reasons:

Non-const constructors in children

If a Container contains child widgets with non-const constructors, the entire widget cannot be const. For example:

// This won't work
const Container(
  child: DateTime(2023, 3, 15), // DateTime constructor is not const
)

Runtime determined properties

Container often uses properties whose values are determined at runtime:

// This won't work
const Container(
  width: MediaQuery.of(context).size.width * 0.5, // Runtime value
)

Default behaviour and implicit constraints

Even when not explicitly specified, Container often adapts its size and layout based on:

  • Parent constraints
  • Child size requirements
  • Default alignment behavior

These runtime calculations make it non-const.

Complex internal structure

The Container widget is a convenience widget that combines multiple other widgets:

  • Padding
  • Alignment
  • DecoratedBox
  • ConstrainedBox
  • Transform

If any of these internal widgets cannot be const, the entire Container cannot be const.

Decoration objects

Many decorations used with Container don't have const constructors:

// This won't work
const Container(
  decoration: BoxDecoration(
    gradient: LinearGradient(...), // Not const constructable
  ),
)

When Container can be const

A Container can be const when:

  1. All its properties have const values
  2. Its child (if any) is also const
  3. It doesn't use any runtime-dependent values

Example of a valid const Container:

const Container(
  width: 100,
  height: 100,
  color: Colors.blue,
  child: const Text('Hello'),
)

Use SizedBox instead

If you're mainly concerned with sizing, SizedBox is often a better choice when you need a const widget:

const SizedBox(
  width: 100,
  height: 100,
  child: const Text('Hello'),
)

The SizedBox widget is simpler, more focused, and more likely to be usable with the const constructor.

On this page