_fate_ is a modern data client for React inspired by Relay and GraphQL. It combines view composition, normalized caching, data masking, Async React features, and type-safe data fetching.
Features
View Composition: Components declare their data requirements using co-located "views". Views are composed into a single request per screen, minimizing network requests and eliminating waterfalls.
Normalized Cache: fate maintains a normalized cache for all fetched data. This enables efficient data updates through actions and mutations and avoids stale or duplicated data.
Data Masking & Strict Selection: fate enforces strict data selection for each view, and masks (hides) data that components did not request. This prevents accidental coupling between components and reduces overfetching.
Async React: fate uses modern Async React features like Actions, Suspense, and use to support concurrent rendering and enable a seamless user experience.
Lists & Pagination: fate provides built-in support for connection-style lists with cursor-based pagination, making it easy to implement infinite scrolling and "load-more" functionality.
Optimistic Updates: fate supports declarative optimistic updates for mutations, allowing the UI to update immediately while the server request is in-flight. If the request fails, the cache and its associated views are rolled back to their previous state.
Live Views: fate can keep individual view refs up to date through a single native Server-Sent Events stream, merging updates into the normalized cache.
AI-Ready: fate's minimal, predictable API and explicit data selection enable local reasoning, enabling humans and AI tools to generate stable, type-safe data-fetching code.
A modern data client for React
_fate_ is designed to make data fetching and state management in React applications more composable, declarative, and predictable. The framework has a minimal API, no DSL, and no magic—_it's just JavaScript_.
GraphQL and Relay introduced several novel ideas: fragments co‑located with components, a normalized cache keyed by global identifiers, and a compiler that hoists fragments into a single network request. These innovations made it possible to build large applications where data requirements are modular and self‑contained.
Nakazawa Tech builds apps and games primarily with GraphQL and Relay. We advocate for these technologies in talks and provide templates (server, client) to help developers get started quickly.
However, GraphQL comes with its own type system and query language. If you are already using tRPC or another type‑safe RPC framework, it's a significant investment to adopt and implement GraphQL on the backend. This investment often prevents teams from adopting Relay on the frontend.
Many React data frameworks lack Relay's ergonomics, especially fragment composition, co-located data requirements, predictable caching, and deep integration with modern React features. Optimistic updates usually require manually managing keys and imperative data updates, which is error-prone and tedious.
_fate_ takes the great ideas from Relay and applies them to plain TypeScript data fetching. You get type safety between the client and server, a native protocol with optional adapters such as tRPC, and GraphQL-like ergonomics for data fetching. Using _fate_ usually looks like this:
_Learn more about fate's core concepts or create an app from one of the templates._
Getting Started
Template
Create a new fate app with Vite+:
vp create fate my-app
Explore the fate stack to see the tools included in your new project.
The template selector can create a React or Vue client for a Void app with Drizzle, a tRPC app with Drizzle or Prisma, a GraphQL app with Prisma, or a fate client for an existing GraphQL server. React is the default UI framework; pass --framework vue or choose Vue in the template selector to create a Vue app. The template sources live in the fate repo under packages/create-fate/templates/fate. They feature modern tools to deliver an incredibly fast development experience.
Manual Installation
For a React client, install react-fate. It requires React 19.2+:
::: code-group
``bash [npm]
npm add react-fate
bash [pnpm]
pnpm add react-fate
bash [yarn]
yarn add react-fate
:::
For a Vue client, install vue-fate:
::: code-groupbash [npm]
npm add vue-fate
bash [pnpm]
pnpm add vue-fate
bash [yarn]
yarn add vue-fate
:::
If your server is a separate package, install
@nkzw/fate there as a runtime dependency too. Install @nkzw/fate on the client only for a barebones integration without a framework adapter:
::: code-group
bash [npm]
npm add @nkzw/fate
bash [pnpm]
pnpm add @nkzw/fate
bash [yarn]
yarn add @nkzw/fate
:::
> [!WARNING]
>
> _fate_ is currently in alpha and not production ready. If something doesn't work for you, please open a pull request.
If you'd like to try the example app in GitHub Codespaces, click the button below:
Core Concepts
_fate_ has a minimal API surface and is aimed at reducing data fetching complexity.
Thinking in Views
In fate, each component declares the data it needs using views. Views are composed upward through the component tree until they reach a root, where the actual request is made. fate fetches all required data in a single request. React Suspense manages loading states, and any data-fetching errors naturally bubble up to React error boundaries. This eliminates the need for imperative loading logic or manual error handling.
Traditionally, React apps are built with components and hooks. fate introduces a third primitive: views – a declarative way for components to express their data requirements. An app built with fate looks more like this:
With fate, you no longer worry about _when_ to fetch data, how to coordinate loading states, or how to handle errors imperatively. You avoid overfetching, stop passing unnecessary data down the tree, and eliminate boilerplate types created solely for passing server data to child components.
> [!NOTE]
> Views in _fate_ are what fragments are in GraphQL.
Views
Defining Views
Let's start by defining a simple view for a blog's
Post component. fate requires you to explicitly "select" each field that you plan to use in your components. Here is how you can define a view for a Post entity that has title and content fields:
tsx
import { view } from 'react-fate';
type Post = {
content: string;
id: string;
title: string;
};
Fields are selected by setting them to true in the view definition. This tells _fate_ that these fields should be fetched from the server and made available to components that use this view.
> [!NOTE]
> The Post type above is an example. In a real application, this type is defined on the server and imported into your client code.
Resolving a View with useView
Now we can use the view that we defined in a PostCard React component to resolve the data against a reference of an individual Post:tsx
import { useView, ViewRef } from 'react-fate';
A ViewRef is a reference to a concrete object of a specific type, for example a Post with id 7. It contains the unique ID of the object, the type name (as __typename) and some fate-specific metadata. fate creates and manages these references for you, and you can pass them around your components as needed.
Components using
useView listen to changes for all selected fields. When data changes, fate re-renders all of the fields that depend on that data. For example, if the title of the Post changes, the PostCard component re-renders with new data. However, if a different field such as likes that isn't selected in PostView changes, the PostCard component will not re-render.
Fetching Data with
useRequest
Now that we defined our view and component, we fetch the data from the server using the
useRequest hook from fate. This hook allows us to declare what data we need for a specific screen or component tree. At the root of our app, we can request a list of posts like this:
tsx
import { useRequest } from 'react-fate';
import { PostCard, PostView } from './PostCard.tsx';
In the above example we are defining a single view for a
Post. One of fate's core strengths is view composition. Let's say we want to show the author's name along with the post. A simple way to do this is by adding an author field to the PostView with a concrete selection:
tsx
import { Suspense } from 'react';
import { useView, ViewRef } from 'react-fate';
This code fetches the author associated with the Post and makes it available to the PostCard component. However, this approach has some downsides:
The
author selection is tightly coupled to the PostView. If we want to use the author's data in another component, we would need to duplicate the field selection.
If the
author has more fields that we want to use in other components, we would need to add them to the PostView, leading to overfetching.
We cannot reuse the
author field selection in other views or components.
In fate, views are composable and reusable. Instead of inlining the selection, we can define a UserView and compose it into the PostView like this:
tsx
import type { Post, User } from '@your-org/server/views';
import { view } from 'react-fate';
When building complex UIs, you will often build multiple components that share the same data requirements. In fate, you can use view spreads to compose such views together. This is similar to GraphQL fragment spreads, but works with plain JavaScript objects.
Let's assume we want to fetch and display additional information about the author in the
PostCard, such as their bio. Instead of directly assigning our UserView to the author field, we can instead spread it and add the bio field:
We can also spread multiple views together. For example, if we have another view called UserStatsView that selects some statistics about the user, we can include it in the PostView like this:
Views are opaque objects. Even if you select the same field multiple times through different views, the composed object won't have conflicting fields or result in TypeScript errors. fate automatically deduplicates fields during runtime and ensures that each field is only fetched once.
useView and Suspense
We learned that useRequest is responsible for fetching data from the server and useView is used for reading data from the cache. In some situations data may not be available in the cache and useView might need to suspend the component to fetch only the missing data. Once that data is fetched and written to the cache, the component resumes rendering.
_Tip: You can test this behavior in development mode with Fast Refresh (HMR) enabled in your bundler. When you edit the selection of a view, components using that view will suspend, fetch the missing data, and then resume rendering._
Type Safety and Data Masking
fate provides guarantees through TypeScript and during runtime that prevent you from accessing data that wasn't selected in a component. This ensures that you declare all the data dependencies at the right level in your component tree, and prevents accidental coupling between components.
In the below example, we forgot to select the content of a Post. As a result, type-checks fail and the content field is undefined during runtime:tsx
const PostView = view()({
id: true,
title: true,
// content: true is omitted.
});
{/ TypeScript errors here, and post.content is undefined during runtime /}
{post.content}
);
};
Views can only be resolved against refs that include that view directly or via view spreads. If a component tries to resolve a view against a ref that isn't linked, it will throw an error during runtime:
const PostDetail = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
// This throws because the post reference passed into this component
// is of type AnotherPostView, not PostDetailView.
const post = useView(PostDetailView, postRef);
};
ViewRefs carry a set of view names they can resolve. useView throws if a ref does not include the required view.
Requests
Requesting Lists
The useRequest hook can be used to declare our data needs for a specific screen or component tree. At the root of our app, we can request a list of posts like this:tsx
import { useRequest } from 'react-fate';
import { PostCard, PostView } from './PostCard.tsx';
This component suspends or throws errors, which bubble up to the nearest error boundary. Wrap your component tree with ErrorBoundary and Suspense components to show error and loading states:
tsx
Loading…
}>
> [!NOTE]
>
> useRequest may issue multiple operations in the same render pass. fate transports can batch those operations into fewer network requests: the native HTTP transport batches same-microtask operations into one POST /fate request, and the tRPC adapter can use tRPC's HTTP Batch Link.
Requesting Objects by ID
If you want to fetch data for a single object instead of a list, you can specify the
Request arguments are part of the cache key. Two list requests for the same root with different filters or sorting arguments keep separate list state, and cursor arguments are merged into the same list when you load more pages. The selected view is part of the key as well: requesting PostCardView and PostDetailView can share normalized records, but fate still tracks whether the specific fields for each request are present.
Request Modes
useRequest supports different request modes to control caching and data freshness. The available modes are:
cache-first (_default_): Returns data from the cache if available, otherwise fetches from the network.
stale-while-revalidate: Returns data from the cache and simultaneously fetches fresh data from the network.
network-only: Always fetches data from the network, bypassing the cache.
You can pass the request mode as an option to useRequest:
fate stores records in a normalized cache keyed by
__typename and id. Lists and root queries point at those records, and views read from the normalized cache. When a useRequest call is mounted, fate retains the request so the records and lists needed by that screen stay in memory. When the component unmounts, the request is released and fate schedules garbage collection.
Released requests are kept in a small release buffer before their data becomes collectible. This makes common route transitions cheap: navigating away from a screen and quickly coming back usually reuses the cached records instead of refetching them. The default release buffer stores the 10 most recently released requests.
Set gcReleaseBufferSize to 0 in tests or very memory-sensitive environments when released screens should be collected immediately.
cache-first request handles are stable while their request is cached. If garbage collection later removes the data for a fulfilled request, the next cache-first request automatically fetches it again rather than returning stale references.
If you call
fate.request(...) outside React and need the result to stay in memory across manual gc() calls, retain the same request for the lifetime of that work:
try {
const { posts } = await fate.request(request);
// Use posts while this request is retained.
} finally {
retained.dispose();
}
Garbage collection waits for active optimistic updates to settle before sweeping records. This keeps temporary optimistic records and their list positions stable while mutations are still pending.
SSR and Hydration
Create a request-scoped fate client on the server, preload the route data, and dehydrate its normalized cache:
Transport the returned value through your framework's loader serialization, React Server Component props, or a safely escaped JSON bootstrap script. The snapshot contains plain serializable values, so serializers such as Seroval can carry it without fate-specific integration. Treat the snapshot as opaque: hydrate it through fate rather than reading or editing its internal data.
On the browser, hydrate the new client before rendering components that call
Hydrated cache-first requests resolve from the normalized cache without refetching. Hydration restores records, selected-field coverage, root queries, and list pagination state. It intentionally does not restore active requests, subscriptions, retainers, timers, or optimistic mutation state.
Snapshots carry a hydration scope and are rejected by clients with a different scope. Generated clients set a stable scope automatically. When constructing a client directly, pass
hydrationScope and rotate it when deploying an incompatible cache schema or when separating cache namespaces:
Use hydrationLimits when an application needs stricter bootstrap payload limits. fate applies conservative defaults for total encoded values, collection sizes, and string lengths.
By default, hydration preserves values already present in the browser cache while adding missing server data. Pass
{ merge: 'replace' } only when the snapshot should authoritatively reset the durable cache:
preserve-existing recursively combines plain scalar objects while keeping browser values on conflicts. Arrays, dates, entity references, and list windows are atomic: an existing browser value wins as a whole. Replaying a snapshot is safe and does not notify subscribers when durable cache state is unchanged.
Do not reuse request-scoped snapshots across users. Dehydrate after awaited route preloading: snapshots are point-in-time values and do not stream cache patches for data that resolves later. Hydration and dehydration reject clients with in-flight requests, so hydrate the initial snapshot before rendering.
Deferred Views
Use
defer when a field should not block the parent view. The parent view receives a deferred handle immediately after the eager fields are available, and the component that reads that handle with useView, useListView, or useLiveListView decides which Suspense boundary handles the loading state.
tsx
import { Suspense } from 'react';
import { defer, useListView, useView, view, Deferred, ViewRef } from 'react-fate';
Deferred fields are not optional data. They are explicit handles that existing view APIs can read. If the deferred selection is missing from the normalized cache, fate fetches only that missing selection and suspends the component that tried to resolve it.
This keeps parent components simple: eager fields like
title and content are available when useView(PostView, postRef) returns, while slower or secondary fields such as comments can load under their own boundary.
GraphQL transports use the same client semantics today. The deferred field is omitted from the eager request and fetched when the deferred handle is resolved. GraphQL
@defer is the natural transport representation for this feature, but consuming incremental multipart patches requires additional transport support before fate can safely normalize streamed patches from a single GraphQL response.
List Views
Pagination with
useListView
You can wrap a list of references using
useListView to enable connection-style lists with pagination support.
For example, you can define a
CommentView and reuse it inside of a CommentConnectionView:
tsx
import { useListView, ViewRef } from 'react-fate';
If loadNext is undefined, it means there are no more comments to load. If you want to instead load previous comments, you can use the third argument returned by useListView, which is loadPrevious. Similarly, if there are no previous comments to load, loadPrevious will be undefined.
Pagination Arguments
Connection views can define default arguments, and
useListView carries those arguments forward when loading more pages:
When loadNext runs, fate sends the next cursor as after and keeps the page size in first. When loadPrevious runs, fate sends the previous cursor as before and uses last for the page size. This lets the server distinguish forward and backward pagination while keeping the component API small.
Additional arguments on a root request are scoped to that root list:
The categoryId list above has its own cache entry and pagination state. Loading another page for that list does not update a different posts request with another category or search query.
Live Views
useLiveView resolves a ViewRef just like useView, but also keeps the selected object up to date through the native live SSE transport.
tsx
import { useLiveView, ViewRef } from 'react-fate';
The API mirrors useView: pass a view and a ref, and get back the same masked data shape. A null ref returns null and does not subscribe.
How Live Updates Work
The native HTTP transport opens one Server-Sent Events (SSE) connection per fate client. When components mount or unmount live views, the client sends subscribe and unsubscribe control messages to the server. The server keeps those selections on the connection and sends updates only for records that connection subscribed to.
When the server sends an update, fate normalizes the selected record into the same cache used by requests, actions, and mutations. Components that read affected fields re-render automatically.
For example, if
PostView selects likes, a live update that changes likes re-renders the PostCard. If another component only selected title, it does not re-render for a likes change.
Live deletion events remove the record from the normalized cache in the same way as mutations, and any lists or object fields that reference it are pruned.
Client Setup
Configure the native transport and point the client at your fate endpoint:
tsx
import { FateClient } from 'react-fate';
import { createFateClient } from 'react-fate/client';
> [!NOTE]
>
> Live views use GET /fate/live for the single SSE stream and POST /fate/live for subscribe/unsubscribe control messages.
Server Setup
Live views use an event bus. By default, the bus signals that an object changed and fate refetches the selected object through the same data view pipeline used by
byId queries before sending it to the client. Update events can also include changed field paths so fate only resolves the intersection of those paths and each active subscription.
Pass a live event bus to
createFateServer and expose the native handler:
tsx
import { createFateServer, createHonoFateHandler, createLiveEventBus } from '@nkzw/fate/server';
import type { AppContext } from './context.ts';
import { sources } from './sources.ts';
import { Root } from './views.ts';
fate keeps a bounded in-memory queue for each native SSE connection while live events are waiting to be resolved and sent. The default limit is 1000 queued events per connection. If a client falls behind and exceeds the limit, fate closes that live connection so server memory cannot grow without bound. You can tune the limit by passing the object form:
The hook returns the same tuple as useListView: items, loadNext, and loadPrevious. Live events append, prepend, insert, or delete edges from one connection without deleting the underlying records.
By default, live appends and prepends respect pagination boundaries. If the relevant edge still has more pages, fate keeps the incoming node attached to that edge instead of expanding the loaded window. For chat or activity streams where new items should keep appearing immediately, opt into visible live insertion on the connection view:
Connection identity follows Relay's model: pagination args like first, last, after, and before are ignored for live connection matching, while filter args such as categoryId are part of the identity.
Emitting Events
After a mutation changes an object, emit an update event for that object:
You can pass an eventId when emitting. fate sends it on the native SSE event and includes the last received event ID when it resubscribes after a reconnect:
The default createLiveEventBus is an in-memory fanout bus and does not replay events that were emitted while a client was disconnected. Use a durable custom live bus if your deployment needs reconnects to catch up from lastEventId; otherwise the client receives future live events after it reconnects.
Error Handling
Live subscription errors are reported out of band. They do not replace the last cached data or throw through the component that called
useLiveView.
Pass
onLiveError when creating the client to send those failures to your logger or monitoring system:
The handler runs in a microtask after the subscription reports the error. Components continue to read whatever data is currently available in the fate cache.
Actions
fate does not provide hooks for mutations like traditional data fetching libraries do. Instead, mutations are exposed in two ways:
fate.actions for use with useActionState and React Actions.
fate.mutations for traditional imperative mutation calls.
Server mutations are exposed automatically as actions and mutations by fate's Vite plugin. The transport determines where those mutations are declared:
By using useActionState, fate Actions integrate with Suspense and concurrent rendering.
Optimistic Updates
fate Actions support optimistic updates out of the box. For example, to update the post's like count optimistically, you can pass an
optimistic object to the action call. This will immediately update the cache with the new like count and re-render all views that select the likes field:
When data changes through optimistic updates or otherwise, fate only re-renders the views that select the changed fields. In the above example, only views that select the likes field will re-render. If a view only selects the title field, it won't re-render when the likes field changes.
If a mutation fails, the cache will be rolled back to its previous state and any views depending on the mutated data will be updated.
Inserting New Objects
When a mutation inserts a new object, you can provide an optimistic object with a temporary ID to represent the new object in the cache until the server responds with the actual ID. For example, to add a new comment to a post optimistically, you can do the following:
By default, fate inserts new records after existing items in matching root lists and nested lists. For a newest-first list, pass insert: 'before' so optimistic records appear at the beginning:
Insertion respects pagination boundaries. If you append to a list that still has a next page, fate keeps the new record attached to the unresolved trailing edge instead of mixing it into the loaded page. As you load more pages, the inserted record stays at the end until the server returns the canonical item or the list reaches the edge. The same behavior applies to prepends while hasPrevious is true.
Multiple pending optimistic inserts keep their visible order. For example, two
insert: 'before' calls on a newest-first feed show the second optimistic item before the first, matching what users expect from newly created content.
Selecting a View with Actions
Mutations may change data that is not directly specified in the mutation result. For example, adding a comment increases the post's comment count. For such cases, you can provide a
view to an action that specifies which fields to fetch as part of the mutation:
The server will return the selected fields and fate updates the cache and re-renders all views that depend on the changed data. The action result contains the newly added comment with the selected fields:
fate Actions are the recommended way to execute server mutations in React components. However, there are cases where you might want to call mutations imperatively, outside of React components, or without waiting for previous actions to finish like
useActionState does. For such cases, you can use fate.mutations to call mutations imperatively:
You can call mutations from anywhere, and without waiting for previous mutations to finish. The mutation API matches the API of fate Actions, including optimistic updates and view selection. With mutations, you'll need to handle loading states and errors manually, and the result is returned as a promise.
Mutation Server Implementation
fate Actions & Mutations are backed by regular server mutations. If you already know how your fate server is wired, the client-side API above is the same regardless of transport. If not, start with the server setup for your environment:
See Server Integration for complete native HTTP and tRPC setup examples, and Void Integration for route helpers when your app runs on Void.
Action & Mutation Error Handling
fate Actions & Mutations separate error handling into two scopes: "call site" and "boundary". Call site errors are expected to be handled at the location where the action or mutation is called. Boundary errors are unexpected errors that should be handled by a higher-level error boundary.
If your server returns a
NOT_FOUND error with code 404, the result of an Action or Mutation will contain an error object that you can handle at the call site:
useActionState, the result of the action is cached until the component using the action is unmounted. When a mutation fails with an error, you might want to clear the error state without invoking the action again. fate Actions take a 'reset'` token to reset the action state: