Local State
Understand how to manage local state in Flutter applications.
What is it?
Local state refers to data that is specific to a single widget and does not need to be shared with other parts of the application. It is typically managed within a StatefulWidget using setState(). Local state is ideal for simple UI state like toggles, form inputs, animations, and any data that only affects a small part of the UI.
Why does it exist?
Local state exists to:
- Manage widget-specific data
- Handle simple UI state
- Enable local updates without affecting the whole app
- Keep state management simple and contained
- Improve performance by limiting rebuild scope
- Support interactive UI elements
- Maintain component encapsulation
Basic Local State
Managing state within a single widget.
// Basic local state with StatefulWidget
class LocalStateExample extends StatefulWidget {
const LocalStateExample({super.key});
@override
State<LocalStateExample> createState() => _LocalStateExampleState();
}
class _LocalStateExampleState extends State<LocalStateExample> {
// Local state variables
int _counter = 0;
String _text = 'Hello';
bool _isToggled = false;
// Methods that update state
void _incrementCounter() {
setState(() {
_counter++;
});
}
void _updateText(String newText) {
setState(() {
_text = newText;
});
}
void _toggleSwitch() {
setState(() {
_isToggled = !_isToggled;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Local State'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Counter
Card(
child: ListTile(
title: const Text('Counter'),
subtitle: Text('Value: $_counter'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
setState(() {
_counter--;
});
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _incrementCounter,
),
],
),
),
),
// Text input
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Text Input'),
const SizedBox(height: 8),
TextField(
onChanged: _updateText,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter text',
),
),
const SizedBox(height: 8),
Text('Current text: $_text'),
],
),
),
),
// Toggle
Card(
child: SwitchListTile(
title: const Text('Toggle'),
subtitle: Text(_isToggled ? 'ON' : 'OFF'),
value: _isToggled,
onChanged: (value) {
setState(() {
_isToggled = value;
});
},
),
),
],
),
),
);
}
}
What's happening here? - State variables in State class - setState() triggers rebuild - Each interaction updates local state - UI reflects state changes
Form State
Managing form inputs and validation.
// Form state management
class FormStateExample extends StatefulWidget {
const FormStateExample({super.key});
@override
State<FormStateExample> createState() => _FormStateExampleState();
}
class _FormStateExampleState extends State<FormStateExample> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
// Form state
String _name = '';
String _email = '';
String _password = '';
bool _isSubmitting = false;
String? _submissionResult;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
setState(() {
_isSubmitting = true;
_submissionResult = null;
});
// Simulate submission
Future.delayed(const Duration(seconds: 2), () {
setState(() {
_isSubmitting = false;
_submissionResult = 'Form submitted successfully!';
});
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Form State'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
// Name field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your name';
}
return null;
},
onChanged: (value) {
setState(() {
_name = value;
});
},
),
const SizedBox(height: 16),
// Email field
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
onChanged: (value) {
setState(() {
_email = value;
});
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
onChanged: (value) {
setState(() {
_password = value;
});
},
),
const SizedBox(height: 24),
// Submit button with loading state
_isSubmitting
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
child: const Text('Submit'),
),
if (_submissionResult != null) ...[
const SizedBox(height: 16),
Text(
_submissionResult!,
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(height: 16),
Text('Name: $_name'),
Text('Email: $_email'),
Text('Password: ${'*' * _password.length}'),
],
),
),
),
);
}
}
What's happening here? - TextEditingController for form fields - Form validation with validator - Loading state during submission - Form data displayed in UI
Animation State
Managing animation state locally.
// Animation state management
class AnimationStateExample extends StatefulWidget {
const AnimationStateExample({super.key});
@override
State<AnimationStateExample> createState() => _AnimationStateExampleState();
}
class _AnimationStateExampleState extends State<AnimationStateExample>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggleExpansion() {
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Animation State'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animated container
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 200 * (1 + _animation.value * 0.5),
height: 200 * (1 + _animation.value * 0.5),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10 * _animation.value,
spreadRadius: 5 * _animation.value,
),
],
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.star,
size: 50 + 30 * _animation.value,
color: Colors.white,
),
const SizedBox(height: 8),
Text(
_isExpanded ? 'Expanded!' : 'Tap to expand',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
},
),
const SizedBox(height: 24),
// Toggle button
ElevatedButton(
onPressed: _toggleExpansion,
child: Text(_isExpanded ? 'Collapse' : 'Expand'),
),
const SizedBox(height: 16),
Text('State: ${_isExpanded ? 'Expanded' : 'Collapsed'}'),
Text('Animation: ${_animation.value.toStringAsFixed(2)}'),
],
),
),
);
}
}
What's happening here? - AnimationController manages animation - Animation values drive UI changes - State tracks expansion status - AnimatedBuilder rebuilds on animation
Complex Local State
Managing complex local state.
// Complex local state management
class TodoListStateExample extends StatefulWidget {
const TodoListStateExample({super.key});
@override
State<TodoListStateExample> createState() => _TodoListStateExampleState();
}
class _TodoListStateExampleState extends State<TodoListStateExample> {
// Complex state
List<Todo> _todos = [];
String _newTodoText = '';
int _nextId = 1;
String _filter = 'all';
// Filtered todos
List<Todo> get _filteredTodos {
switch (_filter) {
case 'active':
return _todos.where((todo) => !todo.isCompleted).toList();
case 'completed':
return _todos.where((todo) => todo.isCompleted).toList();
default:
return _todos;
}
}
void _addTodo() {
if (_newTodoText.trim().isEmpty) return;
setState(() {
_todos.add(Todo(
id: _nextId++,
title: _newTodoText.trim(),
isCompleted: false,
));
_newTodoText = '';
});
}
void _toggleTodo(int id) {
setState(() {
final todo = _todos.firstWhere((todo) => todo.id == id);
todo.isCompleted = !todo.isCompleted;
});
}
void _deleteTodo(int id) {
setState(() {
_todos.removeWhere((todo) => todo.id == id);
});
}
void _clearCompleted() {
setState(() {
_todos.removeWhere((todo) => todo.isCompleted);
});
}
int get _activeCount => _todos.where((todo) => !todo.isCompleted).length;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Todo List'),
actions: [
if (_todos.any((todo) => todo.isCompleted))
TextButton(
onPressed: _clearCompleted,
child: const Text(
'Clear Completed',
style: TextStyle(color: Colors.white),
),
),
],
),
body: Column(
children: [
// Add todo input
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
hintText: 'Add a new todo...',
border: OutlineInputBorder(),
),
onChanged: (value) {
setState(() {
_newTodoText = value;
});
},
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _addTodo,
child: const Text('Add'),
),
],
),
),
// Filter tabs
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFilterChip('All', 'all'),
_buildFilterChip('Active', 'active'),
_buildFilterChip('Completed', 'completed'),
],
),
// Stats
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Total: ${_todos.length}'),
Text('Active: $_activeCount'),
],
),
),
// Todo list
Expanded(
child: ListView.builder(
itemCount: _filteredTodos.length,
itemBuilder: (context, index) {
final todo = _filteredTodos[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => _toggleTodo(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
color: todo.isCompleted ? Colors.grey : null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _deleteTodo(todo.id),
),
);
},
),
),
],
),
);
}
Widget _buildFilterChip(String label, String value) {
return Padding(
padding: const EdgeInsets.all(4),
child: ActionChip(
label: Text(label),
onPressed: () {
setState(() {
_filter = value;
});
},
backgroundColor: _filter == value ? Colors.blue : Colors.grey[200],
labelStyle: TextStyle(
color: _filter == value ? Colors.white : Colors.black,
),
),
);
}
}
class Todo {
final int id;
final String title;
bool isCompleted;
Todo({
required this.id,
required this.title,
this.isCompleted = false,
});
}
What's happening here? - Complex state management - Multiple state variables - Computed properties - State operations with setState
Best Practices
Keep State Minimal
// Good - Minimal state
class GoodWidget extends StatefulWidget {
@override
State<GoodWidget> createState() => _GoodWidgetState();
}
class _GoodWidgetState extends State<GoodWidget> {
int _counter = 0; // Only what's needed
@override
Widget build(BuildContext context) {
return Text('$_counter');
}
}
// Bad - Too much state
class BadWidget extends StatefulWidget {
@override
State<BadWidget> createState() => _BadWidgetState();
}
class _BadWidgetState extends State<BadWidget> {
int _counter = 0;
String _temp = '';
bool _flag1 = false;
bool _flag2 = false;
// Unnecessary state
}
Use setState Efficiently
// Good - Only update what changed
@override
Widget build(BuildContext context) {
return Column(
children: [
CounterWidget(counter: _counter),
const StaticWidget(), // Won't rebuild
],
);
}
// Bad - Rebuilding everything
@override
Widget build(BuildContext context) {
return Column(
children: [
CounterWidget(counter: _counter),
StaticWidget(), // Rebuilds unnecessarily
],
);
}
Use const Constructors
// Good - const constructor
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return const Text('Hello');
}
}
// Bad - Missing const
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return Text('Hello'); // Rebuilds unnecessarily
}
}
Common Mistakes
Mutating State Without setState
Wrong:
// No setState - UI won't update
void _increment() {
_counter++;
}
Correct:
// With setState
void _increment() {
setState(() {
_counter++;
});
}
Heavy Computation in setState
Wrong:
// Heavy work inside setState
void _update() {
setState(() {
_data = heavyComputation(); // Slow
});
}
Correct:
// Work outside setState
void _update() {
final result = heavyComputation();
setState(() {
_data = result;
});
}
Summary
Local state manages data within a single widget using StatefulWidget and setState(). It's ideal for simple UI state, form inputs, animations, and component-specific data. Keep state minimal, use setState efficiently, and consider lifting state up when sharing is needed.
Next Steps
Did You Know?
- Local state is managed in StatefulWidget
- setState triggers rebuild
- Local state is not shared with other widgets
- State is encapsulated in the widget
- State persists across rebuilds
- Local state is good for UI-specific data
- State can be lifted to parent widgets
- Use const widgets for performance