Суть Чтение реальных сниппетов fetching — серверный waterfall, queryKey, RSC-разметка с Suspense и гонка поиска — выбери поведение или фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги data fetching читаются в коде и в панели Network. Прочитай каждый сниппет, предскажи форму round-trip’ов и выбери фикс, который senior-фронтендер делает первым.
Цель
Потренируй цикл, который запускаешь на каждом ревью fetching: проследи, какие запросы последовательны vs параллельны, прочитай ключ кэша как идентичность, заметь RSC-waterfall, прячущийся в цикле, и убей гонку ответов в источнике.
Сниппет 1 — цикл в серверном компоненте
// Server Component — рендерит список из 50 заказовasync function OrderList({ ids }: { ids: string[] }) { const orders = []; for (const id of ids) { orders.push(await fetchOrder(id)); // один await на итерацию } return <ul>{orders.map(o => <Order key={o.id} data={o} />)}</ul>;}
Викторина
Completed
Каждый fetchOrder занимает ~40ms. Каково суммарное время fetch и каков фикс?
Heads-up Параллельно фетчат только соседние Server Components. Явный await внутри цикла выполняется строго последовательно; параллелизм надо выразить через Promise.all.
Heads-up JavaScript никогда не переупорядочивает и не параллелит await'ы в цикле. Каждый await ставит функцию на паузу до разрешения промиса, прежде чем начнётся следующая итерация.
Heads-up Promise.all сохраняет порядок входа в массиве результата, так что упорядоченность бесплатна. Последовательную цену можно избежать; for-await цикл — это дефект.
Сниппет 2 — ключ запроса
function useProduct(id: string, currency: string) { return useQuery({ queryKey: ['product', id], // currency НЕ в ключе queryFn: () => fetchProduct(id, currency), });}
Викторина
Completed
Пользователь переключает валюту с USD на EUR на том же товаре. Что ломается и почему?
Heads-up queryFn действительно замыкает currency, но кэш ключуется только по ['product', id]. Если эта запись свежа, TanStack Query возвращает кэшированное значение вообще без вызова queryFn — новая валюта никогда не фетчится.
Heads-up Ключ не изменился — currency в нём нет — поэтому ничто не инвалидируется. Баг обратный: устаревшее попадание в кэш, а не лишний fetch.
Heads-up staleTime=0 форсит рефетч при монтировании, но сначала всё равно возвращает устаревшее кэшированное USD-значение и смешивает две валюты в одной записи. Идентичность принадлежит ключу; каждый вход, меняющий ответ, должен быть частью queryKey.
Reviews занимает 800ms. Когда пользователь впервые видит заголовок товара и почему?
Heads-up Это блокирующий SSR без Suspense. Здесь Suspense-граница вокруг Reviews позволяет серверу стримить заголовок сразу и отправить чанк отзывов позже.
Heads-up Suspense изолирует за fallback только своё поддерево. h1 находится вне границы и стримится с оболочкой; за скелетоном ждёт только Reviews.
Heads-up Заголовок — это серверно-отрендеренный HTML, видимый до запуска любого JS. Гидрация прикрепляет интерактивность позже; для отрисовки заголовка она не нужна.
Сниппет 4 — поле поиска
function useSearch(query: string) { const [results, setResults] = useState([]); useEffect(() => { fetch(`/api/search?q=${query}`) .then(r => r.json()) .then(setResults); // нет cleanup, нет отмены }, [query]); return results;}
Викторина
Completed
При быстром наборе это периодически показывает результаты более раннего запроса. Каков минимальный корректный фикс?
Heads-up Задержка не упорядочивает ответы — устаревший ответ 'reac' всё равно может прийти последним. Только отмена (или версионирование queryKey) гарантирует победу последнего запроса.
Heads-up useMemo избегает пересчёта значения; он ничего не делает с ответами сети, пришедшими не по порядку. Гонка во времени fetch'а, а не в пересчёте.
Heads-up Fetch во время рендера — худший анти-паттерн: он срабатывает на каждый рендер и по-прежнему без отмены. Фикс — AbortController в cleanup эффекта или библиотека запросов, отменяющая устаревшие ключи.
Итог
Каждый баг fetching виден в коде: await внутри цикла — это серверный waterfall, Promise.all ограничивает его самым медленным вызовом; queryKey должен содержать каждый вход, меняющий ответ, иначе кэш отдаёт неправильную запись; Suspense-граница позволяет оболочке стримиться на TTFB, пока медленные дети разрешаются независимо; а неотменённый fetch в эффекте гоняется на быстром вводе — AbortController в cleanup или библиотека, отменяющая устаревшие ключи, дают победу последнему запросу. Читай код, проследи round-trip’ы, чини структуру прежде чем тянуться к настройке.