If you have ever copied the same ElevatedButton with the same padding, colors, and loading spinner into five different screens, this post is for you. Reusable widgets are the single biggest step from "it works" Flutter code to code that is easy to maintain.
In this tutorial we build a reusable PrimaryButton step by step, then look at the rules that make any custom widget easy to reuse.
Why build reusable widgets?
- Consistency: every button in the app looks and behaves the same.
- One place to change: a design update is a one-line change, not a search-and-replace across the project.
- Cleaner screens: screen files describe what is on the page, not how each piece is styled.
- Easier testing: a small widget with clear inputs is simple to test in isolation.
The problem: duplicated UI code
Here is a typical button that ends up copied all over an app:
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('Continue'),
),
)It works, but every copy is a chance for the styles to drift apart.
Step 1: Extract a StatelessWidget
Move the code into its own widget and turn everything that changes between screens into a constructor parameter:
import 'package:flutter/material.dart';
class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool isLoading;
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: Text(label),
),
);
}
}Now every screen uses one line:
PrimaryButton(label: 'Continue', onPressed: _submit, isLoading: _isLoading)Step 2: Make it configurable, not complicated
Add options only when a real screen needs them. An optional icon is a common one:
class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final bool isLoading;
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
final child = isLoading
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2.5))
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[Icon(icon, size: 20), const SizedBox(width: 8)],
Text(label),
],
);
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: child, // same style as before
),
);
}
}Step 3: Use the theme instead of hard-coded colors
Hard-coded Colors.indigo breaks the moment you add dark mode or rebrand. Read colors from the theme instead:
final colors = Theme.of(context).colorScheme;
ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
// ...
)Now the button follows whatever theme the app uses.
Rules for widgets that stay reusable
- One job per widget. A button is a button; it should not also fetch data.
- Pass data in, send events out. Take values through the constructor and report actions through callbacks like
onPressed. Never reach into a specific screen's state. - Required for essentials, defaults for the rest.
labelis required;isLoadingdefaults tofalse. - Prefer composition. Accept a
Widget childwhen callers need flexibility, instead of adding ten boolean flags. - Keep them in one folder, such as
lib/widgets/, so the team knows where to look before building something new.
Wrapping up
Turning repeated UI into small, configurable widgets keeps your Flutter code consistent, faster to change, and easier to test. Start with the piece you copy most often (usually a button, input field, or card) and extract it today.
Watch the full walkthrough in the video at the top of this post, and check out more Flutter tutorials on our blog.

