Agent skill
flutter-widget-patterns
Flutter widget composition patterns — Stateless vs Stateful, widget decomposition, keys, const constructors, BuildContext, InheritedWidget, and Builder
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/flutter-widget-patterns
SKILL.md
Flutter Widget Patterns
Overview
Reference guide for idiomatic Flutter widget design in Dart. Apply these patterns when building, reviewing, or refactoring widgets to ensure correct rebuilds, good performance, and maintainable widget trees.
Stateless vs Stateful Decision Tree
Does the widget...
├── Need to change after initial build?
│ ├── NO → StatelessWidget ✓
│ └── YES → Does the change come from...
│ ├── Parent rebuilding with new props? → StatelessWidget ✓
│ ├── Internal state (counter, toggle, animation)?
│ │ └── YES → StatefulWidget ✓
│ ├── AnimationController or TickerProvider?
│ │ └── YES → StatefulWidget with TickerProviderStateMixin ✓
│ └── Global/shared state (theme, auth, data)?
│ └── Use state management (Provider/Riverpod) ✓
StatelessWidget
class UserAvatar extends StatelessWidget {
const UserAvatar({
super.key,
required this.imageUrl,
this.radius = 24.0,
});
final String imageUrl;
final double radius;
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(imageUrl),
);
}
}
StatefulWidget
class ExpandableCard extends StatefulWidget {
const ExpandableCard({
super.key,
required this.title,
required this.content,
});
final String title;
final Widget content;
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Text(widget.title),
),
if (_isExpanded) widget.content,
],
);
}
}
Widget Decomposition
When to Extract a Widget
Extract a widget when:
- The build method exceeds 50 lines
- A subtree is reused in 2+ places
- A subtree has its own state
- You want to limit the rebuild scope (extracted widgets only rebuild when their inputs change)
Extract as Widget, Not Method
// BAD: Helper method — rebuilds with parent, no independent lifecycle
class MyPage extends StatelessWidget {
Widget _buildHeader(String title) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(title, style: const TextStyle(fontSize: 24)),
);
}
@override
Widget build(BuildContext context) {
return Column(children: [_buildHeader('Hello')]);
}
}
// GOOD: Extracted widget — independent rebuild, can be const
class PageHeader extends StatelessWidget {
const PageHeader({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(title, style: const TextStyle(fontSize: 24)),
);
}
}
Keys
When to Use Each Key Type
Do I need a key?
├── Static list (never reordered/filtered)? → No key needed
├── Dynamic list with unique domain IDs?
│ └── ValueKey(item.id) ✓
├── Dynamic list without unique IDs but with unique field combo?
│ └── ValueKey('${item.name}_${item.date}') ✓
├── Need to preserve state when widget moves in tree?
│ └── GlobalKey (sparingly) ✓
├── Need every instance to be unique?
│ └── UniqueKey() (forces rebuild, rarely needed)
└── Objects with proper == and hashCode?
└── ObjectKey(item) ✓
// GOOD: ValueKey with stable ID
ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return TaskTile(
key: ValueKey(task.id), // Stable across reorders
task: task,
onToggle: () => toggleTask(task.id),
);
},
);
// BAD: Using index as key — breaks when list reorders
key: ValueKey(index) // WRONG for reorderable lists
GlobalKey — Use Sparingly
// Accessing State from outside the widget
final formKey = GlobalKey<FormState>();
Form(
key: formKey,
child: Column(children: [/* fields */]),
);
// Validate from parent
if (formKey.currentState?.validate() ?? false) {
formKey.currentState!.save();
}
Const Constructors for Performance
Mark widgets as const when all fields are compile-time constants. Const widgets are canonicalized — Flutter skips rebuilding them entirely.
// GOOD: const constructor — widget never rebuilds
class AppDivider extends StatelessWidget {
const AppDivider({super.key}); // const constructor
@override
Widget build(BuildContext context) {
return const Divider(height: 1, thickness: 1); // const child too
}
}
// Usage — const at call site
const AppDivider(), // Flutter caches this instance
// When you CAN'T use const
class UserChip extends StatelessWidget {
const UserChip({super.key, required this.name}); // const constructor OK
final String name;
@override
Widget build(BuildContext context) {
return Chip(label: Text(name)); // Not const — name is runtime value
}
}
// But call site can't be const because name varies:
UserChip(name: user.name) // No const prefix
The Const Lint
Enable prefer_const_constructors and prefer_const_declarations in analysis_options.yaml.
BuildContext Usage
Rules
- Never store
BuildContextin state or pass it to async gaps without checkingmounted - Use
contextto read the nearest ancestor:Theme.of(context),MediaQuery.of(context) - After an
await, checkmountedbefore using context
class _MyWidgetState extends State<MyWidget> {
Future<void> _submit() async {
final navigator = Navigator.of(context); // Capture before await
final messenger = ScaffoldMessenger.of(context);
try {
await api.submit(data);
if (!mounted) return; // Check before using context
navigator.pop();
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
}
InheritedWidget Pattern
Use for dependency injection and efficient rebuilds based on data changes.
class AppConfig extends InheritedWidget {
const AppConfig({
super.key,
required this.apiBaseUrl,
required this.featureFlags,
required super.child,
});
final String apiBaseUrl;
final Map<String, bool> featureFlags;
static AppConfig of(BuildContext context) {
final result = context.dependOnInheritedWidgetOfExactType<AppConfig>();
assert(result != null, 'No AppConfig found in context');
return result!;
}
// Optional: static method that doesn't subscribe to changes
static AppConfig? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AppConfig>();
}
@override
bool updateShouldNotify(AppConfig oldWidget) {
return apiBaseUrl != oldWidget.apiBaseUrl ||
featureFlags != oldWidget.featureFlags;
}
}
// Usage
final config = AppConfig.of(context);
if (config.featureFlags['darkMode'] == true) { /* ... */ }
Builder Pattern
Use Builder widgets to get a new BuildContext below a widget you just created.
// Problem: Scaffold.of(context) fails because context is ABOVE the Scaffold
@override
Widget build(BuildContext context) {
return Scaffold(
body: ElevatedButton(
onPressed: () {
// BAD: context is above Scaffold, no ScaffoldMessenger found
ScaffoldMessenger.of(context).showSnackBar(/*...*/);
},
child: const Text('Show Snackbar'),
),
);
}
// Solution: Builder provides a context BELOW the Scaffold
@override
Widget build(BuildContext context) {
return Scaffold(
body: Builder(
builder: (scaffoldContext) {
return ElevatedButton(
onPressed: () {
// GOOD: scaffoldContext is below Scaffold
ScaffoldMessenger.of(scaffoldContext).showSnackBar(/*...*/);
},
child: const Text('Show Snackbar'),
);
},
),
);
}
LayoutBuilder for Responsive Widgets
class ResponsiveGrid extends StatelessWidget {
const ResponsiveGrid({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth > 900 ? 3
: constraints.maxWidth > 600 ? 2
: 1;
return GridView.count(
crossAxisCount: columns,
children: children,
);
},
);
}
}
Anti-patterns
Mega-Build Methods (>50 Lines)
Split into extracted widgets. Long build methods are hard to read and cause unnecessary rebuilds of the entire subtree.
setState in initState
// BAD: setState in initState triggers unnecessary rebuild
@override
void initState() {
super.initState();
setState(() { _value = widget.initialValue; }); // WRONG
}
// GOOD: Initialize directly
@override
void initState() {
super.initState();
_value = widget.initialValue; // No setState needed
}
Missing Const Constructors
Every widget whose fields are all final and whose super constructor is const should have a const constructor. Missing it prevents const instantiation and wastes rebuilds.
Rebuilding Entire Subtrees
// BAD: Entire Column rebuilds when counter changes
class _MyState extends State<MyWidget> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // Rebuilds unnecessarily
Text('$_counter'), // Only this needs to update
const ExpensiveFooter(), // Rebuilds unnecessarily
],
);
}
}
// GOOD: Extract the changing part into its own widget
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // Never rebuilds
const CounterText(), // Only this rebuilds
const ExpensiveFooter(), // Never rebuilds
],
);
}
}
class CounterText extends StatefulWidget {
const CounterText({super.key});
@override
State<CounterText> createState() => _CounterTextState();
}
class _CounterTextState extends State<CounterText> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _counter++),
child: Text('$_counter'),
);
}
}
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?