A React + TypeScript app that loads house listings from the HomeVision staging API in an infinite-scrolling, virtualized grid. The implementation focuses on production-style concerns that matter for a long, paginated feed: keeping scrolling smooth as more pages load, handling a flaky API without throwing away already-loaded content, and degrading gracefully when listing images fail.
- React 19 with TypeScript
- Vite for dev server and production bundle
- TanStack Query (
useInfiniteQuery) for pagination, caching, and retry - TanStack Virtual for window-based row virtualization
- Tailwind CSS + shadcn/ui primitives for layout and components
- Vitest, Testing Library, and MSW for unit and integration tests
- Node.js (see
.node-version/.nvmrc) - pnpm (see
package.json#packageManager)
cd homevision
pnpm install --frozen-lockfileNotes:
- If you see
Ignored build scripts: msw@...duringpnpm install, this is expected in some pnpm configurations that block dependency install scripts by default. MSW is used in Node-based tests (msw/node) and does not rely on a browser Service Worker in this project.
pnpm devOpens the Vite dev server (default: http://localhost:5173).
pnpm test # watch mode
pnpm test -- --run # single run (CI-style)pnpm e2eNotes:
- If Playwright has not downloaded Chromium yet, run
pnpm exec playwright install chromiumonce and then re-runpnpm e2e.
pnpm lintpnpm buildpnpm previewpnpm validatesrc/api/client.ts—fetchHouses; normalizes the API shape (photoURL→photoUrl) and throws a structuredApiErroron HTTP failures.src/hooks/use-houses.ts— WrapsuseInfiniteQuery: flattens pages, deduplicates by id, configures retries for transient failures, and maps errors to a plain string (errorMessage) so UI components never importApiError.src/hooks/use-grid-layout.ts— SingleResizeObserveron the grid container; derivescolumnCountfrom actual container width andscrollMarginfromoffsetTop. Replaces hardcoded JS breakpoints.src/components/house-grid.tsx— Responsive virtualized rows using CSS Gridauto-fill; prefetches the next page near the end of loaded rows; composes cards, skeletons, andInlineError.src/components/house-card.tsx,house-image.tsx— Presentation; images use lazy loading and anonErrorfallback so broken URLs do not break the grid.
main.tsx wraps the app in QueryClientProvider with shared defaults for retries, exponential backoff, and a short staleTime.
Errors are normalized at the API layer (ApiError in client.ts) and mapped to a user-facing string inside useHouses, which exposes errorMessage: string | null. UI components never import ApiError — they render plain strings.
- Initial load failure: A full-screen
InlineErrorwith a "Try again" button. - Later page failure: Already-loaded cards stay on screen; an inline error banner appears below.
handleRetrycallsfetchNextPagewhen houses exist, orrefetchfor an empty list — this invariant ensures the right recovery path is taken. - Retry strategy (Query layer): Up to 3 retries with exponential backoff for transient 5xx / network errors. 4xx errors are not retried. The API client stays thin and throws normalized errors; retry logic lives in React Query to avoid double-retries.
useHouses flattens the loaded pages and deduplicates listings by id before rendering. This is a small defensive step, but it helps a production feed stay stable if paginated responses overlap or the upstream API returns repeated records.
This app uses infinite scroll, so the number of loaded listings can keep growing during a session. Without virtualization, every fetched card would remain mounted in the DOM, which increases layout work, memory usage, and the cost of re-rendering as the list grows. Virtualization keeps the scroll experience stable by only mounting the rows near the viewport while preserving the same infinite-feed behavior.
@tanstack/react-virtual virtualizes rows of cards — the TanStack-recommended pattern for responsive grids. Column count is not hardcoded via JS breakpoints; useGridLayout uses a callback ref so a ResizeObserver runs as soon as the width-constrained container mounts and derives columns from measured container width. This keeps the virtualizer's row math in sync with CSS Grid auto-fill without duplicating Tailwind breakpoints in JS.
Each virtual row renders its items inside a CSS Grid container (grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))]), so the browser controls column sizing and JS only supplies the numeric column count needed for row slicing. useWindowVirtualizer also receives the measured scrollMargin, which keeps the visible-row calculation aligned with the document scroll position instead of assuming the grid starts at the top of the page.
This adds some implementation and testing complexity, but it is a deliberate trade-off for an infinite list: the feed should remain responsive even after many pages have been loaded.
Broken photoUrl values are handled in HouseImage (fallback asset), separate from API retries — network/API errors and image load errors are different failure modes.
Unit tests mock @tanstack/react-virtual entirely so every virtual row renders unconditionally. This validates data-flow, error UI, image fallback behavior, and retry logic without requiring a real browser viewport. Scroll integration, estimateSize, scrollMargin, and prefetch thresholds are not covered by unit tests — an optional Playwright or E2E spec is recommended for regression safety on those paths.
The shell uses semantic regions (header, main). Cards use article where appropriate. Loading and error announcements use live regions; retry controls are keyboard-focusable. Image alt text prefers address or homeowner when available.
After pnpm build && pnpm preview:
- Scroll the grid; additional pages should load.
- If the API misbehaves, errors should appear with recovery; already loaded items should remain visible after a failed "next page."
- Broken listing images should show the fallback graphic, not a broken image icon.
- Keep scrolling through multiple pages; the feed should remain responsive instead of feeling heavier as more results accumulate.
Run the combined check anytime:
pnpm validateThis runs lint + vitest run + build in sequence.