Page Error State Pattern
Type: How-to guide Audience: Agents and developers adding a new
pages-*page component that fetches data on mount
When to apply
Apply this pattern to any pages-* component that:
- Fetches its primary data client-side on mount
- Renders a blank or broken screen when the fetch fails
- Has a
v-if="isLoading"→v-else-if="<data>"template with nov-else
Confirmed implementations: RestaurantPageMobile.vue (#881), HomeBody.vue (#883).
The three pieces
| Piece | Where | Purpose |
|---|---|---|
queryKeyFactory.ts | packages/shared-service/src/<domain>/ | Stable, typed TanStack query keys |
use<Domain>PageQuery.ts | packages/pages-<domain>/src/<feature>/lib/ | Wraps the fetch in useQuery; converts isSuccess: false → throw |
PageErrorState | packages/shared-ui — @hh-pegasus/shared-ui/PageErrorState | The v-else branch — error UI with retry + back buttons |
Step-by-step
Step 1 — Add a query key factory
Create packages/shared-service/src/<domain>/queryKeyFactory.ts:
export const <domain>QueryKeys = {
all: ["<domain>"] as const,
detail: (slug: string) => ["<domain>", "detail", slug] as const,
};
Follow the shape in packages/shared-service/src/restaurant/queryKeyFactory.ts and packages/shared-service/src/home/queryKeyFactory.ts.
Step 2 — Create use<Domain>PageQuery.ts
Create packages/pages-<domain>/src/<feature>/lib/use<Domain>PageQuery.ts:
import { useQuery } from "@tanstack/vue-query";
import { <domain>QueryKeys } from "@hh-pegasus/shared-service/<domain>/queryKeyFactory";
import { get<Domain>Data } from "@hh-pegasus/shared-api/<domain>";
export function use<Domain>PageQuery(params: ...) {
const query = useQuery({
queryKey: <domain>QueryKeys.detail(params.slug),
queryFn: async () => {
const result = await get<Domain>Data(params);
// Convert the { isSuccess, message } envelope to a throw
// so TanStack sets isError rather than treating it as success
if (!result.isSuccess || !result.data) {
throw new Error(result.message || "Failed to load");
}
return result.data;
},
retry: false,
staleTime: 1000 * 60 * 5,
});
return {
query,
isLoading: query.isLoading,
isFetching: query.isFetching, // needed for retry loading indicator — see Step 4
isError: query.isError,
refetch: () => void query.refetch(),
};
}
Critical: The API envelope ({ isSuccess, message, data }) never throws on its own — network errors set isSuccess: false but return normally. The unwrap pattern above is what turns that into a real error TanStack can track.
If the query function has side-effecting store writes, keep them out of queryFn. Return the raw data and apply store writes in a watchEffect in the component (Step 3).
Step 3 — Migrate the component
Replace the manual onMounted / try-catch / isLoading ref with the composable:
// Before
const isLoading = ref(true);
onMounted(async () => {
try {
const result = await getPageData(...)
if (!result.isSuccess) { handleError(...); return; }
applyToStores(result.data);
} catch (err) {
handleError(...);
} finally {
isLoading.value = false;
}
});
// After
const { query, isLoading, isFetching, isError, refetch } = use<Domain>PageQuery(slug, lang);
// Store writes: reactive, not imperative
watchEffect(() => {
if (query.data.value) applyToStores(query.data.value);
});
// Error reporting: same as before, but driven by TanStack's error ref
watchEffect(() => {
if (query.error.value) handleError({ err: query.error.value, ... });
});
See useGroupLandingPage.ts lines 85–108 as the canonical reference for this watchEffect pattern.
Step 4 — Add PageErrorState to the template
<template>
<YourPageLoadingSkeleton v-if="isLoading || isFetching" />
<PageErrorState v-else-if="isError" @retry="refetch" @back="handleBack" />
<template v-else-if="data">
<!-- existing content unchanged -->
</template>
</template>
Why
isLoading || isFetchingand not justisLoading? TanStack’sisLoadingequalsisPending && isFetching— it istrueonly on the first fetch when no data exists yet. On retry (refetch()),isFetchingbecomestruebutisLoadingstaysfalse(prior error state still exists). WithoutisFetching, the error panel stays visible with no loading feedback while the retry is in flight. Both must be checked so the skeleton reappears on every fetch cycle.
Import: import PageErrorState from "@hh-pegasus/shared-ui/PageErrorState";
For @back, use the navigationAdapterRegistry:
import { navigationAdapterRegistry } from "@hh-pegasus/shared-helper/navigationAdapterRegistry";
function handleBack() {
const adapter = navigationAdapterRegistry.get();
if (adapter) adapter.navigate({ destination: "back" });
}
If the page is a root destination (e.g. home) where back navigation is not applicable, omit @back.
Step 5 — Write unit tests
Create <feature>/lib/__tests__/use<Domain>PageQuery.test.ts. Mock @tanstack/vue-query with an inline useQuery stub that calls queryFn and exposes { data, isLoading, isError, error } refs. Test three paths:
vi.mock("@tanstack/vue-query", async () => {
const { ref } = await import("vue");
return {
useQuery: vi.fn(({ queryFn }) => {
const data = ref(undefined),
isLoading = ref(true);
const isError = ref(false),
error = ref(null);
void (async () => {
try {
data.value = await queryFn();
} catch (e) {
isError.value = true;
error.value = e;
} finally {
isLoading.value = false;
}
})();
return { data, isLoading, isError, error, refetch: vi.fn() };
}),
};
});
Required test cases:
- Success —
isErrorisfalse,query.datacontains the expected shape isSuccess: false—isErrorbecomestrue- Thrown error —
isErrorbecomestrue
See packages/pages-restaurants/src/detail/lib/__tests__/useRestaurantDetailQuery.test.ts and packages/pages-home/src/__tests__/useHomeBodyQuery.test.ts for complete examples.
Reference implementations
| Domain | Query composable | Query key factory |
|---|---|---|
| Restaurant detail | packages/pages-restaurants/src/detail/lib/useRestaurantDetailQuery.ts | packages/shared-service/src/restaurant/queryKeyFactory.ts |
| Home | packages/pages-home/src/useHomeBodyQuery.ts | packages/shared-service/src/home/queryKeyFactory.ts |
Rules and constraints
retry: false— pages do not auto-retry on failure. The user retries manually via the “Try Again” button.- Always expose
isFetching— gate the skeleton onisLoading || isFetching, neverisLoadingalone.isLoadingisfalseon retry; onlyisFetchingistrue. Missing this causes the error panel to stay visible with no loading feedback while the retry is in flight. staleTime: 1000 * 60 * 5— 5-minute cache, consistent with the globalQueryClientdefault configured inapps/ionic/src/main.ts.enabledflag — if the page has SSR hydration (likeHomeBody), passenabled: !props.ssrto disable the query when the server already provided data. Otherwise the query fires redundantly on mount.- No store writes in
queryFn—queryFnmust be pure (return value only). Move store writes to awatchEffectonquery.data.valuein the component. @tanstack/vue-querymust be in the package’sdependencies— checkpackage.jsonbefore adding the composable. Currently present in:pages-restaurants,pages-home.