Where the complexity goes: REST, its helpers, and Relay
Who handles each requirement?
| Requirement (click to toggle) | fetch + useEffect REST, hand-rolled | TanStack Query REST + a cache library | BFF per screen REST shaped for each view | tRPC typed RPC + TanStack Query | GraphQL + Relay fragments + compiler |
|---|---|---|---|---|---|
Frontend code you maintain for the checked requirements
illustrative model, see note belowModel: each you write it cell ≈ 40 lines of frontend code, a new endpoint ≈ 18 frontend lines (plus backend work not counted here), library + glue ≈ 14, declarative ≈ 3 (a directive or an option). These are rough weights for comparing shapes, not a benchmark. The code samples below show where they come from.
The endpoint explosion
REST endpoints are shaped by the first screen that needed them. As screens multiply, you either chain generic endpoints (waterfalls and over-fetching) or add screen-specific ones (a growing surface to version and keep alive). Drag the slider and flip between the two models.
Ship screens, count endpoints
Yellow endpoints were added for the newest screen. Each one needs backend work, docs, versioning, and frontend fetch, cache and type code, and none can be deleted while an old app version still calls it.
The same feature, three ways
A post page with its author and commenters, plus a like button that must stay correct in the feed, the sidebar and the page itself. Code lines exclude comments.
REST · fetch + useEffect
41 linesfunction PostPage({ id }: { id: string }) {
const [post, setPost] = useState<Post | null>(null);
const [author, setAuthor] = useState<User | null>(null);
const [comments, setComments] = useState<CommentWithAuthor[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const post = await get<Post>(`/posts/${id}`); // round trip 1
const [author, raw] = await Promise.all([
get<User>(`/users/${post.authorId}`), // round trip 2
get<Comment[]>(`/posts/${id}/comments`),
]);
const ids = [...new Set(raw.map((c) => c.authorId))];
const users = await Promise.all(ids.map((u) => get<User>(`/users/${u}`))); // 3 (N+1)
const byId = new Map(users.map((u) => [u.id, u]));
if (cancelled) return;
setPost(post);
setAuthor(author);
setComments(raw.map((c) => ({ ...c, author: byId.get(c.authorId)! })));
} catch (e) {
if (!cancelled) setError(e as Error);
}
})();
return () => { cancelled = true; };
}, [id]);
if (error) return <ErrorView error={error} />;
if (!post || !author) return <Spinner />;
return (
<PostView post={post} author={author} comments={comments}
onLike={async () => {
const prev = post;
setPost({ ...post, likeCount: post.likeCount + 1, viewerHasLiked: true });
// The feed, sidebar and profile each hold their own copy of this post:
eventBus.emit("post:liked", post.id);
try { await post_(`/posts/${id}/like`); }
catch { setPost(prev); eventBus.emit("post:unliked", post.id); }
}}
/>
);
}
// + types for Post, User, Comment kept in sync with the API by hand
// + an eventBus listener in every component that shows a postREST · TanStack Query
36 linesfunction PostPage({ id }: { id: string }) {
const post = useQuery({ queryKey: ["post", id], queryFn: () => get<Post>(`/posts/${id}`) });
const author = useQuery({
queryKey: ["user", post.data?.authorId],
queryFn: () => get<User>(`/users/${post.data!.authorId}`),
enabled: !!post.data, // waterfall, by design
});
const comments = useQuery({ queryKey: ["comments", id], queryFn: () => get<Comment[]>(`/posts/${id}/comments`) });
const authors = useQueries({
queries: [...new Set(comments.data?.map((c) => c.authorId) ?? [])].map((u) => ({
queryKey: ["user", u],
queryFn: () => get<User>(`/users/${u}`),
})),
});
const qc = useQueryClient();
const like = useMutation({
mutationFn: () => post_(`/posts/${id}/like`),
onMutate: async () => {
// Every cache entry that might contain this post has to be patched:
await qc.cancelQueries({ queryKey: ["post", id] });
const prev = qc.getQueryData<Post>(["post", id]);
qc.setQueryData<Post>(["post", id], (p) => p && { ...p, likeCount: p.likeCount + 1, viewerHasLiked: true });
qc.setQueriesData<InfiniteData<Post[]>>({ queryKey: ["feed"] }, (feed) => patchPostInPages(feed, id));
qc.setQueryData<Post>(["topPost"], (p) => (p?.id === id ? { ...p, likeCount: p.likeCount + 1 } : p));
return { prev };
},
onError: (_e, _v, ctx) => {
qc.setQueryData(["post", id], ctx?.prev);
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["topPost"] });
},
onSettled: () => qc.invalidateQueries({ queryKey: ["post", id] }),
});
if (post.error || author.error || comments.error) return <ErrorView />;
if (post.isPending || author.isPending || comments.isPending || authors.some((a) => a.isPending)) return <Spinner />;
return <PostView post={post.data} author={author.data} comments={joinAuthors(comments.data, authors)} onLike={() => like.mutate()} />;
}
// + patchPostInPages, joinAuthors, query-key conventions shared across the appGraphQL · Relay
26 linesfunction PostPage({ id }: { id: string }) {
const { post } = useLazyLoadQuery<PostPageQuery>(graphql`
query PostPageQuery($id: ID!) {
post(id: $id) { ...PostView_post }
}
`, { id });
return post ? <PostView post={post} /> : <NotFound />;
}
function LikeButton({ post }: { post: LikeButton_post$key }) {
const data = useFragment(graphql`
fragment LikeButton_post on Post { id likeCount viewerHasLiked }
`, post);
const [commit] = useMutation<LikeButtonMutation>(graphql`
mutation LikeButtonMutation($input: LikePostInput!) {
likePost(input: $input) { post { id likeCount viewerHasLiked } }
}
`);
return (
<button onClick={() => commit({
variables: { input: { postId: data.id, like: true } },
optimisticResponse: {
likePost: { post: { id: data.id, likeCount: data.likeCount + 1, viewerHasLiked: true } },
},
})}>♥ {data.likeCount}</button>
);
}
// PostView, AuthorLine and CommentItem each declare their own fragment.
// One request. Loading via Suspense. Types generated. Every view of the post updates.- Small apps with a handful of screens and little shared data.
- Public, cache-heavy APIs where HTTP/CDN caching per URL matters most.
- File uploads, webhooks and streaming media: plain HTTP is simpler there.
- A TypeScript monorepo where tRPC's inferred types cover you with zero schema tooling.
- Many screens over the same entities, built by several teams.
- Mobile and web clients that need different shapes of the same data.
- Heavily interactive UIs where one change must show up everywhere, instantly.
- Long-lived products where refactoring components mustn't mean renegotiating endpoints.
- why it mattersThe complexity doesn't vanish with GraphQL. It moves into a schema on the server and a compiler on the client, both written once instead of per screen.
- why it mattersLibraries like TanStack Query solve caching and request state well. What they can't know is that
["post", id]and the feed contain the same object. A normalized store can. - why it mattersBFFs and tRPC fix round trips by moving aggregation to the server, and pay for it with an endpoint or procedure per screen.