Победа над спагетти из useQuery и Promise.all в React

#react#typescript#performance#data-fetching

TL;DR: Когда ваш бэкенд — это зоопарк микросервисов с кучей перекрёстных ссылок, стандартные подходы к data fetching превращаются в ад. Мы создали типобезопасный резолвер ссылок @nimir/references, который умеет батчить, дедуплицировать и кешировать запросы.

Контекст проблемы

В современных React-приложениях с богатыми доменными моделями часто встречается ситуация, когда данные содержат множество перекрёстных ссылок. Типичный пример:

interface Ticket {
  id: string;
  title: string;
  assigneeId: string | null;
  watcherIds: string[];
  // ... другие поля
}

Чтобы отобразить тикет полностью, нам нужно:

  1. Загрузить сам тикет
  2. Загрузить assignee по assigneeId
  3. Загрузить всех watchers по watcherIds
  4. Для каждого watcher’а возможно загрузить его team
  5. Для каждой team загрузить lead… и так далее

Традиционные подходы и их недостатки

Наивная реализация с Promise.all

const loadTicketData = async (ticketId: string) => {
  const ticket = await fetchTicket(ticketId);
  
  const [assignee, watchers] = await Promise.all([
    ticket.assigneeId ? fetchUser(ticket.assigneeId) : null,
    Promise.all(ticket.watcherIds.map(fetchUser))
  ]);
  
  const team = assignee?.teamId ? await fetchTeam(assignee.teamId) : null;
  const lead = team?.leadUserId ? await fetchUser(team.leadUserId) : null;
  
  return { ticket, assignee, watchers, team, lead };
};

Проблемы:

Реализация с TanStack Query

const useTicketData = (ticketId: string) => {
  const { data: ticket } = useQuery(['ticket', ticketId], () => fetchTicket(ticketId));
  
  const { data: assignee } = useQuery(
    ['user', ticket?.assigneeId],
    () => ticket?.assigneeId ? fetchUser(ticket.assigneeId) : null,
    { enabled: !!ticket?.assigneeId }
  );
  
  // ... и так далее для каждого уровня вложенности
};

Проблемы:

Наше решение: @nimir/references

Базовый пример использования

import { defineReferences } from '@nimir/references';

const refs = defineReferences(c => ({
  User: c.source<User>({ batch: ids => fetchUsers(ids) }),
  Team: c.source<Team>({ batch: ids => fetchTeams(ids) }),
  Ticket: c.source<Ticket>({ batch: ids => fetchTickets(ids) }),
}));

const resolveTicket = async (ticketId: string) => {
  const result = await refs.inline(
    { id: ticketId }, // начальные данные
    {
      fields: {
        id: { source: 'Ticket', fields: {
          assigneeId: {
            source: 'User',
            fields: {
              teamId: {
                source: 'Team',
                fields: { leadUserId: 'User' }
              }
            }
          },
          watcherIds: 'User'
        }}
      }
    }
  );
  
  return result;
};

Ключевые особенности

  1. Батчинг запросов: Все запросы одного типа объединяются в один batch
  2. Дедупликация: Каждый ID запрашивается только один раз, независимо от количества упоминаний
  3. Типобезопасность: Полная поддержка TypeScript с корректным выводом типов
  4. Гибкое кеширование: Поддержка in-memory, IndexedDB и Redis кешей

React-интеграция

import { defineReferences } from '@nimir/references/react';

const refs = defineReferences(/* ... */);

const useResolvedTicket = refs.hook(useTicketQuery, {
  fields: {
    assigneeId: {
      source: 'User',
      fields: { teamId: 'Team' }
    },
    watcherIds: 'User'
  }
});

function TicketView({ ticketId }: { ticketId: string }) {
  const { data, isLoading, error } = useResolvedTicket(ticketId);
  
  if (isLoading) return <Loader />;
  if (error) return <ErrorView error={error} />;
  
  // data.assigneeIdT - полностью типизированный User | null
  // data.watcherIdsT - User[]
  return (
    <div>
      <h1>{data.title}</h1>
      <AssigneeCard user={data.assigneeIdT} />
      <WatchersList watchers={data.watcherIdsT} />
    </div>
  );
}

Производительность и оптимизации

  1. Параллельное разрешение ссылок: Глубина вложенности не влияет на время выполнения
  2. Умный кеш: Запросы кешируются на уровне отдельных ID
  3. Оптимизированные ререндеры: React-хуки минимизируют количество обновлений
// Пример с кастомным кешем
const refs = defineReferences(c => ({
  User: c.source<User>({
    batch: ids => fetchUsers(ids),
    cache: {
      ttl: 60_000, // 1 минута
      store: new RedisCache({ /* конфиг */ })
    }
  })
}));

Когда стоит использовать это решение

  1. Работа с legacy REST API без GraphQL
  2. Множество микросервисов с перекрёстными ссылками
  3. Сложные доменные модели с глубокими связями
  4. Когда важна производительность при работе с вложенными данными

Альтернативы и когда они лучше

  1. GraphQL: Если вы контролируете API — лучше использовать его
  2. Backend-for-frontend: Когда можно вынести логику разрешения ссылок на бэкенд
  3. React Query + manual batching: Для простых случаев может быть достаточно

Заключение

@nimir/references — это не серебряная пуля, но мощный инструмент для специфического сценария работы с вложенными ссылками в данных. Он особенно полезен в legacy-проектах, где нет возможности изменить архитектуру бэкенда.

Решение уже используется в продакшене и показало сокращение количества запросов на 60-80% в сложных сценариях. Код открыт и доступен на GitHub для feedback и contributions.

Для простых проектов может быть избыточным, но когда ваш бэкенд действительно напоминает “сборище гоблинов” — это может быть именно тем, что нужно.


Источник: https://dev.to/mimikkk/i-got-tired-of-usequerypromiseall-spaghetti-so-i-built-this-2n73