TL;DR: Когда ваш бэкенд — это зоопарк микросервисов с кучей перекрёстных ссылок, стандартные подходы к data fetching превращаются в ад. Мы создали типобезопасный резолвер ссылок @nimir/references, который умеет батчить, дедуплицировать и кешировать запросы.
Контекст проблемы
В современных React-приложениях с богатыми доменными моделями часто встречается ситуация, когда данные содержат множество перекрёстных ссылок. Типичный пример:
interface Ticket {
id: string;
title: string;
assigneeId: string | null;
watcherIds: string[];
// ... другие поля
}
Чтобы отобразить тикет полностью, нам нужно:
- Загрузить сам тикет
- Загрузить assignee по assigneeId
- Загрузить всех watchers по watcherIds
- Для каждого watcher’а возможно загрузить его team
- Для каждой 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 };
};
Проблемы:
- Нет дедупликации запросов (один и тот же user может запрашиваться несколько раз)
- Сложно добавлять новые уровни вложенности
- Ошибки в null-check’ах
- Нет единого места для управления кешированием
Реализация с 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 }
);
// ... и так далее для каждого уровня вложенности
};
Проблемы:
- Множественные ререндеры при последовательном разрешении зависимостей
- Сложность в управлении общими зависимостями
- Огромное количество boilerplate-кода
Наше решение: @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;
};
Ключевые особенности
- Батчинг запросов: Все запросы одного типа объединяются в один batch
- Дедупликация: Каждый ID запрашивается только один раз, независимо от количества упоминаний
- Типобезопасность: Полная поддержка TypeScript с корректным выводом типов
- Гибкое кеширование: Поддержка 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>
);
}
Производительность и оптимизации
- Параллельное разрешение ссылок: Глубина вложенности не влияет на время выполнения
- Умный кеш: Запросы кешируются на уровне отдельных ID
- Оптимизированные ререндеры: React-хуки минимизируют количество обновлений
// Пример с кастомным кешем
const refs = defineReferences(c => ({
User: c.source<User>({
batch: ids => fetchUsers(ids),
cache: {
ttl: 60_000, // 1 минута
store: new RedisCache({ /* конфиг */ })
}
})
}));
Когда стоит использовать это решение
- Работа с legacy REST API без GraphQL
- Множество микросервисов с перекрёстными ссылками
- Сложные доменные модели с глубокими связями
- Когда важна производительность при работе с вложенными данными
Альтернативы и когда они лучше
- GraphQL: Если вы контролируете API — лучше использовать его
- Backend-for-frontend: Когда можно вынести логику разрешения ссылок на бэкенд
- 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