Экран списка юнитов с фильтрами, сортировкой, поиском и бесконечным скроллом поверх mock API.
Live demo: https://units-explorer.vercel.app
pnpm install
pnpm devПриложение откроется на http://localhost:5173.
Base URL API настраивается через переменную окружения VITE_API_URL (см. .env.example). Если переменная не задана, используется дефолт https://test-api.ltouch.ai — прописан в src/api/config/instance.ts, поэтому проект работает из чистого клона без единого env-файла.
Остальные команды:
pnpm build # tsc + прод-сборка
pnpm preview # локальный просмотр прод-сборки
pnpm typecheck # строгий tsc без эмита
pnpm lint # oxlint (правила кода)
pnpm format # biome: форматирование + сортировка импортовReact 19, TypeScript (strict), Tailwind CSS 4, TanStack Router / Query / Table / Virtual, axios, zod, Vite.
src/
api/
config/ axios-инстанс, интерсепторы ошибок, query-client
units/ эндпоинты домена: units.api.ts + units.types.ts (zod-схемы)
components/ переиспользуемые примитивы, не знающие про домен:
Icon, ToggleChip, NumberRangeInput, SearchInput, StatusBadge
widgets/ доменные сборки из примитивов: UnitsTable, UnitsFilters
pages/ страницы: Units (список), UnitDetails (карточка юнита)
routes/ тонкие file-based роуты TanStack Router
hooks/ общие хуки (useDebouncedCallback)
utils/ cn, форматтеры (цена/комнаты/дата), uniqueById
constants/ словари для UI (подписи статусов)
Слои читаются так: components — кубики, widgets — сборки из кубиков, pages — композиция сборок. Данные загружаются хуками на уровне страницы и спускаются вниз только через props.
URL — единственный источник правды для состояния экрана. Фильтры, сортировка и поиск живут в query-параметрах (validateSearch + zod на роуте). Это даёт reload, back/forward и шаринг ссылки без единой строчки кода синхронизации: /?rooms=[1,2]&sortBy=price&sortDir=asc воспроизводит экран у любого получателя. Невалидные параметры не роняют страницу — каждое поле схемы с .catch(undefined) молча отбрасывает мусор, как это делает и сам API. Отдельного стейт-менеджера в проекте нет намеренно: серверное состояние — в кэше TanStack Query, состояние экрана — в URL, локальные мелочи — в useState. Хранить было бы нечего.
Бесконечный скролл, а не пагинация. Сценарий пользователя — «листать квартиры, пока не понравится», а не «перейти на страницу 47»: подбор жилья — это браузинг, номер страницы здесь ничего не значит. Минус скролла (позиция не шарится ссылкой) компенсирован тем, что фильтры и сортировка в URL сохраняются. Реализация: useInfiniteQuery, догрузка за 5 строк до конца списка.
Виртуализация. В базе 5000 юнитов; без виртуализации после десятка догрузок DOM разросся бы до тысяч строк. @tanstack/react-virtual рисует только видимые ~25 строк, сверху и снизу — два спейсер-<tr>, так что таблица остаётся семантичной. Заголовок sticky.
Честные состояния на каждый случай. Скелетон при первой загрузке; при смене фильтра старые данные остаются на экране приглушёнными (placeholderData: keepPreviousData), а не мигают скелетоном; пустая выдача — отдельное состояние с подсказкой; ошибка первой загрузки — карточка с ретраем (кнопка показывает «Повторяем…» и блокируется); ошибка догрузки — инлайн-строка с ретраем под таблицей, уже загруженные данные не выбрасываются; неудачный фоновый рефетч не стирает таблицу. Устаревшие ответы отменяются: AbortSignal проложен от TanStack Query через axios до реального запроса, поиск дополнительно задебаунсен (400 мс), причём висящий таймер отменяется при сбросе фильтров и back-навигации.
axios-инстанс с двумя интерсепторами. Успешный: API умеет отвечать HTTP 200 с телом {"error": "internal"} — интерсептор ловит это и превращает в исключение, иначе UI показал бы «успех» без данных. Ошибочный: любая сетевая беда нормализуется в единый ApiError { message, status } — таймаут, обрыв, HTTP-код; UI работает с одним предсказуемым типом ошибки.
zod на границе с API. Все ответы парсятся схемами. Толерантность заложена в схему: status может быть null или неизвестной строкой — нормализуется в 'unknown', и таблица честно показывает «Не указан» вместо падения.
react-table с ручной сортировкой. Колонки описаны декларативно через columnHelper, выравнивание и сортируемость — данными в meta колонки. Встроенный sorting-механизм библиотеки не используется: сортирует сервер, а конвертация форматов туда-обратно добавила бы кода больше, чем весь наш вариант. Клик по заголовку циклит asc → desc → сброс и пишет в URL; aria-sort и scope="col" для скринридеров.
Цены в минорных единицах до самого края. API отдаёт копейки (priceMinorUnits: 100 из /api/meta) — в состоянии, URL и запросах цены остаются в копейках, конверсия в рубли происходит только в точках контакта с человеком: форматирование ячейки и поля фильтра. Одно место конверсии — ноль рассинхронов.
| Причуда | Обработка |
|---|---|
status бывает null и недокументированный on_hold |
толерантная zod-схема → 'unknown'; бейджи на все 5 значений; чип фильтра on_hold появляется, если статус пришёл в URL |
Дубликаты id между страницами |
дедуп uniqueById при склейке страниц — без него React-ключи и «двойные» квартиры |
Страница за пределами данных → HTTP 200 с {"error":"internal"} |
интерсептор превращает в ошибку; getNextPageParam дополнительно не шагает за total и на пустую страницу |
| Невалидные query-параметры молча игнорируются | зеркальное поведение на клиенте: .catch(undefined) в схеме URL |
X-Total-Count только в заголовке |
берётся из заголовка с fallback на длину выдачи |
| Цена в минорных единицах | делитель и валюта из /api/meta, форматирование Intl.NumberFormat, защита от невалидного кода валюты |
pageSize молча обрезается до 100 |
размер страницы фиксирован в API-слое (50) |
rooms: 0 означает студию |
единый formatRooms в таблице, фильтрах и карточке |
- Тесты. Инфраструктура готова (vitest + jsdom в конфиге), но за отведённое время я предпочёл вложиться в обработку краевых случаев рантайма. Первым делом покрыл бы чистые функции границы с API:
unitsSearchSchema(мусорные URL), схемыunits.types(грязные ответы) иuniqueById— самая высокая плотность логики на строчку кода. - React Compiler. Не включён из-за влияния на скорость dev-сборки; ручных мемоизаций в проекте две, обе — требование контракта react-table (стабильные ссылки
columns/data), компилятор их не отменил бы. - Красивый URL для массивов. TanStack Router сериализует массивы как JSON (
rooms=%5B1,2%5D). Кастомный кодек сделал бы ссылку читаемее, но добавил бы неоднозначный парсинг — не стоит того для двух параметров. - Ряд улучшений при большем времени: сохранение позиции скролла при возврате с карточки юнита, диапазон-слайдер цены вместо двух полей, колонка
createdAtс сортировкой, e2e-смоук на Playwright.
Mock API задания: https://test-api.ltouch.ai (докуменация — /docs). Используются GET /api/units, GET /api/units/:id, GET /api/meta.