You tap “Submit”, nothing changes, and your brain immediately asks: “Did it work?” In mobile apps, that half-second of silence is where duplicate requests, angry taps, and abandoned flows come from. I’ve seen this most often on forms (sign-in, checkout, support tickets) and file actions (upload, export, sync). The fix isn’t just “show a spinner somewhere”—it’s to make the button itself acknowledge the action, block repeated taps, and recover gracefully when the operation succeeds, fails, or times out.\n\nI’m going to show you a reliable pattern for a loading progress indicator button in Flutter: the UI stays stable (no jitter from changing label width), the state is easy to reason about, and the async work can be swapped from a Future.delayed demo to real networking, uploads, or database calls. I’ll also cover accessibility, error states, cancellation, and a few modern 2026-era Flutter practices (Material 3, async commands, and testable state).\n\n## What a “Loading Button” Should Actually Guarantee\nA loading button is a small component with surprisingly strict requirements. When I build this in production apps, I want these guarantees:\n\n- Single-flight behavior: a second tap should not start a second request while the first is running.\n- Clear feedback where the user’s attention is: the button itself should change, because that’s what the user just interacted with.\n- Stable layout: the button shouldn’t shrink or jump when swapping “Submit” → “Loading…”.\n- Correct async lifecycle: the UI must stop loading even if the operation errors.\n- Accessible semantics: screen readers should announce busy state and the button should remain discoverable.\n- Extensible states: success, failure, and retry should feel native rather than bolted on.\n\nA good mental model: the button is like an elevator call button. When you press it, it lights up so you know the building heard you. You don’t need to stare at a separate sign across the lobby.\n\n## A Minimal, Runnable Example (Demo Delay → Real Async)\nI like starting from a tiny working app and then hardening it. Here’s a complete main.dart you can paste into a fresh Flutter project. It uses Material 3 and keeps the button width stable while showing a spinner.\n\n import ‘package:flutter/material.dart‘;\n\n void main() {\n runApp(const LoadingButtonDemoApp());\n }\n\n class LoadingButtonDemoApp extends StatelessWidget {\n const LoadingButtonDemoApp({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MaterialApp(\n debugShowCheckedModeBanner: false,\n theme: ThemeData(\n useMaterial3: true,\n colorSchemeSeed: Colors.green,\n ),\n home: const LoadingButtonDemoPage(),\n );\n }\n }\n\n class LoadingButtonDemoPage extends StatefulWidget {\n const LoadingButtonDemoPage({super.key});\n\n @override\n State createState() => LoadingButtonDemoPageState();\n }\n\n class LoadingButtonDemoPageState extends State {\n bool isLoading = false;\n String? status;\n\n Future submit() async {\n // Single-flight guard: if already loading, ignore.\n if (isLoading) return;\n\n setState(() {\n isLoading = true;\n status = null;\n });\n\n try {\n // Demo async work. Replace with your network call / upload / etc.\n await Future.delayed(const Duration(seconds: 3));\n\n // Simulate success.\n setState(() {\n status = ‘Submitted successfully.‘;\n });\n } catch (e) {\n setState(() {\n status = ‘Submit failed: $e‘;\n });\n } finally {\n // Always stop loading.\n if (mounted) {\n setState(() {\n isLoading = false;\n });\n }\n }\n }\n\n @override\n Widget build(BuildContext context) {\n final colorScheme = Theme.of(context).colorScheme;\n\n return Scaffold(\n appBar: AppBar(\n title: const Text(‘Loading Button Demo‘),\n backgroundColor: colorScheme.primary,\n foregroundColor: colorScheme.onPrimary,\n centerTitle: true,\n ),\n body: Center(\n child: ConstrainedBox(\n constraints: const BoxConstraints(maxWidth: 420),\n child: Padding(\n padding: const EdgeInsets.all(16),\n child: Column(\n mainAxisAlignment: MainAxisAlignment.center,\n crossAxisAlignment: CrossAxisAlignment.stretch,\n children: [\n // Status area\n AnimatedSwitcher(\n duration: const Duration(milliseconds: 250),\n child: status == null\n ? const SizedBox(height: 24)\n : Text(\n status!,\n key: ValueKey(status),\n style: TextStyle(\n color: status!.startsWith(‘Submit failed‘)\n ? colorScheme.error\n : colorScheme.primary,\n ),\n textAlign: TextAlign.center,\n ),\n ),\n const SizedBox(height: 16),\n\n LoadingProgressButton(\n isLoading: isLoading,\n onPressed: submit,\n label: ‘Submit‘,\n loadingLabel: ‘Loading…‘,\n ),\n ],\n ),\n ),\n ),\n ),\n );\n }\n }\n\n /// A reusable loading button that:\n /// – keeps a stable size\n /// – disables taps while loading\n /// – shows a spinner + loading label\n class LoadingProgressButton extends StatelessWidget {\n const LoadingProgressButton({\n super.key,\n required this.isLoading,\n required this.onPressed,\n required this.label,\n this.loadingLabel = ‘Loading…‘,\n });\n\n final bool isLoading;\n final Future Function() onPressed;\n final String label;\n final String loadingLabel;\n\n @override\n Widget build(BuildContext context) {\n final colorScheme = Theme.of(context).colorScheme;\n\n // Keep a consistent minimum height and avoid width jump by reserving space.\n return SizedBox(\n height: 52,\n child: FilledButton(\n onPressed: isLoading ? null : () async => onPressed(),\n child: Stack(\n alignment: Alignment.center,\n children: [\n // This invisible text reserves width for the longer of the two labels.\n Opacity(\n opacity: 0,\n child: Text(\n (loadingLabel.length > label.length) ? loadingLabel : label,\n style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),\n ),\n ),\n\n AnimatedSwitcher(\n duration: const Duration(milliseconds: 200),\n switchInCurve: Curves.easeOut,\n switchOutCurve: Curves.easeIn,\n child: isLoading\n ? Row(\n key: const ValueKey(‘loading‘),\n mainAxisAlignment: MainAxisAlignment.center,\n children: [\n SizedBox(\n width: 18,\n height: 18,\n child: CircularProgressIndicator(\n strokeWidth: 2.4,\n color: colorScheme.onPrimary,\n ),\n ),\n const SizedBox(width: 12),\n Text(\n loadingLabel,\n style: const TextStyle(\n fontSize: 16,\n fontWeight: FontWeight.w600,\n ),\n ),\n ],\n )\n : Text(\n label,\n key: const ValueKey(‘idle‘),\n style: const TextStyle(\n fontSize: 16,\n fontWeight: FontWeight.w600,\n ),\n ),\n ),\n ],\n ),\n ),\n );\n }\n }\n\nWhy I like this version:\n\n- The try/catch/finally block makes it hard to “forget” to stop loading.\n- The button disables itself when busy (onPressed: null), preventing double-submit.\n- Stack + Opacity(0) reserves width so the label swap doesn’t cause a tiny layout shift.\n- AnimatedSwitcher makes the change feel intentional, not abrupt.\n\nTo connect this to real app behavior, replace Future.delayed with something like:\n\n await apiClient.submitOrder(orderId: orderId);\n\nor an upload:\n\n await uploader.uploadFile(path);\n\nThe UI side stays the same.\n\n## State Management That Scales Past One Screen\nThe setState demo is fine for a single button. But as soon as you have multiple async actions, retries, and “disable while busy” rules, I recommend moving to an explicit command-like state. The idea is simple: your UI reads an object that says “idle / running / success / error”.\n\nHere’s a small pattern I use often: AsyncCommand. It’s testable, reusable, and works with any state management approach.\n\n import ‘package:flutter/foundation.dart‘;\n\n enum AsyncStatus { idle, running, success, error }\n\n class AsyncCommand extends ChangeNotifier {\n AsyncStatus status = AsyncStatus.idle;\n Object? error;\n\n AsyncStatus get status => status;\n bool get isRunning => status == AsyncStatus.running;\n Object? get error => error;\n\n Future run(Future Function() action) async {\n if (status == AsyncStatus.running) return;\n\n status = AsyncStatus.running;\n error = null;\n notifyListeners();\n\n try {\n await action();\n status = AsyncStatus.success;\n } catch (e) {\n status = AsyncStatus.error;\n error = e;\n } finally {\n notifyListeners();\n }\n }\n\n void reset() {\n status = AsyncStatus.idle;\n error = null;\n notifyListeners();\n }\n }\n\nThen in your widget, you can hook it up with AnimatedBuilder (no extra packages):\n\n class SubmitSection extends StatefulWidget {\n const SubmitSection({super.key});\n\n @override\n State createState() => SubmitSectionState();\n }\n\n class SubmitSectionState extends State {\n final AsyncCommand submitCommand = AsyncCommand();\n\n @override\n void dispose() {\n submitCommand.dispose();\n super.dispose();\n }\n\n @override\n Widget build(BuildContext context) {\n return AnimatedBuilder(\n animation: submitCommand,\n builder: (context, ) {\n return Column(\n crossAxisAlignment: CrossAxisAlignment.stretch,\n children: [\n if (submitCommand.status == AsyncStatus.error)\n Text(\n ‘Request failed: ${submitCommand.error}‘,\n style: TextStyle(color: Theme.of(context).colorScheme.error),\n ),\n const SizedBox(height: 12),\n LoadingProgressButton(\n isLoading: submitCommand.isRunning,\n label: ‘Submit‘,\n loadingLabel: ‘Sending…‘,\n onPressed: () => submitCommand.run(() async {\n await Future.delayed(const Duration(seconds: 2));\n }),\n ),\n ],\n );\n },\n );\n }\n }\n\nThis is the same user experience, but now you have a clean place to add:\n\n- retry rules\n- analytics events\n- timeouts\n- cancellation\n- showing “success” for a brief moment\n\n### Traditional vs Modern (2026) Approaches\nHere’s how I think about the evolution of this pattern:\n\n
Traditional Flutter approach
\n
—
\n
setState(() => isLoading = true) inside the widget
AsyncCommand, Riverpod Notifier, Bloc/Cubit), UI becomes mostly declarative \n
Manually add if (isLoading) return; everywhere
\n
Often forgotten or only printed to console
try/catch/finally always; represent error state explicitly \n
Instant swap (jarring)
AnimatedSwitcher with stable layout \n
Widget tests only
\n
Each screen reinvents the button
\n\nIf you already use Riverpod, Bloc, or another system, keep it—just model the state as “idle/running/success/error” and the loading button becomes trivial to render.\n\n## UI Details That Make the Button Feel “Native”\nMost loading buttons fail in small ways that users notice subconsciously. These are the fixes I reach for:\n\n### Keep Size and Tap Target Stable\nWhen the label changes length, the button can resize slightly. That looks like a glitch.\n\n- Reserve width for the longest label (as shown with Opacity(opacity: 0, child: Text(...))).\n- Fix the height with SizedBox(height: 52).\n- Keep padding consistent; don’t remove padding in loading state.\n\nIf your labels are localized, “longest label” becomes a moving target. In that case, I either:\n\n- reserve for a safe maximum string (for example, “Submitting…” in your longest supported locale), or\n- avoid text change altogether and keep the label the same while overlaying a spinner (I’ll show that option later).\n\n### Make the Spinner Match the Button\nA default CircularProgressIndicator() is often too large and too thick.\n\n- Set a smaller SizedBox(width: 18, height: 18).\n- Use strokeWidth around 2.0–2.8.\n- Use the correct color (colorScheme.onPrimary for filled buttons).\n\nAlso consider the visual weight of the label. I prefer a semi-bold label (600-ish) so the button still reads as a button, not a floating progress indicator.\n\n### Don’t Just Show “Loading…” Forever\nIn real apps, loading isn’t always 3 seconds. I often add a timeout with a better message.\n\n import ‘dart:async‘;\n\n Future runWithTimeout(Future Function() action) async {\n await action().timeout(\n const Duration(seconds: 15),\n onTimeout: () {\n throw TimeoutException(‘The request took too long. Check your connection.‘);\n },\n );\n }\n\nThen show a retry UI. Users forgive slow networks; they don’t forgive silence.\n\nOne practical nuance: a timeout doesn’t mean the server didn’t receive the request. If you’re doing something non-idempotent (like “place order”), I pair the timeout with a “check status” call or a screen that can reconcile state when the user returns. The button pattern stays the same, but the meaning of failure becomes more careful.\n\n### Show Success (Briefly) When It Helps\nFor actions like “Saved”, a short success feedback reduces repeat taps.\n\nA simple approach: swap the label to a checkmark and “Saved” for ~800ms, then reset. I keep it optional because not every action needs celebration.\n\nHere’s an example of a tiny success phase using the AsyncCommand status (no extra dependencies):\n\n class SuccessAwareButton extends StatelessWidget {\n const SuccessAwareButton({\n super.key,\n required this.status,\n required this.onPressed,\n });\n\n final AsyncStatus status;\n final Future Function() onPressed;\n\n @override\n Widget build(BuildContext context) {\n final isLoading = status == AsyncStatus.running;\n final isSuccess = status == AsyncStatus.success;\n\n return LoadingProgressButton(\n isLoading: isLoading,\n onPressed: onPressed,\n label: isSuccess ? ‘Done‘ : ‘Submit‘,\n loadingLabel: ‘Sending…‘,\n );\n }\n }\n\nIf you want a check icon, I usually add it as a prefix during success (and reserve width accordingly). The key idea is: success is a state, not a one-off snack bar.\n\n## Accessibility: Make Busy State Understandable\nLoading indicators are visual by default, so it’s easy to accidentally ship a button that’s confusing for screen reader users.\n\nWhat I do:\n\n- Disable the button while loading: that makes the state clear in most screen readers.\n- Add semantics when needed: if your button stays enabled for cancellation or other reasons, you should annotate it.\n\nHere’s a small tweak you can apply around the button:\n\n Semantics(\n button: true,\n enabled: !isLoading,\n label: isLoading ? ‘Submit, busy‘ : ‘Submit‘,\n child: LoadingProgressButton(\n isLoading: isLoading,\n onPressed: onPressed,\n label: ‘Submit‘,\n loadingLabel: ‘Loading…‘,\n ),\n )\n\nAlso consider:\n\n- Contrast: if your spinner is white on a light button, it will disappear. Always check onPrimary contrast.\n- Motion sensitivity: AnimatedSwitcher is mild, but if your app has a “reduce motion” setting, you can shorten durations.\n- Focus and keyboard navigation (especially on desktop/web): when the button disables itself, focus behavior should remain sane. If the focus ring disappears in a confusing way, you can keep focus on the disabled button container while disabling only the activation. I’ll show a pattern for this in the “cancellation” section.\n\nA small but helpful touch: announce state changes. When the action starts, I sometimes use SemanticsService.announce (sparingly) for important actions like “Payment processing” so a screen reader user isn’t stuck wondering if anything happened. Keep it short, and only for high-impact flows.\n\n## Real-World Scenarios (Where Loading Buttons Shine)\nThis pattern isn’t just for “Submit.” I reach for it in a few repeated scenarios:\n\n### 1) Form Submit (Sign-in, Checkout, Support Ticket)\nThe button should be the center of gravity. My typical flow looks like this:\n\n- validate locally (required fields, basic format)\n- if invalid: show inline errors; do not start loading\n- if valid: start loading, disable inputs (optional), call async\n- on success: navigate or show success state\n- on failure: stop loading, show error and keep the form editable\n\nOne nuance: I avoid blocking the entire screen unless the action truly locks the user out of doing anything else. For example, “Sign in” often blocks because the next screen depends on auth. But “Save profile” can be backgrounded: keep the screen usable and make the button reflect that saving is in flight.\n\n### 2) Network Call With Retries\nRetry is easiest when the UI already has an explicit error state. If the button is the primary action, I often label the error state as a secondary line and keep the button label as “Retry”.\n\nA simple retry pattern:\n\n- First press: label “Submit”\n- If request fails: show error text + button label “Retry”\n- If request succeeds: button label “Done” briefly or navigate\n\nThis avoids the common anti-pattern where you show a snack bar error but the button goes back to “Submit” with no context that something failed.\n\n### 3) File Upload (Indeterminate vs True Progress)\nA spinner implies indeterminate loading. For file uploads, users often benefit from actual progress, especially on slower connections. There are two button styles that work well:\n\n- Button stays the same size, label changes to “Uploading 43%”\n- Button shows a linear progress indicator embedded inside\n\nIf you can only provide indeterminate progress (common when you don’t have byte counts), keep the spinner but make the label specific: “Uploading…” instead of “Loading…”. Specific beats generic.\n\nHere’s a simple “percent” variant that still preserves layout stability (you provide progress from 0.0 to 1.0):\n\n class LoadingProgressButtonWithPercent extends StatelessWidget {\n const LoadingProgressButtonWithPercent({\n super.key,\n required this.progress,\n required this.onPressed,\n required this.isLoading,\n required this.label,\n });\n\n final double? progress; // null = indeterminate\n final bool isLoading;\n final Future Function() onPressed;\n final String label;\n\n @override\n Widget build(BuildContext context) {\n String loadingLabel = ‘Loading…‘;\n if (progress != null) {\n final pct = (progress! 100).clamp(0, 100).toInt();\n loadingLabel = ‘Uploading $pct%‘;\n }\n\n return LoadingProgressButton(\n isLoading: isLoading,\n onPressed: onPressed,\n label: label,\n loadingLabel: loadingLabel,\n );\n }\n }\n\nYou still get single-flight, disabling, and stable layout—just with better information.\n\n### 4) “Dangerous” Actions (Delete, Reset, Sign Out)\nFor destructive actions, the loading state also acts as a “commitment” indicator: the app is carrying out something that can’t be instantly undone. My rule of thumb:\n\n- show loading state on the destructive button\n- keep the cancel/close affordance visible (if safe)\n- on success: show confirmation that the action completed\n\nAlso: if you show a confirmation dialog, the loading state usually belongs inside the dialog’s confirm button, not on the underlying screen. Users read dialogs as a mini-flow with its own state.\n\n## When NOT to Use a Loading Button\nA loading progress indicator button is great, but I don’t force it everywhere. I avoid it when:\n\n### The Action is Instant and Local\nToggling a local setting or expanding UI shouldn’t pretend to be “loading.” If it’s immediate, keep it immediate. If it’s local but slow (like a heavy computation), you’re better off optimizing or running it off the main isolate than slapping a spinner on a button.\n\n### The Action Should Not Block Repeated Taps\nSome actions are designed for repeated taps (like “+” in a quantity selector). You can still show progress elsewhere if there’s network syncing, but disabling the button may be a worse UX than letting the user continue and batching updates.\n\n### The UI Already Has Clear In-Place Feedback\nIf the action is “Add to cart” and your cart icon updates instantly and reliably, the button may not need a spinner. In that case, I prefer a brief pressed animation or a success checkmark rather than a full loading state.\n\n## Edge Cases That Break Naive Implementations\nThis is where most “loading button” snippets fall apart in production. Here are the breakpoints I harden for.\n\n### Edge Case 1: Exceptions That Skip UI Reset\nIf you don’t use finally, you will eventually ship a button stuck in loading state. I treat try/catch/finally as non-negotiable for button-driven async actions.\n\nIf your code has multiple awaits, be careful not to return early before the reset. The command pattern helps because you centralize the lifecycle.\n\n### Edge Case 2: User Navigates Away Mid-Request\nIf the screen is popped while the request is in flight, calling setState will throw. In the demo we used if (mounted). In larger apps, I prefer to:\n\n- keep async work out of the widget when possible (command/controller owns it)\n- ensure cancellation or ignore results when disposed\n\nFor “fire-and-forget” calls that don’t affect UI, I still avoid starting them from a button without some lifecycle ownership. Even a ChangeNotifier controller with dispose discipline is better than raw setState.\n\n### Edge Case 3: Multiple Buttons Share One Busy State\nCommon example: a form with “Save” and “Save & Close.” If both trigger the same underlying request, they should share a single-flight guard. Otherwise you get dueling requests and inconsistent UI.\n\nWith the command object, it’s straightforward: both buttons point to the same command and simply pass different actions or parameters. The UI becomes coherent because “busy” is owned by the command, not by individual widgets.\n\n### Edge Case 4: Idempotency and Duplicate Requests\nDisabling the button prevents duplicate taps from the UI, but it doesn’t prevent duplicate requests from retries, hot reload, or background resume logic. For anything that matters (payments, orders, irreversible operations), I also protect on the server side with idempotency keys.\n\nI mention this because teams sometimes over-trust UI single-flight. The button is the first line of defense, not the only one.\n\n### Edge Case 5: Very Fast Requests (Jarring Flicker)\nIf the request completes in 50–150ms, showing a spinner can create a weird flicker: tap → spinner flashes → back to idle. That looks broken even though it’s “correct.”\n\nI handle this with one of two strategies:\n\n- Delayed spinner: only show the loading indicator if the action takes longer than, say, 150–250ms.\n- Minimum spinner duration: once shown, keep it visible for at least ~300ms so it doesn’t blink.\n\nHere’s a command-side “delayed spinner” idea (kept simple):\n\n class DelayedLoadingController extends ChangeNotifier {\n bool showLoading = false;\n bool get showLoading => showLoading;\n\n Future run(Future Function() action) async {\n showLoading = false;\n notifyListeners();\n\n final loadingTimer = Future.delayed(const Duration(milliseconds: 200))\n .then(() {\n showLoading = true;\n notifyListeners();\n });\n\n try {\n final result = await action();\n return result;\n } finally {\n // Ensure loading doesn‘t remain on; timer might still complete later.\n showLoading = false;\n notifyListeners();\n }\n }\n }\n\nIn a real app I’d make this more robust (cancel timer, minimum duration), but even this introduces the idea: don’t punish fast paths with UI noise.\n\n## Alternative UI Patterns (Same Goal, Different Feel)\nThe “spinner + loading label” is my default, but there are other patterns worth knowing.\n\n### Pattern A: Keep Label ثابت, Overlay Spinner\nIf you want to guarantee zero width changes across locales and dynamic type sizes, you can keep the label constant and overlay a spinner on top while reducing label opacity. The button stays readable and stable.\n\nConceptually:\n\n- Always render “Submit”\n- When loading: show spinner on top and fade label slightly\n- Disable taps\n\nThis avoids measuring text or reserving width. It also tends to look more “platform-native” on some designs where buttons don’t change wording mid-action.\n\nHere’s a variation using the same component style (still no jitter):\n\n class OverlaySpinnerButton extends StatelessWidget {\n const OverlaySpinnerButton({\n super.key,\n required this.isLoading,\n required this.onPressed,\n required this.label,\n });\n\n final bool isLoading;\n final Future Function() onPressed;\n final String label;\n\n @override\n Widget build(BuildContext context) {\n final cs = Theme.of(context).colorScheme;\n\n return SizedBox(\n height: 52,\n child: FilledButton(\n onPressed: isLoading ? null : () async => onPressed(),\n child: Stack(\n alignment: Alignment.center,\n children: [\n AnimatedOpacity(\n duration: const Duration(milliseconds: 150),\n opacity: isLoading ? 0.35 : 1.0,\n child: Text(\n label,\n style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),\n ),\n ),\n if (isLoading)\n SizedBox(\n width: 18,\n height: 18,\n child: CircularProgressIndicator(\n strokeWidth: 2.4,\n color: cs.onPrimary,\n ),\n ),\n ],\n ),\n ),\n );\n }\n }\n\nI use this when localization is a big deal, or when product/design wants the button copy to remain stable (“Submit” should stay “Submit”).\n\n### Pattern B: Inline Progress Bar Inside Button\nFor uploads/exports, a linear indicator embedded in the button can communicate progress without adding extra UI. Visually, it reads like “this action is filling up.”\n\nImplementation idea:\n\n- Use a Stack\n- Bottom layer: a ClipRRect + LinearProgressIndicator(value: progress)\n- Top layer: label and maybe spinner\n\nThis works best when your button background is relatively flat and you have enough contrast.\n\n### Pattern C: Two-Step Actions (Confirm → Loading)\nSometimes “loading button” is the wrong abstraction. For actions like payments, I prefer a two-step flow:\n\n- Step 1: user taps “Pay”\n- Step 2: screen transitions to a processing state with explicit context\n\nThe button still shows loading briefly, but the app quickly moves to a dedicated processing UI. This reduces anxiety because the user gets context (“Processing payment… don’t close the app”).\n\n## Cancellation: Let Users Back Out (When Safe)\nNot every request should be cancelable, but some should. For example:\n\n- uploading a large file\n- syncing a long list\n- generating an export\n\nThere are two ways I approach cancel UX:\n\n### Option 1: Button Becomes “Cancel” During Loading\nInstead of disabling the button, the button remains enabled and changes its meaning to cancel. This is powerful but needs careful semantics so it doesn’t become confusing.\n\nThe main requirements:\n\n- the busy indicator remains visible\n- the label clearly says “Cancel”\n- a tap triggers cancel token / request abort\n- state moves back to idle\n\nAt a high level (pseudo-structure):\n\n // isRunning true -> show spinner + ‘Cancel‘\n // onPressed during running -> cancel operation\n\nIn real networking, cancellation support depends on your HTTP client. If you can’t truly abort the request, you can still implement “soft cancel”: ignore the result when it arrives and reset the UI. That’s still valuable because it returns control to the user.\n\n### Option 2: Keep Button Disabled, Provide a Separate Cancel UI\nSometimes it’s safer: keep “Submit” disabled while running, but show a secondary text button below: “Cancel”. This avoids changing the primary button’s meaning mid-flight (which can be risky for accessibility and muscle memory).\n\nMy rule: if the action is destructive or non-idempotent, don’t turn the same button into “Cancel.” Prefer a separate, explicit cancel affordance.\n\n## Error UX: Make Failures Actionable\nThe easiest error state is “Submit failed: $e,” but production apps benefit from a little structure. When an operation fails, users ask two questions:\n\n1) What happened?\n2) What can I do next?\n\nI design the failure state around those questions. Practical improvements:\n\n### Map Technical Errors to User Messages\nInstead of printing raw exceptions, map them:\n\n- network unavailable → “No internet connection.”\n- timeout → “This is taking too long.”\n- validation error from server → show field-level error\n- unauthorized → “Session expired. Please sign in again.”\n\nEven a small mapping function helps a lot:\n\n String userMessageForError(Object e) {\n if (e is TimeoutException) return ‘That took too long. Please try again.‘;\n return ‘Something went wrong. Please try again.‘;\n }\n\nThen render that message in the status area.\n\n### Keep the Button Label Meaningful\nAfter failure, don’t default back to “Submit” with no context. I like:\n\n- If user needs to edit input: keep label “Submit” but show error\n- If user should just try again: change label to “Retry”\n\nThat’s a small change that reduces cognitive load.\n\n### Don’t Lose Form State\nIf your failure state resets the whole form, users get angry. The loading button should never clear inputs unless the request succeeded and you have a clear reason.\n\n## Performance Considerations (Small Component, Real Impact)\nA loading button isn’t usually a performance bottleneck, but it can be a performance multiplier when used widely. Here’s what I watch:\n\n### Avoid Unnecessary Rebuilds\nIf you place the loading button in a widget that rebuilds frequently (like a large StreamBuilder), the animation may restart or feel jittery. Keep the button under stable state ownership. With command objects or localized state, you rebuild only the relevant section.\n\n### Prefer Simple Animations\nAnimatedSwitcher is great because it’s declarative and cheap. Avoid heavy custom animations unless you need them. If you do animate, keep durations short (150–250ms) so the UI stays snappy.\n\n### Manage “Fast Path” Flicker\nAs mentioned earlier, delaying the spinner is a UX performance optimization. It reduces perceived jank and keeps the interface calm.\n\n### Keep Hit Testing Predictable\nWhen you use Stack, make sure the tappable area is still the button itself. Avoid layering widgets that accidentally capture taps. If you add overlays, keep them non-interactive.\n\n## Testing: Make This Boring to Maintain\nLoading buttons touch async lifecycle, which is exactly where flaky behavior sneaks in. I like to test two layers:\n\n1) unit tests for the command/controller\n2) widget tests for rendering and disable logic\n\n### Unit Test the Single-Flight Guard\nYou want a test that proves: if run is called twice quickly, the action runs once.\n\nIf you use AsyncCommand, you can structure a test like:\n\n- create command\n- call run with an action that waits on a Completer\n- call run again before completing\n- verify action executed only once\n\nEven if you don’t write the test right now, designing your state so it can* be tested is a huge win. That’s one of the main reasons I like command objects.\n\n### Widget Test the Disabled State\nAt the widget level, what matters is:\n\n- button is enabled when idle\n- button is disabled when loading\n- loading label/spinner appears when loading\n\nIf you keep the button as a small reusable widget, it’s easy to test in isolation with a MaterialApp wrapper.\n\n## A More Complete “Production” Button API\nOnce teams start reusing this, you’ll want a slightly richer API than just label and loadingLabel. Here’s what I typically add:\n\n- icon or leading (optional)\n- minWidth (optional)\n- semanticLabel override\n- style or button variant selection\n- showDelayedSpinner (optional)\n\nBut I keep the default usage simple. The worst reusable component is one that demands 12 parameters every time.\n\nA pragmatic interface I like is:\n\n- required: isLoading, onPressed, label\n- optional: loadingLabel, spinnerColor, height\n\nEverything else stays in theming or a wrapper component.\n\n## Integrating With Real Networking (Timeouts, Error Mapping, Idempotency)\nIf I’m wiring this to a real HTTP call, my flow looks like:\n\n- In the command/controller: wrap the call in a timeout\n- Map the exception to a user message\n- In the UI: display the message and present retry\n\nExample structure inside run:\n\n await _submitCommand.run(() async {\n await apiClient\n .submitTicket(data)\n .timeout(const Duration(seconds: 15));\n });\n\nThen error mapping in the UI layer (or controller if you prefer) and show a friendly message.\n\nIf you’re submitting something that must never duplicate, add an idempotency key to the request. The loading button prevents obvious duplicates; the idempotency key prevents expensive ones.\n\n## Common Pitfalls (And How I Avoid Them)\nThese are the mistakes I see most often when teams implement loading buttons quickly:\n\n### Pitfall 1: Forgetting to Disable\nIf the button still triggers onPressed while loading, double-submit is inevitable. Even if you guard in code, you’re still burning taps and confusing users. Disable the button and guard in code—both.\n\n### Pitfall 2: Layout Jump From Label Swap\nA tiny jump makes the UI feel cheap. Reserve width or avoid label changes. Don’t “hope” it’s fine—localization and dynamic type will prove it isn’t.\n\n### Pitfall 3: Global Loading Overlay for a Local Action\nIf the user tapped a button, show feedback at the button. Full-screen blockers should be reserved for flows that truly block the entire app. Otherwise you train users to fear tapping buttons.\n\n### Pitfall 4: No Error State\nIf failures are only logged, users will tap repeatedly. Make errors visible and actionable.\n\n### Pitfall 5: Not Handling Dispose / Navigation\nIf the screen unmounts during the request, you can crash or leak state. Use mounted checks for setState, or keep the async work in a controller with a clean lifecycle.\n\n## A Quick Checklist I Use Before Shipping\nIf I’m reviewing a PR with a loading progress indicator button, I scan for these items:\n\n- single-flight guard exists (UI disable + code guard)\n- try/finally or equivalent lifecycle handling exists\n- error state is visible to the user\n- success state is either explicit or navigation is immediate\n- layout remains stable in loading state (no jitter)\n- semantics are reasonable (disabled/busy state is understandable)\n- fast-path doesn’t flicker (optional, but a nice polish)\n\nIf those are true, the button will behave like a reliable component, not a fragile demo.\n\n## Wrap-Up: The Button Is a Contract\nA loading progress indicator button isn’t just decoration—it’s a contract between user and app: “I heard you, I’m working, and I’ll tell you what happened.” When you build it with single-flight behavior, stable layout, robust async lifecycle, and clear error/success states, you prevent the most common frustration loop in mobile UI: tap, doubt, tap again.\n\nStart with the minimal example, then graduate to a command-like state once your app has more than one async action. From there you can add timeouts, retries, cancellation, and real progress without changing the core pattern. That’s the sweet spot: a small component that’s boringly reliable everywhere you use it.


