Custom Widgets
Building reusable, composable widgets in Flutter — the foundation of every great Flutter app.
Custom widgets
are the building blocks of Flutter applications. While Flutter
provides a rich set of built-in widgets (like
Container
,
Row
,
Column
,
Text
, etc.), you'll quickly find that you need to create
your own widgets to encapsulate reusable UI components and business logic.
💡 Key Concept
In Flutter, everything is a widget . Custom widgets allow you to compose existing widgets into new, reusable components. This is the core philosophy of Flutter — building UIs by composing widgets.
Understanding when to use StatelessWidget vs StatefulWidget is crucial for building efficient Flutter apps.
StatelessWidget
- Immutable — properties can't change
- Build method runs once unless parent rebuilds
- Perfect for presentation-only widgets
- More performant
-
Example:
Text,Icon,Container
StatefulWidget
- Mutable — can change over time
- Has a separate State object
-
Can call
setState()to rebuild - More complex lifecycle
-
Example:
Checkbox,TextField, animations
class
CustomButton
extends
StatelessWidget {
final
String text;
final
VoidCallback onPressed;
final
Color color;
CustomButton
({
required
this
.text,
required
this
.onPressed,
this
.color = Colors.blue,
});
@override
Widget
build
(BuildContext context) {
return
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: color),
onPressed: onPressed,
child: Text(text),
);
}
}
class
CounterWidget
extends
StatefulWidget {
final
String label;
CounterWidget
({
required
this
.label});
@override
_CounterWidgetState
createState
() => _CounterWidgetState();
}
class
_CounterWidgetState
extends
State<CounterWidget> {
int
_count =
0
;
void
_increment
() {
setState
(() {
_count++;
});
}
@override
Widget
build
(BuildContext context) {
return
Column(
children: [
Text(
'${widget.label}: $_count'
),
ElevatedButton(
onPressed: _increment,
child: Text(
'Increment'
),
),
],
);
}
}
✅ Best Practice: Prefer Stateless
Always start with a StatelessWidget . Only switch to StatefulWidget when you need to manage local state that changes over time. This keeps your code simpler and more performant.
In Flutter, composition is preferred over inheritance. Instead of extending a widget to customize it, you compose it with other widgets.
// DON'T extend a widget to customize it
class
MyCustomButton
extends
ElevatedButton {
MyCustomButton
({
required
VoidCallback onPressed,
required
Widget child,
}) :
super
(
onPressed: onPressed,
child: child,
);
}
// Compose widgets to build new ones
class
MyCustomButton
extends
StatelessWidget {
final
VoidCallback onPressed;
final
String text;
MyCustomButton
({
required
this
.onPressed,
required
this
.text});
@override
Widget
build
(BuildContext context) {
return
ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
padding: EdgeInsets.
symmetric
(
horizontal:
32
,
vertical:
16
,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.
circular
(
12
),
),
),
child: Text(
text,
style: TextStyle(fontSize:
18
, fontWeight: FontWeight.bold),
),
);
}
}
💡 Why Composition?
- Flexibility – You can combine widgets in any way
- Reusability – Composed widgets can be used in many places
- Testability – Smaller, focused widgets are easier to test
- Maintainability – Changes are isolated to specific widgets
- Performance – Composition is more efficient than deep inheritance
Custom widgets shine when building complex, reusable UI components. Here's how to build a User Profile Card that can be reused across your app:
class
ProfileCard
extends
StatelessWidget {
final
String name;
final
String email;
final
String avatarUrl;
final
VoidCallback onTap;
final
bool
isOnline;
ProfileCard
({
required
this
.name,
required
this
.email,
required
this
.avatarUrl,
required
this
.onTap,
this
.isOnline =
false
,
});
@override
Widget
build
(BuildContext context) {
return
Card(
elevation:
4
,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.
circular
(
12
),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.
circular
(
12
),
child: Padding(
padding: EdgeInsets.
all
(
16
),
child: Row(
children: [
// Avatar with online indicator
Stack(
children: [
CircleAvatar(
radius:
30
,
backgroundImage: NetworkImage(avatarUrl),
),
if
(isOnline)
Positioned(
bottom:
0
,
right:
0
,
child: Container(
width:
14
,
height:
14
,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.
all
(
color: Colors.white,
width:
2
,
),
),
),
),
],
),
SizedBox(width:
16
),
// User info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize:
18
,
fontWeight: FontWeight.bold,
),
),
Text(
email,
style: TextStyle(
fontSize:
14
,
color: Colors.grey[
600
],
),
),
],
),
),
Icon(
Icons.chevron_right,
color: Colors.grey[
400
],
),
],
),
),
),
);
}
}
// Usage in any screen
ProfileCard(
name:
'John Doe'
,
email:
'john@example.com'
,
avatarUrl:
'https://i.pravatar.cc/150?img=1'
,
isOnline:
true
,
onTap: () {
// Navigate to user profile
Navigator.
push
(context, MaterialPageRoute(
builder: (context) => UserProfileScreen(userId:
'123'
),
));
},
)
1. The "Container" Pattern
Create widgets that accept a
child
and wrap it with styling, layout, or behavior.
class
CardContainer
extends
StatelessWidget {
final
Widget child;
final
EdgeInsets padding;
final
double
elevation;
CardContainer
({
required
this
.child,
this
.padding =
const
EdgeInsets.
all
(
16
),
this
.elevation =
2
,
});
@override
Widget
build
(BuildContext context) {
return
Card(
elevation: elevation,
child: Padding(
padding: padding,
child: child,
),
);
}
}
2. The "List View" Pattern
Create reusable list builders for consistent data display.
class
UserList
extends
StatelessWidget {
final
List<User> users;
final
void
Function(User) onUserTap;
UserList
({
required
this
.users,
required
this
.onUserTap});
@override
Widget
build
(BuildContext context) {
return
ListView.builder(
itemCount: users.
length
,
itemBuilder: (context, index) {
final
user = users[index];
return
ProfileCard(
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
isOnline: user.isOnline,
onTap: () => onUserTap(user),
);
},
);
}
}
3. The "Loading State" Pattern
Build widgets that handle loading, error, and success states.
class
AsyncContent
<T>
extends
StatelessWidget {
final
AsyncSnapshot<T> snapshot;
final
Widget Function(T data) onSuccess;
final
Widget Function(String error)? onError;
final
Widget? onLoading;
AsyncContent
({
required
this
.snapshot,
required
this
.onSuccess,
this
.onError,
this
.onLoading,
});
@override
Widget
build
(BuildContext context) {
if
(snapshot.connectionState == ConnectionState.waiting) {
return
onLoading ?? Center(child: CircularProgressIndicator());
}
if
(snapshot.hasError) {
return
onError?.
call
(snapshot.error.
toString
()) ??
Center(child: Text(
'Error: ${snapshot.error}'
));
}
if
(snapshot.hasData) {
return
onSuccess.
call
(snapshot.data!);
}
return
SizedBox.shrink();
}
}
The Flutter team produces Widget of the Week videos — short, 1-minute explainer videos covering individual widgets. These are excellent resources for learning about specific widgets and how to use them.
🎥 Popular Widget Videos
- Container – The most common layout widget
- Row & Column – Flexible layout in one dimension
- Stack – Overlapping widgets
- ListView – Scrollable list of widgets
- GridView – Scrollable grid of widgets
- FutureBuilder – Async data handling
- StreamBuilder – Real-time data handling
- Hero – Shared element transitions
Follow these steps to create your own custom widgets:
const
when possible for performance
Not every widget that accepts data needs to be Stateful. If the widget doesn't change its own state, use StatelessWidget.
Always start with StatelessWidget. Only switch to StatefulWidget when you need to manage local state that changes over time.
Extending widgets to customize them leads to brittle code and makes it harder to change the implementation.
Build custom widgets by combining existing widgets. This is more flexible and maintainable.
Non-const widgets are rebuilt every frame, even when nothing changes, hurting performance.
Make your widget's constructor
const
when possible. This allows Flutter to
optimize rebuilds.
Putting business logic inside widgets makes them harder to test and reuse.
Use ViewModels, BLoCs, or providers for business logic. Keep widgets focused on presentation.
🎯 Key Takeaway
Custom widgets
are the foundation of Flutter development. By
composing
existing widgets into new ones, you can build reusable,
maintainable, and performant UIs. Always
prefer StatelessWidget
over
StatefulWidget and
composition over inheritance
. Use
const
constructors for performance and keep your widgets focused on presentation.