Skip to Content
Design systemLoading, error and refresh states

Loading, error and refresh states

How a screen says “still loading”, “it broke”, and “let me try again”.

Before this existed, 76 files dropped a raw CircularProgressIndicator inline, WorkoutState used isError/errorMessage while FinanceState used error: String?, most ViewModels stored e.message and threw away the typed failure the API layer had already produced, and no screen had a consistent full-screen error with a retry. Desktop and web had no way to refresh at all.

Everything below is enforced by scripts/check_loading_states.sh (wired into verifyAndroidQuality as checkLoadingStates), whose baseline is now empty.


The state model

One sealed type, in ui/state/UiState.kt:

sealed interface UiState<out T> { data object Loading : UiState<Nothing> data class Success<T>( val data: T, val isRefreshing: Boolean = false, val refreshError: RemoteFailure? = null, ) : UiState<T> data class Error(val failure: RemoteFailure) : UiState<Nothing> }

The invariant that makes everything else mechanical:

Error means the screen is empty. Anything with data on it is Success.

Success carries refreshError precisely so a failed refresh keeps the stale data on screen instead of blanking it. A three-case type cannot express that, and blanking a workout mid-session because a background poll failed is not acceptable.

SituationState
First load, nothing yetLoading
First load failed, no dataError(failure)
Data presentSuccess(data)
Refresh in flightSuccess(data, isRefreshing = true)
Refresh failed, data preservedSuccess(data, refreshError = failure)

UiState<T> wraps one independently-loadable slice, never a whole screen. A screen with N independent loads holds N UiState fields; transient UI (search text, selected tab, dialog visibility) sits beside them as plain fields. FinanceState has six slices, so a failing Budgets tab leaves the Accounts tab beside it untouched. WorkoutState has one, plus a separate actionError for failures that are actions rather than loads.

Helpers so ViewModels never re-derive the transitions: reduce(outcome), toRefreshing(), settled(), dataOrNull(), isRefreshing().


Which primitive, when

This is the whole taxonomy. The guard bans CircularProgressIndicator in screens because the right answer is almost never “a spinner”.

The situationUseWhy
First load, nothing on screenOterSkeletonList / OterSkeletonCard / OterSkeletonTextReserves the shape the content will occupy, so nothing jumps when it arrives
Refresh over existing contentOterRefreshBox (gesture) + OterRefreshButton (desktop/web)The content stays; the indicator says work is happening
In-place action of unknown duration — a submitting button, a row awaiting a mutationOterInlineSpinnerThere is no content shape to stand in for, so a skeleton would be a lie
Determinate progressOterProgressRing / OterProgressBarIt’s not a load at all
Load failed, nothing to showApiErrorState(failure, onRetry)Full-screen, localized, with an honest retry
Load failed, stale content still validOterBanner over the contentNever blank what the user was reading
Nothing to show, but nothing failedOterEmptyStatePredates this work; unchanged

RestTimerBar is the case worth remembering: it was CircularProgressIndicator(progress = progress, …) — the workout rest countdown, not a load. A blanket find-and-replace would have turned a progress ring into a spinner. Read the call site.

Skeletons are shaped, and they can be per-slice

A skeleton exists to reserve the geometry the real content will take, so nothing jumps when it lands. OterSkeletonList is the right default for a list; it is the wrong answer for a page with its own layout. DashboardScreen has a private DashboardSkeleton that mirrors its hero, KPI strip and bento grid — column count included, so the grid doesn’t reflow on arrival.

If a screen’s slices load independently, shimmer them independently. The dashboard fetches nine widgets in nine requests; its isLoading is any { it is Loading }, so one slow widget used to hold every other card behind a full-screen skeleton. It now also exposes loadingWidgets: StateFlow<Set<String>>, and each card renders either itself or an OterSkeletonCard in its own grid cell. A slow Finance widget shimmers alone while Tasks, Habits, Nutrition, Workout and Journal are already usable.


Which failure gets which surface

Encoded once in api/ApiErrorCodes.kt as isRetryable() / isSessionExpired() / isConnectivity(). The state case already tells you the surface; the code refines whether a retry is honest.

ApiErrorCodesFirst load (UiState.Error)Refresh failure (Success.refreshError)Retry?
CLIENT_NETWORK, CLIENT_TIMEOUTFull-screen ApiErrorStateGlobal snackbar only — no bannerYes
SERVER_ERROR, INTERNAL_ERROR, UNKNOWN, CLIENT_ERROR_UNREADABLEFull-screen ApiErrorStateInline OterBannerYes
NOT_FOUNDFull-screen, not-found copyn/aNo
FORBIDDENFull-screenBannerNo
UNAUTHORIZEDRenders nothingNothingNo
BAD_REQUEST, INVALID_*Full-screenBannerNo

Two rules that are easy to get wrong:

A 401 renders nothing. ApiErrorState returns early. The token refresh in HttpClientConfig and SessionExpiredNotifier own that path, and a user-driven retry would race the in-flight refresh.

Connectivity failures never show a banner over stale content. The global network snackbar already reported them; a banner would be the same news twice. ApiErrorState does show full-screen for a first-load connectivity failure, and claims it via ScreenErrorOwnership so the snackbar stands down. The snackbar waits SCREEN_CLAIM_WINDOW_MS (350ms) before firing, because the API funnel reports a failure the instant the request fails — before the ViewModel writes its state and a frame before the screen composes.

Action errors are not load errors. A set that wouldn’t save, a track that wouldn’t delete: those go to actionError and a snackbar. They must never blank the screen. Workout had ~16 of these conflated with load failures before this work.


Refresh: gesture on touch, button on desktop

One hoisted state object feeds two sibling affordances. The screen owns both, so nothing needs a CompositionLocal:

val refresh = rememberOterRefreshState( isRefreshing = state.isLoading && state.items.isNotEmpty(), onRefresh = viewModel::refresh, ) OterPageScaffold(title = "…", toolbar = { OterRefreshButton(refresh) }) { OterRefreshBox(refresh) { /* the list */ } }

OterRefreshBox uses Material3 PullToRefreshBox on touch and is a transparent passthrough elsewhere. OterRefreshButton renders nothing on touch, so a screen can place it unconditionally.

The branch is prefersPullToRefresh()not isDesktop(), whose wasm actual is false, which would wrongly enable the gesture in a browser:

TargetprefersPullToRefresh()Affordance
Android, iOStruepull gesture
Desktop (JVM), wasmfalsetoolbar button

isRefreshing must be wired, or the gesture looks broken

isRefreshing = state.isLoading && state.items.isNotEmpty()

A load with nothing on screen is a first load (skeleton). A load with items already visible is a refresh (indicator). Tasks shipped for months with this hardcoded to false: the gesture fired, the data reloaded, and PullToRefreshBox retracted the indicator the instant the finger lifted, so it read as “nothing happened”.


Refresh coverage, and why

Refresh is not a blanket feature. It belongs on a screen that lists server data the user might expect to be stale. It does not belong on a form, a dialog, or a screen whose data was handed to it by its parent.

Has it (10)

TasksScreen · WorkScreen · HabitsScreen · BooksScreen · MealDetailScreen · NutritionScreen (three tabs, each with its own retry) · StudyScreen (three tabs) · DashboardScreen · WorkoutScreen (per view) · FinanceScreen (per tab)

DashboardScreen is the reference for a screen with no page toolbar: the refresh button is a TopEnd-aligned overlay inside the OterRefreshBox, which costs no layout on touch because OterRefreshButton renders nothing there.

WorkoutScreen and FinanceScreen are the reference for a screen with several views or tabs: the refresh action reloads only the visible one (onDaySelected / onLoadWeeklyPlan / onGetAllExercises; getBudgets / getAccounts / …), and the toolbar button reflects that view’s own slice, so it spins only while that view is reloading.

settled() is not optional. updateContent {} and its equivalents preserve isRefreshing on purpose — they exist for enrichments that land during a refresh. When a success path is the end of a refresh, it has to settle explicitly. loadWeeklyPlan didn’t, and the Week tab’s indicator spun forever once a pull could start one. Pinned by WorkoutStateTest.

Deliberately excluded

Study’s History tab. HistoryTab owns its own filtered load — date range, topic, mode — and that filter state lives inside the composable. A blind reload would silently drop the user’s filters. Fixing it means hoisting the filter state, not adding a wrapper.

Forms, dialogs and wizardsRecipeEditScreen, StudySessionFormScreen, NewEdit*, TransactionImportScreen, ReceiptScanScreen, the Work*Dialog family. There is nothing to refresh; the user is editing.

AuthLoginScreen, SignUpScreen, AuthScreen, ForgotEmailScreen, ForgotNewPasswordScreen. No remote list. These use OterInlineSpinner in their submit buttons.

Local-only state — placeholders, ComponentGalleryScreen.

Not wired yet, and why not

These load server data and plausibly should have refresh. They don’t, because a refresh needs a reload callback plumbed from the ViewModel to the screen, and each is per-screen work rather than a wrapper. Listed so nobody has to rediscover them:

ListsNotificationsScreen · CalendarScreen · StatisticsScreen · ProjectsScreen · JournalScreen · WorkTicketsScreen · WorkClientsScreen · WorkStoreScreen · WorkStatsScreen · WorkoutHistoryScreen (desktop) · DesktopBooksScreen · DesktopProjectsScreen · DesktopTagsScreen · TagsScreen · TimersScreen

Detail screens that load their own dataTaskDetailScreen, HabitDetailScreen, ProjectDetailScreen, WorkTicketDetailScreen, SearchRecordDetailScreens. Each does a LaunchedEffect(id) { … } of its own, so refresh is meaningful here, exactly as it is on MealDetailScreen (which does have it). They are only unwired, not excluded.

Of these, the Work family (WorkTicketsScreen, WorkClientsScreen, WorkStoreScreen, WorkStatsScreen) are the cheapest: each already has a state.isLoading and a load callback, and they share one shape.

Unverified on a device. prefersPullToRefresh() is false on the JVM, so no JVM test harness can reach PullToRefreshBox through OterRefreshBox — the mechanism was verified by driving PullToRefreshBox directly with a real swipeDown. The seven wired screens have not been pulled on Android or iOS hardware.


Typed failures, and the two places they’re still lossy

ApiOutcome / RemoteFailure / HttpFailureMapper already produce typed error codes. Two boundaries still discard them:

Services that throw. About half the services predate ApiOutcome and throw (TaskServiceTaskServiceException). apiOutcomeOf { … } maps them via HttpFailureMapper.fromThrowable, which walks the cause chain and reads a real Ktor ResponseException’s status when there is one. Accurate — but the fix belongs upstream: those services should return ApiOutcome via safeApiCall.

Six Finance source ViewModels store error: String?. FinanceState is an aggregate assembled from them, so financeSlice(data, isLoading, error) has to synthesise a RemoteFailure with an UNKNOWN code, keeping the original text in debugMessage. Users get correct generic copy and a working retry; the specific server code doesn’t survive. When those ViewModels adopt UiState, financeSlice collapses into a plain forward.

Both are documented at the call sites. Neither is a reason to reach for a raw spinner.


The guard

scripts/check_loading_states.sh — report-only by default, --strict in CI.

Scope: shared/…/ui/screens/**, composeApp/…/desktopMain/…/ui/screens/**, and every *_presentation source tree. ui/components/primitives/** is exempt: it is the kit, and it’s where the spinner legitimately lives.

Its baseline (config/loading-states-guard-baseline.txt) was seeded with the 39 screens that predated the kit and is now empty. A new entry is a regression to fix, not something to record.

The legacy ui/components/Loading.kt and ui/components/Error.kt were deleted once it emptied. Error.kt never had a single caller.

See also