I Got Tired of useQuery/Promise.all Spaghetti So I Built This 🫖🦡

I Built a Thing Because My Backend Is a Bunch of Goblins

I use React. I don’t like React. I’m stuck with it.

My backend is a hot bunch of microservices. 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. Three fetches. Thank god for TanStack Query, or I’d have lost my mind years ago.

In a saner world, we’d have Redis, or GraphQL, or a single coherent API. Instead we have goblin microservices 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 with a simple few requests:

const ticket = await fetchTicket('t-1');
const assignee = await fetchUser(ticket.assigneeId);

Then it kinda grows. The ticket now has watchers. The assignee has a team. The team has a lead. We wait a bit and now each of those has nested a few references too. You end up with a blob of Promise.all, useQueries, null checks, and individual fetches. Nothing batched. No deduplication. Types are a mess. Theres a bunch of annoying statuses which rerender a bunch, Every new field is another 10 lines of boilerplate. You look at the mess you made after some time and are sorely disappointed.

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 idea

This is two levels. Pages have dozens of reference fields. The product is quite old, the backend architecture is an old hobgoblin. It’s exhausting.

The Thing I made

@nimir/references — its a type-safe ( undefined/null checks wohoo, we love nullchecks.. ) nested reference resolver. We define sources (how to fetch batches), then we declare which fields are ids. It does the rest: batching, deduplication, caching, nested traversal. Up to 10 levels. Null-safe. Fully typed.

Define sources once:

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) }),
  Role: c.source<Role>({ batch: ids => fetchRoles(ids) }),
}));

Declare what’s a reference:

const result = await refs.inline(ticket, {
  fields: {
    assigneeId: {
      source: 'User',
      fields: {
        teamId: {
          source: 'Team',
          fields: { leadUserId: 'User' },
        },
        roleIds: 'Role',
      },
    },
    watcherIds: 'User',
  },
});

That’s it. All User fetches (assignee, watchers, team lead) get batched into one call. Duplicate IDs are fetched once. Resolved values land at assigneeIdT, watcherIdTs, etc. — the T suffix means “resolved”. Types infer automatically.

React + TanStack Query

Since I’m stuck with React, there’s a React entry point. Wrap your data hook:

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 things.

Caching

Sources support pluggable caches: in-memory, IndexedDB (via idb-keyval), or Redis. TTL, negative caching, cache warming. If you’ve got Redis on the backend (lucky you), you can plug it in. If not, 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 — typo in fields and you get nothing. TypeScript helps, but runtime won’t yell.

Probably My Single Use-Case

I built this for my own mess: REST microservices, IDs everywhere, no GraphQL, no unified backend. If that’s you, (I’m sorry) legacy APIs, third-party services, mixed data sources — maybe it helps. 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. Shout out to my girlfriend who does not understand this, but morally supported me. Cheers, x0x0

GitHub · Docs · npm

Leave a Reply