I Got Tired of useQuery/Promise.all Spaghetti So I Built This š«š¦”
Source: Dev.to
I use React. I donāt like React. Iām stuck with it.
My backend is a hot bunch of microāservices. Lots of them. Referential data everywhere ā tickets, users, teams, roles, watchers, leads, permissions. As the product scales, the frontend turns into a request relay:
- Fetch a ticket.
- Fetch the assignee.
- Fetch the assigneeās team.
- Fetch the team lead.
- Fetch the watchers.
- Fetch the roles.
Each field is an ID, each ID is another roundātrip. Sometimes the same user ID shows up three times in the tree, resulting in three fetches. Thank God for TanStack Query, or I would have lost my mind years ago.
In a saner world weād have Redis, GraphQL, or a single coherent API. Instead we have goblin microāservices with no common ground. Thereās Elasticsearch, but it fumes when you look at it wrong. So here we are.
I got tired, so I built a thing.
The Chore
Your REST API returns IDs. Your UI needs objects. So you write resolution code.
It starts simple:
const ticket = await fetchTicket('t-1');
const assignee = await fetchUser(ticket.assigneeId);Then it grows. The ticket now has watchers. The assignee has a team. The team has a lead. You end up with a blob of Promise.all, useQueries, null checks, and individual fetches. Nothing is batched. No deduplication. Types are a mess. Every new field adds boilerplate and extra renders.
const assignee = ticket.assigneeId ? await fetchUser(ticket.assigneeId) : null;
const watchers = await Promise.all(
(ticket.watcherIds ?? []).map(id => (id ? fetchUser(id) : null))
);
const team = assignee?.teamId ? await fetchTeam(assignee.teamId) : null;
const lead = team?.leadUserId ? await fetchUser(team.leadUserId) : null;
// ⦠you get the ideaPages end up with dozens of reference fields, and the code becomes exhausting.
The Thing I Made
@nimir/references ā a typeāsafe nested reference resolver. You define sources (how to fetch batches) once, then declare which fields are IDs. The library handles batching, deduplication, caching, and nested traversal (up to 10 levels). Itās nullāsafe and fully typed.
Define sources
import { defineReferences } from '@nimir/references';
const refs = defineReferences(c => ({
User: c.source({ batch: ids => fetchUsers(ids) }),
Team: c.source({ batch: ids => fetchTeams(ids) }),
Role: c.source({ batch: ids => fetchRoles(ids) }),
}));Declare a reference map
const result = await refs.inline(ticket, {
fields: {
assigneeId: {
source: 'User',
fields: {
teamId: {
source: 'Team',
fields: { leadUserId: 'User' },
},
roleIds: 'Role',
},
},
watcherIds: 'User',
},
});All User fetches (assignee, watchers, team lead) are batched into a single call. Duplicate IDs are fetched once. Resolved values are attached with a T suffix (assigneeIdT, watcherIdTs, ā¦) and TypeScript infers their types automatically.
React + TanStack Query
Since youāre stuck with React, thereās a React entry point.
import { defineReferences } from '@nimir/references/react';
const useTicket = refs.hook(useGetTicket, {
fields: { assigneeId: 'User', watcherIds: 'User' },
});
function TicketCard({ id }: { id: string }) {
const { result, status, error, invalidate } = useTicket(id);
// result.assigneeIdT ā User | null
}Or resolve inline data reactively:
const resolved = refs.use(data, { fields: { assigneeId: 'User' } });Works with TanStack Query, SWR, or any hook that returns data.
Caching
Sources support pluggable caches: ināmemory, IndexedDB (via idb-keyval), or Redis. You can configure TTL, negative caching, and cache warming. If you have Redis on the backend, plug it in; otherwise, ināmemory or IndexedDB still cuts down repeat fetches.
Caveats
- Depth limit of 10 levels (prevents infinite loops on circular configs).
- Unknown source names are silently skipped ā a typo in
fieldsyields no data. TypeScript helps, but runtime wonāt warn.
Probably My Single UseāCase
I built this for my own mess: REST microāservices, IDs everywhere, no GraphQL, no unified backend. If youāre dealing with legacy APIs, thirdāparty services, or mixed data sources, this might help. If you control the API and can use GraphQL, do that instead.
On the Other Note
It was very fun to build. I started it at work, then it morphed into this openāsource package. Shoutāout to my girlfriend who doesnāt understand this but morally supported me. Cheers!