Translucent data cards composing into a single light stream on a developer's monitor, with a gray-blue cat watching from the desk corner.

Fate: Relay's Best Ideas, Minus the GraphQL Tax

The Gray Cat
The Gray Cat
0 views

If you have ever wired up data fetching in a React app, you know the ritual. Every screen gets its own useQuery, each one juggling loading flags, error states, and a cache key you hope is unique. After a mutation you reach for setQueryData or invalidateQueries and patch the cache by hand, praying you covered every place that data appears. Server data gets prop-drilled three components deep, dragging along a parade of types created purely to pass it along. It works, but it is a lot of plumbing.

Fate (react-fate) proposes a different shape for this problem. Built by Christoph Nakazawa, the creator of Jest and an alum of the original React and Relay teams at Meta, Fate takes the ideas that made Relay so pleasant to work with — co-located data requirements, fragment composition, a normalized cache, and data masking — and applies them to plain TypeScript. No GraphQL. No query language. No codegen compiler. It works over tRPC, a native HTTP protocol, or an existing GraphQL server, and the data requirements you write are just TypeScript objects.

A quick honesty note before we dive in: Fate is brand new. The first commit landed in October 2025, the 1.0 release shipped in May 2026, and at the time of writing the README still carries an "alpha, not production ready" warning even as a 1.x line ships features at a rapid clip. It also requires React 19.2 or newer, which is a hard gate on who can adopt it today. Treat this article as a tour of an exciting young project, not a recommendation to rewrite your production app this afternoon.

A Third React Primitive

React gives you components and hooks. Fate adds a third building block: the view. A view is a declarative description of exactly which fields a component needs, co-located with that component. It is Fate's answer to a GraphQL fragment, except it is a plain object where you opt into each field by setting it to true.

import { view } from 'react-fate';

type Post = { content: string; id: string; title: string };

export const PostView = view<Post>()({
  content: true,
  id: true,
  title: true,
});

That is the whole concept. PostView declares that any component using it cares about a post's content, id, and title — and nothing else. There is no schema file, no .graphql document, and no build step that has to run to make this real. It is just JavaScript.

The payoff comes from how views behave together. They compose upward through your component tree to a single root, where Fate makes exactly one network request for the whole screen. No more per-component waterfalls. And because each component only sees the fields it selected, you get genuine data masking, enforced both by TypeScript and at runtime.

Reading and Fetching Data

A component consumes a view with the useView hook. It takes the view you defined and an opaque reference to a concrete object, then hands back precisely the masked slice of data.

import { useView, ViewRef } from 'react-fate';

export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
  const post = useView(PostView, postRef);
  return (
    <Card>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </Card>
  );
};

The ViewRef<'Post'> you pass in is an opaque handle to a specific object — it carries the id, the __typename, and some Fate bookkeeping. You pass it around like any other prop, but you cannot read fields off it directly. Only useView can unmask the data, and only the fields your view selected.

The actual network call happens at the root, with useRequest. This is where a screen declares the shape of what it needs and triggers the single combined fetch.

import { useRequest } from 'react-fate';

export function App() {
  const { posts } = useRequest({ posts: { list: PostView } });
  return posts.map((post) => <PostCard key={post.id} post={post} />);
}

Notice that App never sees a post's title or content. It receives a list of references and hands each one to a PostCard. The parent orchestrates, the child reads. Loading is handled by Suspense and errors bubble to the nearest error boundary, so you write the happy path and let React's async machinery handle the rest.

Composing Views Like Fragments

The real elegance shows up when views nest. Suppose a post has an author, and you want to render the author's avatar inside the post card. You define a UserView and drop it straight into PostView as the value of the author field.

export const UserView = view<User>()({
  id: true,
  name: true,
  profilePicture: true,
});

export const PostView = view<Post>()({
  author: UserView,
  content: true,
  id: true,
  title: true,
});

When PostView is fetched, the author's fields ride along in the same request, normalized into the cache as a separate User record. The component rendering the post never touches the author's name — only a dedicated AuthorBadge using UserView can. Data requirements stay next to the components that own them, and the whole tree still collapses into one round trip.

If you need to combine several views over the same object, Fate supports spreads, just like fragment spreads in GraphQL.

export const PostView = view<Post>()({
  author: { ...UserView, ...UserStatsView, bio: true },
  content: true,
  id: true,
  title: true,
});

Selecting the same field through multiple composed views is deduplicated at runtime with no TypeScript conflicts, so you can layer requirements freely without worrying about who selected what.

The Normalized Cache That Updates Itself

Here is where Fate earns its keep. Every object you fetch is stored once in a normalized cache, keyed by its __typename plus its id. Lists and root queries hold references to those records rather than copies of them. When you mutate a Post, that single record updates, and every view selecting a changed field re-renders — automatically, everywhere it appears on screen.

This is the antidote to manual cache patching. There is no setQueryData, no invalidation key juggling, no hunting down the four places a comment count is displayed. The cache is the single source of truth and updates propagate by virtue of normalization.

The reactivity is fine-grained, too. useView subscribes only to the fields it selected. If a post's likes count changes but a particular card only selected title, that card does not re-render. You get field-level precision for free.

Cache lifetime is managed for you as well. A mounted useRequest retains its records; unmounting releases them and schedules garbage collection. A release buffer keeps the most recently released requests around (ten by default, tunable via gcReleaseBufferSize) so flicking back and forth between two screens stays cheap. For tests or memory-sensitive contexts you can set the buffer to 0.

Mutations Through Actions, Not Hooks

Fate deliberately ships no useMutation hook. Instead it leans into React 19's Actions. Server mutations are auto-exposed to the client as both fate.actions.* (for use with useActionState and Action props) and fate.mutations.* (imperative, awaitable, usable outside React).

const fate = useFateClient();
const [result, like] = useActionState(fate.actions.post.like, null);

return (
  <Button action={() => like({ input: { id: post.id } })}>
    Like
  </Button>
);

Because mutations write to the normalized cache, the like count updates everywhere automatically once the server responds. But you usually do not want to wait for the round trip — which is where optimistic updates come in.

like({
  input: { id: post.id },
  optimistic: { likes: post.likes + 1 },
});

The UI updates instantly, and if the request fails, Fate rolls the change back for you. No manual snapshot-and-restore. You can insert brand new objects optimistically with a temporary id and control where they land in a list via insert: 'before' | 'after' | 'none', with pagination boundaries and the ordering of multiple pending inserts respected. A delete: true option removes a record, and a view: option lets an action pull back extra changed fields, like a recomputed commentCount.

For everything outside the React render cycle, the imperative path is a plain await.

const result = await fate.mutations.comment.add({
  input: { content, postId: post.id },
});

Pagination and Live Data

Lists get first-class, connection-style cursor pagination through useListView. You describe the connection shape — its arguments, its items, and its pagination metadata — and Fate hands back the items plus loaders for the next and previous pages.

const CommentConnectionView = {
  args: { first: 10 },
  items: { cursor: true, node: CommentView },
  pagination: {
    hasNext: true,
    hasPrevious: true,
    nextCursor: true,
    previousCursor: true,
  },
};

const [comments, loadNext, loadPrevious] = useListView(
  CommentConnectionView,
  post.comments,
);

When loadNext comes back undefined, you have reached the end — perfect for an infinite scroll or a load-more button.

For real-time data, Fate ships live views with zero ceremony. Swap useView for useLiveView and the reference stays current through a single native Server-Sent Events stream merged into the cache.

const post = useLiveView(PostView, postRef);

There is one SSE connection per client, with subscribe and unsubscribe control messages sent automatically as components mount and unmount. Live deletes prune the record from the cache and from any list referencing it. There is a useLiveListView for live collections, too. No polling loops, no manual refetch timers.

Wiring Up the Client and Server

Fate is a full-stack proposition: the server has to cooperate by exposing data in a way the client can navigate, with each object carrying a stable id and __typename. On the client, setup is a provider around your tree.

import { FateClient } from 'react-fate';
import { createFateClient } from 'react-fate/client';

const fate = createFateClient({
  fetch: (input, init) => fetch(input, { ...init, credentials: 'include' }),
  url: `${SERVER_URL}/fate`,
});

<FateClient client={fate}>{children}</FateClient>;

On the backend you have real flexibility. There is a native HTTP protocol that needs no tRPC at all, a tRPC adapter you can adopt incrementally by adding byId and list queries alongside your existing procedures, a GraphQL integration for teams already invested in a schema, and Void, Nakazawa Tech's own router with a Vite plugin. The templates demonstrate both Drizzle and Prisma, and server-side mutations are auto-exposed to the client by Fate's Vite plugin. The fastest way to see it all working is the scaffolding tool, which lets you pick your client and backend combination:

vp create fate my-app

Or add the packages by hand:

npm add react-fate
yarn add react-fate

For Vue there is vue-fate, and @nkzw/fate provides the framework-agnostic core and server runtime.

Where Fate Fits

The clearest way to place Fate is between the tools you already know. Against React Query and SWR, Fate trades their request-keyed document cache for a true normalized graph, which means it eliminates manual cache patching after mutations and adds genuine data masking those libraries do not have. Against Relay, Fate keeps the co-located fragments, the normalized cache, and the single-request-per-screen ergonomics, but drops the GraphQL server, the query language, and the relay compiler — running instead over tRPC or plain TypeScript. It is, in the author's framing, Relay's ergonomics without the GraphQL tax.

There is a charming provenance detail worth mentioning: the author reports that roughly eighty percent of Fate's code was written by an AI coding agent, with the minimal and predictable API surface deliberately designed so that both humans and AI tools can reason locally about each component's exact data needs. The explicit per-component selection is a feature for codegen as much as for people.

Whether Fate is right for you today comes down to timing. The React 19.2 requirement and the alpha label in the README are real constraints, and weekly downloads are still modest as the project finds its footing. But the design is coherent, the cadence is fast, and the pedigree is serious. If you run a tRPC stack and have ever envied Relay's data layer while balking at the GraphQL investment, Fate is the most direct answer anyone has shipped — and it is well worth keeping an eye on as it matures.