I've been building Flutter apps for a few years now, and I still remember the first time I shipped something I was genuinely proud of. It had a clean UI, smooth animations, and every flow worked exactly as I intended. I handed it to real users and felt good about it.

Within a week, the bug reports started coming in.

Screens freezing, API calls failing silently, Users losing form data they'd spent ten minutes filling out, one user reported the app just... stopped responding after they walked through a tunnel on the subway. I had never tested that. Why would I? It worked fine on my machine.

That experience taught me something I wish someone had told me earlier: there's a real gap between an app that works and an app that is production-ready.

I've now shipped multiple Flutter apps, and I've hit almost every wall this article covers — network failures, memory leaks, state management that made sense at first and became a nightmare at scale, and performance that felt fine in development and janked badly on a user's old device.

This article is everything I've learned from those experiences. Not theory, but actual patterns that came from actual problems.

Table of Contents

Why "It Works on My Machine" is Dangerous in Flutter

Here's what your development environment looks like: fast internet, a powerful machine or emulator, a clean app state on every hot reload, APIs that respond in milliseconds, and you, a careful developer who deliberately follows the happy path.

Here's what your users look like: spotty mobile data, old mid-range devices, six other apps running in the background, and zero patience for a screen that stops loading without explanation.

That gap is where production bugs live.

The tricky part is that Flutter makes development feel so smooth that it's easy to mistake "works on my machine" for "ready for users."

I've made that mistake. Most Flutter developers I know have made it too. The app looks polished. The animations are butter. You demo it to a colleague, and everything goes perfectly. Then someone tries to use it while commuting on patchy mobile data, and the whole thing falls apart.

Production-ready Flutter engineering starts with accepting one uncomfortable truth: things will go wrong. Networks will fail. Devices will run low on memory. Users will background your app at the worst possible moment. The question isn't whether these things happen, but rather whether your app handles them gracefully when they do.

Development vs Production: What Actually Changes

I want to be specific here because "production is different" is easy to say and hard to internalize until you've been burned by it.

In development, a failed API call is something you notice immediately in your terminal, fix in a few minutes, and move on from. In production, that same failed API call happens to a user who sees a blank screen, has no idea why, waits a few seconds, and then either retries or uninstalls. You find out three days later when someone leaves a one-star review.

In development, a widget that rebuilds unnecessarily costs a few milliseconds you never feel. In production, on an older or lower-powered device with several apps running in the background, that same unnecessary rebuild is the thing that pushes a frame over the 16ms budget and creates a stutter the user notices.

In development, a memory leak that adds 5MB of usage over ten minutes is invisible. I once had a leak in a chat feature, an undisposed stream subscription that was completely undetectable during testing. In production, after an hour of use on a low-memory device, the OS started killing the app mid-session. Users thought it was crashing randomly. It took me an embarrassingly long time to track down.

The pattern is always the same: problems that are invisible at development scale become significant at production scale, and problems that are minor on development hardware become severe on the hardware your actual users own.

Network Reliability and Defensive Request Handling

If I had to pick one category of bug that has bitten me the most across multiple apps, it would be this one. Mobile networks are genuinely unreliable, and Flutter apps are often written as though they're not.

The most common networking pattern I see (and wrote myself for longer than I'd like to admit) looks like this:

final response = await dio.get('/user');

setState(() {
  user = response.data;
});

This works perfectly in development. But it has four ways to fail in production:

  1. The request fails due to a network error, and the exception propagates unhandled

  2. The user navigates away before the response arrives and setState is called on a disposed widget

  3. The API returns unexpected data, and the cast throws at runtime

  4. The request hangs indefinitely, and the user stares at a spinner forever

I've hit all four. Here's a version that handles them:

Future<void> loadUser(String userId) async {
  setState(() {
    isLoading = true;
    error = null;
  });

  try {
    final response = await dio.get('/user/$userId');

    // mounted checks whether this widget is still in the widget tree.
    // If the user navigated away while the request was running,
    // mounted is false. Calling setState on a disposed widget throws
    // an error — this one line prevents that entire class of crash.
    if (!mounted) return;

    setState(() {
      user = User.fromJson(response.data as Map<String, dynamic>);
      isLoading = false;
    });
  } on DioException catch (e) {
    if (!mounted) return;

    setState(() {
      // Give the user a message that is actually useful.
      // "Something went wrong" is not helpful. Knowing whether
      // they have no internet vs the server failed lets them
      // decide whether to move or wait.
      error = e.type == DioExceptionType.connectionError
          ? 'No internet connection. Please try again.'
          : 'Failed to load profile. Please try again.';
      isLoading = false;
    });
  }
}

The Three States Every Screen Needs

I used to design screens for the success case and treat loading and error as afterthoughts. That was a mistake. Every screen that fetches remote data needs all three:

@override
Widget build(BuildContext context) {
  // Loading: never leave users staring at a blank screen.
  // A spinner tells them something is happening.
  if (isLoading) {
    return const Center(child: CircularProgressIndicator());
  }

  // Error: show what went wrong and how to recover.
  // A dead end with no retry button is one of the most
  // frustrating things a user can experience.
  if (error != null) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(error!, style: const TextStyle(color: Colors.red)),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () => loadUser(widget.userId),
            child: const Text('Try again'),
          ),
        ],
      ),
    );
  }

  // Success: show the data.
  return UserProfileView(user: user!);
}

The error state with a retry button isn't a nice-to-have. It's the difference between a user who recovers from a network hiccup and a user who thinks your app is broken.

Retry Logic and the Production Request Lifecycle

Mobile networks fail all the time temporarily. A user walks past a dead zone, enters an elevator, or switches from WiFi to mobile data mid-request. The request fails but if retried two seconds later, it would succeed.

Without retry logic, every temporary network failure is a permanent failure from the user's perspective. That's a bad trade.

Future<T> withRetry<T>(
  Future<T> Function() request, {
  int maxAttempts = 3,
  Duration delay = const Duration(seconds: 1),
}) async {
  for (int i = 0; i < maxAttempts; i++) {
    try {
      return await request();
    } catch (e) {
      // On the final attempt, stop retrying and let the
      // error propagate to the caller.
      if (i == maxAttempts - 1) rethrow;

      // Wait before trying again. This gives temporary network
      // issues time to resolve and avoids hammering a server
      // that might already be struggling.
      await Future.delayed(delay);
    }
  }

  throw Exception('Retry failed');
}

Usage is straightforward:

final user = await withRetry(
  () => dio.get('/user/$userId'),
  maxAttempts: 3,
  delay: const Duration(seconds: 2),
);

For production apps with heavier traffic, look at dio_smart_retry. This implements exponential backoff, and the delay doubles between each retry, which is much more considerate of server load during actual outages.

Offline Support and Local Persistence

I learned to take offline support seriously after an embarrassing support ticket. A user had filled out a long onboarding form (15 fields), which took them several minutes, and hit submit on a spotty connection. The request failed. The form cleared. All their data was gone. They were furious, and honestly, they had every right to be.

The goal of offline support is not to replicate every feature without internet. It's to make sure users don't lose progress and don't hit dead ends.

Caching Remote Data

The strategy here is simple: every time a network request succeeds, save the result locally. Then, if the next request fails, serve what you saved last time instead of showing an error screen.

class UserRepository {
  final Dio _dio;
  final Box _cache; // Hive box

  UserRepository(this._dio, this._cache);

  Future<User> getUser(String userId) async {
    try {
      final response = await _dio.get('/user/$userId');
      final user = User.fromJson(response.data as Map<String, dynamic>);

      // Save fresh data to the cache every time a request succeeds.
      // This means the next request can fall back to this
      // if the network is unavailable.
      await _cache.put('user_$userId', user.toJson());

      return user;
    } catch (e) {
      // Network failed. See if we have something cached.
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        // Stale data is better than an error screen.
        // The user sees something useful even without internet.
        return User.fromJson(Map<String, dynamic>.from(cached));
      }

      // Nothing cached. We have no choice but to surface the error.
      rethrow;
    }
  }
}

Preserving User Input

This is the fix for the onboarding ticket I mentioned:

// Save whatever the user has typed whenever the field changes.
_contentController.addListener(() async {
  await _cache.put('draft_post', _contentController.text);
});

// When the screen opens, restore any saved draft.
@override
void initState() {
  super.initState();
  final draft = _cache.get('draft_post') as String?;
  if (draft != null && draft.isNotEmpty) {
    _contentController.text = draft;
  }
}

// Clear the draft once the user successfully submits.
Future<void> _submit() async {
  await _repository.createPost(_contentController.text);
  await _cache.delete('draft_post');
}

Three lines of code that save users from losing their work. This is worth doing in any form that takes more than a minute to fill out.

Packages I use for local persistence:

  1. Hive for simple key-value storage

  2. Isar when I need more powerful queries

  3. sqflite for relational data

  4. shared_preferences strictly for user settings, not for anything substantial

State Management at Scale

setState is fine. I want to say that clearly because there's a tendency in the Flutter community to treat it like it's always wrong. For local, simple UI state – a button toggling, a form field showing validation — setState is exactly the right tool.

The problems start when you use it for state that multiple widgets depend on, or for async operations, or for anything that needs to survive navigation. I've done all of these. Here's what goes wrong:

// This setState call lives high in the widget tree.
// Every widget below it rebuilds — including expensive ones
// that have nothing to do with this state change.
setState(() {
  currentUser = updatedUser;
});

As the app grows, this gets worse. Rebuilds spread. Side effects happen in unexpected order. You start spending more time debugging state than building features.

Moving to Riverpod

After hitting these walls in my second app, I switched to Riverpod and haven't looked back. The core idea is simple: state lives outside widgets, and widgets subscribe to exactly the state they need.

@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  AsyncValue<User> build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future<void> _load() async {
    state = const AsyncValue.loading();

    // AsyncValue.guard runs the future and wraps the result
    // in AsyncValue.data on success or AsyncValue.error on failure.
    // It saves you from writing try/catch every single time.
    state = await AsyncValue.guard(
      () => ref.read(userRepositoryProvider).getUser(userId),
    );
  }

  Future<void> refresh() => _load();
}

In the widget:

@override
Widget build(BuildContext context) {
  // ref.watch subscribes this widget to the notifier.
  // It rebuilds only when userAsync changes — not when
  // unrelated state elsewhere in the app changes.
  final userAsync = ref.watch(userNotifierProvider(widget.userId));

  return userAsync.when(
    // when() forces you to handle loading, error, and data.
    // Miss one and it's a compile error, not a runtime surprise.
    loading: () => const CircularProgressIndicator(),
    error: (e, _) => Text('Error: $e'),
    data: (user) => UserProfileView(user: user),
  );
}

The part I appreciate most: when() makes it a compile error to forget the loading or error state. The compiler enforces what I used to forget.

Immutable State

One thing that burned me hard in a real-time chat feature: a mutable list shared across multiple parts of the app.

List<Message> messages = [];

// Later, in different places:
messages.add(newMessage);       // socket handler
messages.removeAt(0);          // pagination
messages.insert(0, pinned);    // push notification handler

When a message appeared twice, or disappeared at random, tracing which mutation caused it was genuinely painful. The fix is to never mutate and always create a new list:

// The old list is unchanged. The new state is a new list.
// Every change is explicit and traceable.
state = [...state, newMessage];

It feels like a small thing until you spend two hours debugging a mutation bug. Then it feels very important.

Widget Rebuilds and Rendering Performance

Flutter is fast. But unnecessary rebuilds accumulate, and on low-end devices the accumulation is noticeable.

Const Widgets Skip Rebuilds Entirely

The const keyword tells Dart this widget can be created at compile time and reused indefinitely. Any widget whose content will never change is a candidate.

// Without const: a new Text instance is created on every
// rebuild of the parent, even though the content never changes.
Text('Welcome to the app')

// With const: Flutter reuses the same instance.
// No rebuild work, no allocation.
const Text('Welcome to the app')

This sounds like a small thing. In a large widget tree with many static elements, the cumulative effect is real. Make it a habit.

Keep the Rebuild Scope Small

When setState lives high in the widget tree, every widget below it rebuilds — even ones that have nothing to do with the state that changed. The fix is to push state as far down the tree as possible, ideally into its own extracted widget.

// The problem: counter lives in the parent, so every
// setState call rebuilds the entire subtree — including
// ExpensiveListWidget, which has nothing to do with the counter.
class _BadExampleState extends State<BadExample> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $_counter'),
        ElevatedButton(
          onPressed: () => setState(() => _counter++),
          child: const Text('Increment'),
        ),
        const ExpensiveListWidget(), // rebuilds for no reason
      ],
    );
  }
}

Now, only that widget rebuilds when the count changes. ExpensiveListWidget is untouched.

ListView.builder for Anything of Unknown Length

A Column with a mapped list builds every item upfront regardless of whether it is visible. On a list of 200 items, that is 200 widgets created before the user has scrolled at all.

// This builds every single item widget upfront.
// With 200 items, 200 widgets are created on first render,
// most of which are immediately off-screen.
Column(
  children: items.map((item) => ItemCard(item: item)).toList(),
)

// This builds only what is visible, plus a small buffer.
// Scrolling through 10,000 items uses the same memory as 10.
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemCard(items[index]);
  },
)

ListView.builder isn't an optimization for large lists. It's the correct default for any list of unknown or variable size. I use Column with a mapped list only when I know for certain the list will always be tiny.

Async Pitfalls and the Disposed Widget Problem

This is one of those bugs that's completely invisible during development and shows up constantly in production.

The scenario: an async operation starts, the user navigates away before it finishes, and the operation completes and tries to call setState on a widget that no longer exists.

Future<void> _loadData() async {
  final data = await repository.fetchData();

  // If the user navigated away during the await above,
  // this widget is gone. setState throws:
  // "setState() called after dispose()"
  setState(() => this.data = data );
}

The fix is one line:

Future<void> _loadData() async {
  final data = await repository.fetchData();

  // mounted is true while the widget is in the tree,
  // false after dispose() has been called.
  if (!mounted) return;

  setState(() => this.data = data);
}

I now write this check automatically after every await that leads to a setState. It becomes muscle memory quickly.

Never Create Futures Inside Build

This is an easy-to-overlook issue. When you create a Future directly inside the build method, a new Future is created on every rebuild — meaning FutureBuilder treats it as a brand new operation each time and resets to the loading state unnecessarily.

// Bad: a new Future is created on every rebuild.
// FutureBuilder sees a different Future each time
// and resets to loading state unnecessarily.
@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: repository.fetchUser(userId), // new Future every build
    builder: (context, snapshot) { ... },
  );
}
// Good: create the Future once in initState.
// FutureBuilder holds the same reference across rebuilds.
late final Future<User> _userFuture;

@override
void initState() {
  super.initState();
  _userFuture = repository.fetchUser(widget.userId);
}

@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: _userFuture,
    builder: (context, snapshot) { ... },
  );
}

Move Heavy Work Off the UI Thread

Dart renders UI on the main isolate. Anything CPU-intensive that blocks it causes dropped frames.

// Parsing a large API response synchronously on the main isolate
// can block rendering for 50-200ms on slower devices.
final users = (response.data as List)
    .map((json) => User.fromJson(json))
    .toList();
// compute() runs the function in a separate isolate.
// The main isolate stays free to render frames.
// Note: the function must be top-level or static —
// closures that capture local state cannot be sent to another isolate.
final users = await compute(parseUsers, response.data);

List<User> parseUsers(dynamic data) {
  return (data as List)
      .map((json) => User.fromJson(json as Map<String, dynamic>))
      .toList();
}

I reach for compute whenever I am parsing a large JSON response, doing image processing, or running anything that feels slow in a quick profile. The threshold in my head is roughly 16ms — if an operation might take longer than that, it shouldn't be on the main isolate.

Memory Leaks and Lifecycle Management

This one cost me the most debugging time across all the apps I've shipped. Memory leaks in Flutter don't crash immediately. They build slowly — a few megabytes per session, every session — until the app starts feeling heavy, the OS starts killing it in the background, and users file bug reports about "random crashes."

The root cause is almost always the same: something created inside a widget keeps running after the widget is gone.

Controllers That Are Never Disposed

The most common source of memory leaks I've seen, including in my own code, is controllers that are created in initState and never released. Flutter doesn't clean these up automatically.

class _ProfileScreenState extends State<ProfileScreen> {
  late final TextEditingController _nameController;
  late final AnimationController _fadeController;
  late final ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController();
    _fadeController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    // Every controller created in initState needs to be
    // disposed here. This is not optional — it releases
    // native resources and removes listeners that would
    // otherwise keep this widget's memory alive indefinitely.
    _nameController.dispose();
    _fadeController.dispose();
    _scrollController.dispose();
    super.dispose(); // always last
  }
}

An undisposed AnimationController is particularly bad. It holds a ticker that fires on every frame — so it keeps consuming CPU even after the screen it belonged to is gone. I've seen this cause noticeable battery drain in addition to memory issues.

Stream Subscriptions

class _ChatScreenState extends State<ChatScreen> {
  StreamSubscription<Message>? _messageSubscription;

  @override
  void initState() {
    super.initState();
    _messageSubscription = messageStream.listen((message) {
      // Without cancellation, this callback keeps firing
      // even after the screen is removed from the tree.
      // It will call setState on a disposed widget and
      // hold message objects in memory that should be freed.
      if (mounted) setState(() => messages.add(message));
    });
  }

  @override
  void dispose() {
    _messageSubscription?.cancel();
    super.dispose();
  }
}

Timers

@override
void dispose() {
  // A timer that fires after dispose will try to run
  // a callback on a widget that no longer exists.
  _dismissTimer?.cancel();
  super.dispose();
}

A rule I follow without exception: anything created in initState that has a dispose, cancel, or close method gets a corresponding call in dispose. No exceptions, no "I'll add it later."

Observability and Crash Reporting

Before I integrated crash reporting into my first production app, debugging was genuinely painful. A user would report a crash. I would ask what they were doing. They would say "I just opened it." I would stare at the code looking for anything that could cause that. Half the time I never figured it out.

With crash reporting, that changes completely.

Set it Up Before Launch

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Catch Flutter framework errors — widget build errors,
  // rendering errors, etc.
  FlutterError.onError =
      FirebaseCrashlytics.instance.recordFlutterFatalError;

  // Catch errors in async code that Flutter does not catch —
  // errors in event handlers, timers, isolates.
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}

Never Let Failures Be Silent

// This is how I used to write it. If submitOrder throws,
// nothing happens. The user has no idea. I have no idea.
await api.submitOrder(order);
// This is how I write it now.
try {
  await api.submitOrder(order);
  setState(() => orderStatus = OrderStatus.confirmed);
} catch (e, stackTrace) {
  // recordError sends the full exception and stack trace
  // to Crashlytics, with device info and the user's
  // recent session activity attached automatically.
  FirebaseCrashlytics.instance.recordError(e, stackTrace);
  setState(() => orderStatus = OrderStatus.failed);
}

Breadcrumbs

Raw crash logs tell you what broke. Breadcrumbs tell you what the user was doing when it broke. These aren't the same thing.

FirebaseCrashlytics.instance.log('User opened checkout');
FirebaseCrashlytics.instance.log('Payment sheet presented');
FirebaseCrashlytics.instance.log('User submitted payment');
// crash here — now I know the exact sequence

Testing Production Flutter Apps

I'll be honest: I under-tested my first app. I was moving fast, the features worked, and writing tests felt slow. Then I refactored a pricing calculation, introduced a bug that wasn't immediately obvious, and shipped it. A user caught it before I did.

I test more carefully now. Not everything — but the things that matter.

Unit Test Business Logic

test('discount applies percentage correctly', () {
  final result = calculateDiscountedPrice(
    price: 100.0,
    discountPercent: 10,
  );

  // 10% off 100.00 should be 90.00
  expect(result, equals(90.0));
});

test('discount throws for negative percentage', () {
  expect(
    () => calculateDiscountedPrice(price: 100, discountPercent: -5),
    throwsA(isA<ArgumentError>()),
  );
});

Business logic – pricing, validation, authorization – should be in plain Dart functions with no Flutter dependencies, so they can be tested in milliseconds without any test infrastructure.

Widget Test UI States

Flutter's widget testing is genuinely one of its best features. You can test loading states, error states, and user interactions without a device or emulator.

testWidgets('shows error state with retry button on load failure',
    (tester) async {
  final mockRepo = MockUserRepository();
  when(mockRepo.getUser(any)).thenThrow(Exception('Network error'));

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userRepositoryProvider.overrideWithValue(mockRepo),
      ],
      child: const MaterialApp(home: ProfileScreen(userId: 'test')),
    ),
  );

  // pumpAndSettle waits for all animations and async
  // operations to complete before asserting.
  await tester.pumpAndSettle();

  expect(find.text('Failed to load profile. Please try again.'), findsOneWidget);
  expect(find.text('Try again'), findsOneWidget);
});

What I prioritize testing: core business logic, error and loading states, any flow that involves money or data the user can't recover, and the integration points between my app and the backend. Static UI widgets that contain no logic I generally leave uncovered.

Architecture and Long-Term Maintainability

The first app I shipped had no real architecture. Everything was in widgets. Business logic sat next to UI code. State was scattered.

It worked fine for six months. Then I needed to add a feature that touched several existing screens, and what should have taken a day took a week because I couldn't change anything without breaking something else.

The second app I was more deliberate about. Features in their own folders. Repositories separate from widgets. State managed outside the UI layer. When requirements changed — and they always change — the changes were contained.

Separate Concerns at the Layer Boundary

lib/
  features/
    profile/
      data/
        profile_repository.dart     # network + cache logic
      domain/
        user.dart                   # clean domain model
      presentation/
        profile_screen.dart         # widget
        profile_notifier.dart       # state

Widgets shouldn't make network calls. Repositories shouldn't import Flutter. Neither should know anything about the other's internals.

When you need to swap the data source, or test the notifier with a mock, or change the UI without touching the business logic — this separation is what makes that possible.

Technical Debt Accumulates Faster Than You Expect

A shortcut that saves thirty minutes today tends to cost several hours a month from now. The shortcuts that compound fastest in Flutter:

  • Business logic inside widgets (impossible to test, impossible to reuse)

  • dynamic instead of typed models (runtime errors instead of compile-time errors)

  • Copy-pasted validation logic (change it in one place and forget the others)

  • Mutable global state without clear ownership

None of these are catastrophic on day one. All of them make the next change harder than it should be, and the change after that harder still.

End-to-End Example: a Production-Grade Profile Feature

Here's everything from this article assembled into one feature. A repository with caching and retry, a Riverpod notifier with optimistic updates, a widget that handles all three states, and proper lifecycle management throughout.

The Repository

class ProfileRepository {
  final Dio _dio;
  final Box _cache;

  ProfileRepository(this._dio, this._cache);

  Future<User> getUser(String userId) async {
    try {
      final response = await withRetry(
        () => _dio.get('/users/$userId'),
      );

      final user = User.fromJson(
        response.data as Map<String, dynamic>,
      );

      // Cache successful responses for offline fallback.
      await _cache.put('user_$userId', user.toJson());

      return user;
    } on DioException catch (e) {
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        return User.fromJson(Map<String, dynamic>.from(cached));
      }

      if (e.type == DioExceptionType.connectionError) {
        throw NoInternetException();
      }

      throw ServerException(e.response?.statusCode ?? 0);
    }
  }

  Future<void> updateDisplayName(String userId, String name) async {
    await withRetry(
      () => _dio.patch('/users/$userId', data: {'displayName': name}),
    );

    // Invalidate cache so the next read fetches fresh data.
    await _cache.delete('user_$userId');
  }
}

The Notifier

@riverpod
class ProfileNotifier extends _$ProfileNotifier {
  @override
  AsyncValue<User> build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future<void> _load() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(
      () => ref.read(profileRepositoryProvider).getUser(userId),
    );
  }

  Future<void> refresh() => _load();

  Future<void> updateName(String newName) async {
    final current = state.valueOrNull;
    if (current == null) return;

    try {
      await ref
          .read(profileRepositoryProvider)
          .updateDisplayName(userId, newName);

      // Update the UI immediately without waiting for a reload.
      state = AsyncValue.data(current.copyWith(displayName: newName));
    } catch (e, st) {
      FirebaseCrashlytics.instance.recordError(e, st);
      // Restore the previous state if the update fails.
      state = AsyncValue.data(current);
      rethrow;
    }
  }
}

The Widget

class ProfileScreen extends ConsumerWidget {
  final String userId;
  const ProfileScreen({required this.userId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final profileAsync = ref.watch(profileNotifierProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: profileAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => _ErrorView(
          message: e is NoInternetException
              ? 'No internet connection.'
              : 'Failed to load profile.',
          onRetry: () => ref
              .read(profileNotifierProvider(userId).notifier)
              .refresh(),
        ),
        data: (user) => _ProfileView(user: user, userId: userId),
      ),
    );
  }
}

class _ProfileView extends ConsumerStatefulWidget {
  final User user;
  final String userId;
  const _ProfileView({required this.user, required this.userId});

  @override
  ConsumerState<_ProfileView> createState() => _ProfileViewState();
}

class _ProfileViewState extends ConsumerState<_ProfileView> {
  late final TextEditingController _nameController;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController(text: widget.user.displayName);
  }

  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }

  Future<void> _saveName() async {
    try {
      await ref
          .read(profileNotifierProvider(widget.userId).notifier)
          .updateName(_nameController.text);

      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Name updated.')),
      );
    } catch (_) {
      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Failed to update name.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        TextField(
          controller: _nameController,
          decoration: const InputDecoration(labelText: 'Display name'),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: _saveName,
          child: const Text('Save'),
        ),
      ],
    );
  }
}

Final Thoughts

None of this is particularly advanced. It's mostly habits — checking mounted, disposing controllers, handling the error state, caching for offline. Each habit prevents one specific category of production failure, and together they add up to an app that users experience as reliable.

I wish I'd written my first app this way. I didn't, because I didn't know what I didn't know yet. That is normal.

But if you're reading this before shipping your first production app, you now have the benefit of what took me multiple shipped apps and a lot of frustrated user feedback to learn.

The best time to add these patterns is at the start of a feature. The second-best time is now.