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:
-
Ephemeral (Local) State:
- Contained within a single widget
- Doesn't need to be shared with other parts of the app
- Managed using
StatefulWidgetand its associatedStateclass - Examples: current page in a PageView, animation status, or text field content
-
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.
- ValueKey:Â AÂ
- 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.
- Lists:Â In aÂ
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
- ValueKey: A key that uses a value, such as a string or number, for identification.
ValueKey<String>("unique-id")- ObjectKey: Uses an object's identity for uniqueness.
ObjectKey(myObject)- UniqueKey: Generates a unique identifier each time it's created.
UniqueKey()- GlobalKey: A key that's unique across the entire app (not just within a parent).
GlobalKey<FormState>()- 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:
-
When reordering widgets in a list: Without keys, Flutter might not correctly preserve state when items change order.
-
When stateful widgets are moved in the tree: Keys help Flutter keep track of which state belongs to which widget.
-
When widgets of the same type appear and disappear: Keys help Flutter identify which widgets are new and which have reappeared.
-
When using GlobalKey: To access a widget's state from anywhere in the app.
Best practices
- Use keys sparingly, they add overhead.
- When using keys in lists, use something unique to the item (like an ID) as the key value.
- Choose the most appropriate key type for your use case.
- 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
- Widget Location: Represents the widget's position in the widget tree
- Access to Inherited Widgets: Allows widgets to access data from ancestor widgets
- Service Locator: Provides access to services like theme data, media queries, and navigation
- 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
- Context Validity: A context is only valid as long as its widget is in the tree
- Async Operations: Never use
contextafter an asynchronous gap unless you check if it's still mounted - 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:
-
createState():
- Called when the StatefulWidget is inserted into the tree
- Returns a State object for the widget
-
initState():
- Called once when the State object is created
- Used for one-time initializations
- Always call
super.initState()first
-
didChangeDependencies():
- Called immediately after
initState() - Called whenever the widget's dependencies change
- Called when an
InheritedWidgetthat this widget depends on changes
- Called immediately after
-
build():
- Called whenever the widget needs to be rebuilt
- Returns the widget tree that represents the UI
- Called after
initState(),didChangeDependencies(),setState(), anddidUpdateWidget()
-
didUpdateWidget(oldWidget):
- Called when the parent widget changes and this widget needs to be rebuilt
- The
oldWidgetparameter contains the previous widget - Use to compare with
widgetto respond to changes
-
setState():
- Not a lifecycle method per se, but triggers a rebuild
- Notifies the framework that the widget's state has changed
-
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
-
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 freedclass 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 indispose() - 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
- Data Propagation Down the Tree: Provides data to all descendants without manual passing through constructors
- Rebuild Optimization: Only widgets that explicitly depend on the data get rebuilt when it changes
- Context-Based Access: Data can be accessed using the BuildContext
- 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
-
dependOnInheritedWidgetOfExactType: Establishes a dependency relationship that triggers rebuilds when data changes
final myData = context.dependOnInheritedWidgetOfExactType<MyInheritedData>(); -
getElementForInheritedWidgetOfExactType: Retrieves the data without establishing a dependency (no automatic rebuilds)
final myDataElement = context.getElementForInheritedWidgetOfExactType<MyInheritedData>(); final myData = myDataElement?.widget as MyInheritedData; -
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:
- Data Class: Holds the actual data and possibly methods to manipulate it
- InheritedWidget: Provides access to the data through the widget tree
- 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:
- Simplified API: Easier to use than raw InheritedWidget
- Disposal Logic: Automatic cleanup of resources
- Dependency Injection: More structured approach to providing dependencies
- Multiple Providers: Easy composition of multiple data sources
- Specialized Providers: Variants like ChangeNotifierProvider, StreamProvider, etc.
Performance considerations
- Minimize InheritedWidget Data: Keep data small and focused
- Be Selective with Dependencies: Only establish dependencies when needed
- Proper Tree Structure: Place InheritedWidgets at appropriate levels in the tree
- Immutable Data: Consider using immutable data structures
Common pitfalls
- Missing Assert Checks: Always verify the widget is found in context
- Overly Broad Updates: Improperly implemented
updateShouldNotifycan cause unnecessary rebuilds - Misused Dependency Tracking: Using methods that don't track dependencies when you need rebuilds
- 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:
PaddingAlignmentDecoratedBoxConstrainedBoxTransform
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:
- All its properties have const values
- Its child (if any) is also const
- 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.