) {
+ const {
+ data,
+ isLoading,
+ isPending,
+ isFetching,
+ isError,
+ error: queryError,
+ refetch,
+ } = query;
+
+ const retry = (): void => {
+ refetch?.();
+ };
+
+ // 1. **Error wins** — an explicit error short-circuits even if a
+ // cached `data` is present, because the cache may be stale and
+ // misleading.
+ const hasError = isError === true || queryError != null;
+ if (hasError) {
+ const err = queryError as TError;
+ const node =
+ typeof error === "function"
+ ? (error as (e: TError, r: () => void) => ReactNode)(err, retry)
+ : (error ?? );
+ return {node}
;
+ }
+
+ // 2. **Loading** — only when we don't already have data. Falling
+ // back to React Query's `isPending` keeps support for v5 callers.
+ const loading =
+ (isLoading === true || isPending === true) && data === undefined;
+ if (loading) {
+ return (
+
+ {skeleton ?? }
+
+ );
+ }
+
+ // 3. **Empty** — `data` resolved, but it's "nothing to show". We
+ // only render the empty slot when one is provided; otherwise we
+ // fall through to `children(data)` so callers can decide.
+ if (data !== undefined && empty !== undefined) {
+ const checker = (isEmpty ?? DEFAULT_EMPTY) as (d: TData) => boolean;
+ if (checker(data)) {
+ return {empty}
;
+ }
+ }
+
+ // 4. **Success** — render the body. If a `stale` slot is provided
+ // and a background refetch is happening (we have data AND
+ // `isFetching`), show it alongside the body.
+ if (data !== undefined) {
+ const isStale = isFetching === true;
+ return (
+
+ {stale ? stale(data, isStale) : null}
+ {children(data)}
+
+ );
+ }
+
+ // 5. **Indeterminate** — neither error, nor loading, nor data.
+ // Render nothing so we don't flash an empty placeholder.
+ return null;
+}
diff --git a/apps/web/src/shared/components/ui/index.ts b/apps/web/src/shared/components/ui/index.ts
index 6dc305be4..e85a681f1 100644
--- a/apps/web/src/shared/components/ui/index.ts
+++ b/apps/web/src/shared/components/ui/index.ts
@@ -149,6 +149,9 @@ export type { SkeletonProps } from "./Skeleton";
export { SkeletonCard, SkeletonList } from "./SkeletonCard";
export type { SkeletonCardProps, SkeletonListProps } from "./SkeletonCard";
+export { DataState } from "./DataState";
+export type { DataStateProps, DataStateQueryLike } from "./DataState";
+
export { SkipLink } from "./SkipLink";
export type { SkipLinkProps } from "./SkipLink";
diff --git a/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md b/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md
index 5b0bfa363..b77f92259 100644
--- a/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md
+++ b/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md
@@ -1,6 +1,6 @@
# Web deep-dive — Overview
-> **Last validated:** 2026-05-03 by @Skords-01.
+> **Last validated:** 2026-05-04 by @Skords-01.
> **Status:** Active
> **Scope:** `apps/web` + `apps/server` + `packages/*` (mobile — лише дотичні точки).
> **Related:**
@@ -81,7 +81,7 @@
| 2 | Sentry tag `requestId` + UI shows on 5xx | 3 | 1 | 3.00 | [04 §4.4](./04-security-observability-testing-devx.md) — done [#1551](https://github.com/Skords-01/Sergeant/pull/1551) |
| 3 | CSP report-only on Vercel | 3 | 1 | 3.00 | [04 §6.4](./04-security-observability-testing-devx.md) — done [#1551](https://github.com/Skords-01/Sergeant/pull/1551) |
| 4 | Module prefetch on hover + on-idle | 3 | 1 | 3.00 | [03 §5.2 + §10.4](./03-backend-and-performance.md) — done (idle + connection gate) |
-| 5 | `` wrapper | 4 | 2 | 2.00 | [01 §3.2](./01-frontend-ergonomics.md) |
+| 5 | `` wrapper | 4 | 2 | 2.00 | [01 §3.2](./01-frontend-ergonomics.md) — done (component + 10 tests) |
| 6 | `localStorage` 17 → 0 codemod | 4 | 2 | 2.00 | [02 §2.2](./02-architecture-and-state.md) |
| 7 | Audit docs status-table + archive >6-mo | 2 | 1 | 2.00 | [04 §8.5](./04-security-observability-testing-devx.md) |
| 8 | Form-engine unification | 5 | 3 | 1.67 | [01 §3.1](./01-frontend-ergonomics.md) |
diff --git a/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md b/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md
index d8facdf4c..6e699910f 100644
--- a/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md
+++ b/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md
@@ -1,6 +1,6 @@
# Web deep-dive — Frontend ergonomics & UX
-> **Last validated:** 2026-05-03 by @Skords-01.
+> **Last validated:** 2026-05-04 by @Skords-01.
> **Status:** Active
> **Scope:** Forms, loading/empty/error states, Toast, Modal, mobile safe-area, PWA install banner, auth error translation, i18n readiness, feature-reveal pattern.
> **Related:** [`00-overview.md`](./00-overview.md), `docs/audits/UX-UI-AUDIT-2026.md`, `docs/audits/UX-IMPROVEMENT-PLAN.md`.
@@ -54,6 +54,8 @@
## 3.2 [Bad] Loading states — не бачу system-wide skeleton policy
+> **2026-05-04 update.** Wrapper доданий: `` у `apps/web/src/shared/components/ui/DataState.tsx` + 10 contract tests у `DataState.test.tsx`. Precedence error → loading → empty → success зафіксовано тестом, slot-и описані у JSDoc на пропсах. Експорт через UI-barrel (`shared/components/ui/index.ts`). Наступний крок — refactor високотрафічних екранів (`MonoTransactionsPanel`, `BudgetPanel`, `RoutineList`) на цей wrapper в окремих PR-ах.
+
**Що бачу.** Є `ModulePageLoader.tsx`, `PageTransition.tsx`, окремі `... loading` тексти всередині сторінок. Кожна сторінка вирішує сама. Skeleton-component-library немає; немає й уніфікованої політики «коли skeleton, коли spinner, коли nothing».
**Recommendation / fix points.**