In a flaky-network Flutter app, how should a screen keep showing the last good response after a refresh fails, and which states does it need?
answer
- first load versus refresh failure
- stale data plus an error, together
- overwrite only on parsed success
- fetchedAt drives the stale label
- map exceptions to user messages
basics
~20 sKeep the last successfully parsed response and its fetch time, and on a failed refresh show that data marked stale with a non-blocking error and retry. Only a failure with nothing cached gets a full-screen error.
solid answer
~40 sI model the screen state so that "data plus a refresh error" is a first-class case, for example a Dart 3 sealed class with `Loading`, `Ready(items, fetchedAt, refreshError)` and `Failed(error)`. The repository returns fresh data on success and overwrites the last-good copy only after the response has parsed; on failure it returns the cached copy with the error attached. The UI keeps the list, shows "Last updated 07:42" and a banner or snackbar with Retry, and translates the exception — connection error, timeout, 5xx, 401 — into a message the user can act on. A full-screen error with a retry button is reserved for a first load with nothing cached.
code
dart · 45 linessealed class SitesState {
const SitesState();
}
final class SitesLoading extends SitesState {
const SitesLoading();
}
final class SitesReady extends SitesState {
const SitesReady(this.sites, this.fetchedAt, {this.refreshError});
final List<String> sites;
final DateTime fetchedAt;
final String? refreshError; // non-null: showing last good data
}
final class SitesFailed extends SitesState {
const SitesFailed(this.message);
final String message;
}
abstract interface class LastGoodStore {
Future<(List<String>, DateTime)?> read();
Future<void> write(List<String> sites, DateTime at);
}
class SitesRepository {
SitesRepository(this._fetch, this._store, this._describe);
final Future<List<String>> Function() _fetch; // throws on failure
final LastGoodStore _store;
final String Function(Object error) _describe;
Future<SitesState> refresh() async {
try {
final sites = await _fetch(); // decoded models, 2xx only
final now = DateTime.now();
await _store.write(sites, now); // overwrite only on success
return SitesReady(sites, now);
} catch (e) {
final cached = await _store.read();
if (cached == null) return SitesFailed(_describe(e));
final (sites, at) = cached;
return SitesReady(sites, at, refreshError: _describe(e));
}
}
}go deeper
Recall that a failed refresh should keep old data visible with a Retry, rather than blanking the screen.
Explain the state model — loading, ready, ready with a refresh error, failed — and the rule that the cache is written only after a successful parse.
Show how you classify exceptions into user messages, label staleness with the fetch time, scope the cache per user and keep read fallbacks separate from queued writes.
Weigh how stale data may safely be shown for each screen, and when acting on stale data is dangerous enough to require a fresh fetch first.
## The problem On a spotty connection most refreshes that fail are **refreshes**, not first loads: the app already fetched the data successfully minutes or hours ago. A screen that replaces its content with an error page every time a refresh fails punishes the user for the network and hides data they could still use. The pattern that fixes this is a **last-good cache**: remember the most recent successful response, keep showing it, and be honest that it may be stale. ## The states a screen needs The classic mistake is three independent fields — `isLoading`, `error`, `data` — that can drift into impossible combinations and make "stale data plus error" an accident. A Dart 3 `sealed` class makes each state explicit, and a `switch` over it must handle every case: | State | Data on screen | What the UI shows | |---|---|---| | `Loading` (nothing cached) | none | a progress indicator | | `Ready` with no error | fresh or cached items | the list and a "Last updated" time | | `Ready` with `refreshError` | the last good items | the list, a stale label, a banner with Retry | | `Failed` (nothing cached) | none | a full-screen message with Retry | A refresh in progress over existing data is `Ready` plus a small indicator, never a return to the empty `Loading` state. ## Rules for the cache itself 1. **Overwrite only on success.** Write the cached copy after the response came back 2xx **and** decoded into models. Caching a 500 error body or a half-parsed payload poisons the fallback. 2. **Store the fetch time.** `fetchedAt` drives the stale label and lets you decide when cached data is too old to show without a warning. 3. **Scope it per user.** On logout, clear it, or the next account sees the previous user's data. 4. **Keep it small.** A last-good copy is one response per screen, not a general-purpose cache; how long to keep it and where to store it is a caching-strategy decision. ## Turning exceptions into error states Raw exception text such as `DioException [connection error]` is not a message. Classify the failure once, in the repository or a mapper, into a small set the UI can speak to: - **Offline or unreachable** — `DioExceptionType.connectionError`, `connectionTimeout`, or `package:http`'s `ClientException`: "Couldn't reach the server. Showing data from 07:42." - **Slow** — `receiveTimeout`: "The server is taking too long." with Retry. - **Server trouble** — 5xx: "The service is having problems." Retrying later is reasonable. - **Auth** — 401 after refresh failed: send the user to sign in; retrying will not help. - **Bad request** — other 4xx: a bug or a validation message, not a network problem. Keep the original exception for logs; show the mapped message to people. ## UI behaviour that holds up - Never wipe visible data because a refresh failed. - Prefer a banner or snackbar to a modal dialog for refresh failures; a dialog on every flaky request trains users to dismiss dialogs. - Offer a Retry that re-runs the same call, and let pull-to-refresh do the same. - Mark stale data visually with the time, so a surveyor does not act on yesterday's site list believing it is current. ## In the field-survey app The morning's list of assigned sites is fetched at the depot on good Wi-Fi. Out in the fields a refresh fails with a connection error; the list stays, a banner says it was last updated at 07:42, and Retry is one tap away. If the app is opened for the first time with no signal, there is nothing to show, so the screen explains that it needs a connection once and offers Retry. Submitting completed forms while offline is a separate problem of queuing writes, not of displaying reads.
- Why should the cached copy never be written from an error response?The cache exists to be the fallback when requests fail. Writing a 5xx body, an empty list from a failed parse, or a partial payload replaces good data with bad, and the next failure then shows the user garbage or nothing. Write only after a 2xx response has decoded into models.
- When is a full-screen error the right choice?Only when there is nothing useful to show — a first load with no cached copy, or cached data too old to trust. Then the whole screen explains what is needed, such as a connection once, and offers Retry. With data on screen, errors belong in a banner or snackbar.
saying these in an interview costs you the question
- Replace the list with an error page whenever a refresh fails.
- Cache every response body, including 500 errors, so something is always available.
- Show the exception's toString() so users see exactly what went wrong.
- Stale data needs no label; users will refresh if they care.
- A modal dialog on every failed refresh is the clearest error state.