Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 no v-else

Confirmed implementations: RestaurantPageMobile.vue (#881), HomeBody.vue (#883).


The three pieces

PieceWherePurpose
queryKeyFactory.tspackages/shared-service/src/<domain>/Stable, typed TanStack query keys
use<Domain>PageQuery.tspackages/pages-<domain>/src/<feature>/lib/Wraps the fetch in useQuery; converts isSuccess: false → throw
PageErrorStatepackages/shared-ui — @hh-pegasus/shared-ui/PageErrorStateThe 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 || isFetching and not just isLoading? TanStack’s isLoading equals isPending && isFetching — it is true only on the first fetch when no data exists yet. On retry (refetch()), isFetching becomes true but isLoading stays false (prior error state still exists). Without isFetching, 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:

  1. Success — isError is false, query.data contains the expected shape
  2. isSuccess: false — isError becomes true
  3. Thrown error — isError becomes true

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

DomainQuery composableQuery key factory
Restaurant detailpackages/pages-restaurants/src/detail/lib/useRestaurantDetailQuery.tspackages/shared-service/src/restaurant/queryKeyFactory.ts
Homepackages/pages-home/src/useHomeBodyQuery.tspackages/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 on isLoading || isFetching, never isLoading alone. isLoading is false on retry; only isFetching is true. 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 global QueryClient default configured in apps/ionic/src/main.ts.
  • enabled flag — if the page has SSR hydration (like HomeBody), pass enabled: !props.ssr to disable the query when the server already provided data. Otherwise the query fires redundantly on mount.
  • No store writes in queryFn — queryFn must be pure (return value only). Move store writes to a watchEffect on query.data.value in the component.
  • @tanstack/vue-query must be in the package’s dependencies — check package.json before adding the composable. Currently present in: pages-restaurants, pages-home.