08

Where the complexity goes: REST, its helpers, and Relay

Every approach has to solve the same problems: nested data, caching, consistency, optimistic UI, pagination and types. The question is who solves them: your frontend code, a new backend endpoint, a library you configure, or a declaration the framework acts on. Toggle requirements to see how the bill changes.

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
Hover a cell to see what that approach actually asks of you.

Frontend code you maintain for the checked requirements

illustrative model, see note below

Model: 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

Newest: Mobile feed
FeedPost pageProfileMobile feedGET /postsGET /users/:idGET /posts/:idGET /posts/:id/commentsGET /users/:id/postsGET /users/:id/followersGET /mobile/feed
Endpoints the frontend depends on
7
Created for a single screen
1
Requests to render the newest screen
1

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 lines
function 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 post

REST · TanStack Query

36 lines
function 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 app

GraphQL · Relay

26 lines
function 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.
where REST / tRPC are the better call
  • 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.
where GraphQL + Relay pays off
  • 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.