react-fate 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +995 -0
- package/lib/cli.d.mts +1 -0
- package/lib/cli.mjs +3 -0
- package/lib/index.d.mts +62 -0
- package/lib/index.mjs +176 -0
- package/package.json +27 -5
package/README.md
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
<!-- auto-generated from docs/guide/*.md. Do not edit directly. -->
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<picture>
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" srcset="/public/fate-logo-dark.svg">
|
|
6
|
+
<source media="(prefers-color-scheme: light)" srcset="/public/fate-logo.svg">
|
|
7
|
+
<img alt="Logo" src="/public/fate-logo.svg" width="50%">
|
|
8
|
+
</picture>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**_fate_** is a modern data client for React and tRPC inspired by [Relay](https://relay.dev/) and [GraphQL](https://graphql.org/). It combines view composition, normalized caching, data masking, Async React features, and tRPC's type safety.
|
|
12
|
+
|
|
13
|
+
**_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_.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **Async React:** fate uses modern Async React features like Actions, Suspense, and `use` to support concurrent rendering and enable a seamless user experience.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **AI-Ready:** fate's minimal, predictable API and explicit data selection enable local reasoning, allowing AI tools to generate stable, type-safe data-fetching code.
|
|
24
|
+
|
|
25
|
+
### A modern data client for React & tRPC
|
|
26
|
+
|
|
27
|
+
**_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_.
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
Nakazawa Tech builds apps primarily with GraphQL and Relay. We advocate for these technologies in [talks](https://www.youtube.com/watch?v=rxPTEko8J7c&t=36s) and provide templates ([server](https://github.com/nkzw-tech/server-template), [client](https://github.com/nkzw-tech/web-app-template/tree/with-relay)) to help developers get started quickly.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
fate takes the great ideas from Relay and puts them on top of tRPC. You get the best of both worlds: type safety between the client and server, and GraphQL-like ergonomics for data fetching.
|
|
38
|
+
|
|
39
|
+
_[Learn more](/docs/guide/getting-started.md) about fate's core concepts and features._
|
|
40
|
+
|
|
41
|
+
## Getting Started
|
|
42
|
+
|
|
43
|
+
### Installation
|
|
44
|
+
|
|
45
|
+
**_fate_** requires React 19.2+.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm add react-fate @nkzw/fate
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> [!WARNING]
|
|
52
|
+
>
|
|
53
|
+
> **_fate_** is currently in alpha and not production ready. If something doesn't work for you, please open a pull request.
|
|
54
|
+
|
|
55
|
+
If you'd like to try the example app in GitHub Codespaces, click the button below:
|
|
56
|
+
|
|
57
|
+
[](https://github.com/codespaces/new?repo=nkzw-tech/fate)
|
|
58
|
+
|
|
59
|
+
### Core Concepts
|
|
60
|
+
|
|
61
|
+
**_fate_** has a minimal API surface and is aimed at reducing data fetching complexity.
|
|
62
|
+
|
|
63
|
+
#### Thinking in Views
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
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:
|
|
68
|
+
|
|
69
|
+
<p align="center">
|
|
70
|
+
<picture class="fate-tree">
|
|
71
|
+
<source media="(prefers-color-scheme: dark)" srcset="/public/fate-tree-dark.svg">
|
|
72
|
+
<source media="(prefers-color-scheme: light)" srcset="/public/fate-tree.svg">
|
|
73
|
+
<img alt="Tree" src="/public/fate-tree.svg" width="90%">
|
|
74
|
+
</picture>
|
|
75
|
+
</p>
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
## Views
|
|
80
|
+
|
|
81
|
+
### Defining Views
|
|
82
|
+
|
|
83
|
+
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:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { view } from 'react-fate';
|
|
87
|
+
|
|
88
|
+
type Post = {
|
|
89
|
+
content: string;
|
|
90
|
+
id: string;
|
|
91
|
+
title: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const PostView = view<Post>()({
|
|
95
|
+
content: true,
|
|
96
|
+
id: true,
|
|
97
|
+
title: true,
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
> [!NOTE]
|
|
104
|
+
> The `Post` type above is an example. In a real application, this type is defined on the server and imported into your client code.
|
|
105
|
+
|
|
106
|
+
### Resolving a View with `useView`
|
|
107
|
+
|
|
108
|
+
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`:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { useView, ViewRef } from 'react-fate';
|
|
112
|
+
|
|
113
|
+
export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
114
|
+
const post = useView(PostView, postRef);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Card>
|
|
118
|
+
<h2>{post.title}</h2>
|
|
119
|
+
<p>{post.content}</p>
|
|
120
|
+
</Card>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A `ViewRef` is a reference to an object of a specific type, in this case a `Post`. It contains the unique ID for 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.
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
### Fetching Data with `useRequest`
|
|
130
|
+
|
|
131
|
+
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 `HomePage` component, we can request a list of posts like this:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { useRequest } from 'react-fate';
|
|
135
|
+
import { PostCard, PostView } from './PostCard.tsx';
|
|
136
|
+
|
|
137
|
+
export function HomePage() {
|
|
138
|
+
const { posts } = useRequest({
|
|
139
|
+
posts: { root: PostView, type: 'Post' },
|
|
140
|
+
} as const);
|
|
141
|
+
|
|
142
|
+
return posts.map((post) => <PostCard key={post.id} post={post} />);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
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:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<ErrorBoundary FallbackComponent={ErrorComponent}>
|
|
150
|
+
<Suspense fallback={<div>Loading…</div>}>
|
|
151
|
+
<HomePage />
|
|
152
|
+
</Suspense>
|
|
153
|
+
</ErrorBoundary>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
> [!NOTE]
|
|
157
|
+
>
|
|
158
|
+
> `useRequest` might issue multiple requests which are automatically batched together by tRPC's [HTTP Batch Link](https://trpc.io/docs/client/links/httpBatchLink).
|
|
159
|
+
|
|
160
|
+
### Composing Views
|
|
161
|
+
|
|
162
|
+
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:
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
import { Suspense } from 'react';
|
|
166
|
+
import { useRequest, useView, ViewRef } from 'react-fate';
|
|
167
|
+
|
|
168
|
+
export const PostView = view<Post>()({
|
|
169
|
+
author: {
|
|
170
|
+
id: true,
|
|
171
|
+
name: true,
|
|
172
|
+
},
|
|
173
|
+
content: true,
|
|
174
|
+
id: true,
|
|
175
|
+
title: true,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const PostCard = ({ postRef }: { postRef: ViewRef<'Post'> }) => {
|
|
179
|
+
const post = useView(PostView, postRef);
|
|
180
|
+
return (
|
|
181
|
+
<Card>
|
|
182
|
+
<h2>{post.title}</h2>
|
|
183
|
+
<p>by {post.author.name}</p>
|
|
184
|
+
<p>{post.content}</p>
|
|
185
|
+
</Card>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
This code fetches the author associated with the Post and makes it available to the `PostCard` component. However, this approach has some downsides:
|
|
191
|
+
|
|
192
|
+
1. 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.
|
|
193
|
+
1. 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.
|
|
194
|
+
1. We cannot reuse the `author` field selection in other views or components.
|
|
195
|
+
|
|
196
|
+
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:
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import type { Post, User } from '@your-org/server/trpc/views';
|
|
200
|
+
import { view } from 'react-fate';
|
|
201
|
+
|
|
202
|
+
export const UserView = view<User>()({
|
|
203
|
+
id: true,
|
|
204
|
+
name: true,
|
|
205
|
+
profilePicture: true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export const PostView = view<Post>()({
|
|
209
|
+
author: UserView,
|
|
210
|
+
content: true,
|
|
211
|
+
id: true,
|
|
212
|
+
title: true,
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Now we can create a separate `UserCard` component that uses our `UserView`:
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
import { useView, ViewRef } from 'react-fate';
|
|
220
|
+
|
|
221
|
+
export const UserCard = ({ user: userRef }: { user: ViewRef<'User'> }) => {
|
|
222
|
+
const user = useView(UserView, userRef);
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div>
|
|
226
|
+
<img src={user.profilePicture} alt={user.name} />
|
|
227
|
+
<p>{user.name}</p>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
And update `PostCard` to use our `UserCard` component:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
import { UserCard } from './UserCard.tsx';
|
|
237
|
+
|
|
238
|
+
export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
239
|
+
const post = useView(PostView, postRef);
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<Card>
|
|
243
|
+
<h2>{post.title}</h2>
|
|
244
|
+
<UserCard user={post.author} />
|
|
245
|
+
<p>{post.content}</p>
|
|
246
|
+
</Card>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### View Spreads
|
|
252
|
+
|
|
253
|
+
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.
|
|
254
|
+
|
|
255
|
+
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:
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
export const PostView = view<Post>()({
|
|
259
|
+
author: {
|
|
260
|
+
...UserView,
|
|
261
|
+
bio: true,
|
|
262
|
+
},
|
|
263
|
+
content: true,
|
|
264
|
+
id: true,
|
|
265
|
+
title: true,
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Now the `PostCard` component can access the `bio` field of the author:
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
273
|
+
const post = useView(PostView, postRef);
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<Card>
|
|
277
|
+
<h2>{post.title}</h2>
|
|
278
|
+
<UserCard author={post.author} />
|
|
279
|
+
{/* Accessing the bio field */}
|
|
280
|
+
<p>{post.author.bio}</p>
|
|
281
|
+
<p>{post.content}</p>
|
|
282
|
+
</Card>
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
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:
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
export const UserStatsView = view<User>()({
|
|
291
|
+
followerCount: true,
|
|
292
|
+
postCount: true,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
export const PostView = view<Post>()({
|
|
296
|
+
author: {
|
|
297
|
+
...UserView,
|
|
298
|
+
...UserStatsView,
|
|
299
|
+
bio: true,
|
|
300
|
+
},
|
|
301
|
+
content: true,
|
|
302
|
+
id: true,
|
|
303
|
+
title: true,
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Views are opaque objects. Even if you select the same field multiple times through different views, the composed object won't have conflicting fiels or result in TypeScript errors. fate automatically deduplicates fields during runtime and ensures that each field is only fetched once.
|
|
308
|
+
|
|
309
|
+
### `useView` and Suspense
|
|
310
|
+
|
|
311
|
+
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.
|
|
312
|
+
|
|
313
|
+
_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._
|
|
314
|
+
|
|
315
|
+
### Type Safety and Data Masking
|
|
316
|
+
|
|
317
|
+
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.
|
|
318
|
+
|
|
319
|
+
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:
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
const PostView = view<Post>()({
|
|
323
|
+
id: true,
|
|
324
|
+
title: true,
|
|
325
|
+
// `content: true` is omitted.
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
329
|
+
const post = useView(PostView, postRef);
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<Card>
|
|
333
|
+
<h2>{post.title}</h2>
|
|
334
|
+
{/* TypeScript errors here, and `post.content` is undefined during runtime */}
|
|
335
|
+
<p>{post.content}</p>
|
|
336
|
+
</Card>
|
|
337
|
+
);
|
|
338
|
+
};
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
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:
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
const PostDetailView = view<Post>()({
|
|
345
|
+
content: true,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const AnotherPostView = view<Post>()({
|
|
349
|
+
content: true,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const PostView = view<Post>()({
|
|
353
|
+
id: true,
|
|
354
|
+
title: true,
|
|
355
|
+
...AnotherPostView,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
359
|
+
const post = useView(PostView, postRef);
|
|
360
|
+
return <PostDetail post={post} />;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const PostDetail = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
364
|
+
// This throws because the post reference passed into this component
|
|
365
|
+
// is of type `AnotherPostView`, not `PostDetailView`.
|
|
366
|
+
const post = useView(PostDetailView, postRef);
|
|
367
|
+
};
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
ViewRefs carry a set of view names they can resolve. `useView` throws if a ref does not include the required view.
|
|
371
|
+
|
|
372
|
+
### Request Modes
|
|
373
|
+
|
|
374
|
+
`useRequest` supports different request modes to control caching and data freshness. The available modes are:
|
|
375
|
+
|
|
376
|
+
- `cache-first` (_default_): Returns data from the cache if available, otherwise fetches from the network.
|
|
377
|
+
- `stale-while-revalidate`: Returns data from the cache and simultaneously fetches fresh data from the network.
|
|
378
|
+
- `network-only`: Always fetches data from the network, bypassing the cache.
|
|
379
|
+
|
|
380
|
+
You can pass the request mode as an option to `useRequest`:
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
const { posts } = useRequest(
|
|
384
|
+
{
|
|
385
|
+
posts: { root: PostView, type: 'Post' },
|
|
386
|
+
},
|
|
387
|
+
{ mode: 'stale-while-revalidate' },
|
|
388
|
+
);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Request Arguments
|
|
392
|
+
|
|
393
|
+
You can pass arguments to `useRequest` calls. This is useful for pagination, filtering, or sorting. For example, to fetch the first 10 posts, you can do the following:
|
|
394
|
+
|
|
395
|
+
```tsx
|
|
396
|
+
const { posts } = useRequest({
|
|
397
|
+
posts: {
|
|
398
|
+
args: { first: 10 },
|
|
399
|
+
root: PostView,
|
|
400
|
+
type: 'Post',
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## List Views
|
|
406
|
+
|
|
407
|
+
### Pagination with `useListView`
|
|
408
|
+
|
|
409
|
+
You can wrap a list of references using `useListView` to enable connection-style lists with pagination support.
|
|
410
|
+
|
|
411
|
+
For example, you can define a `CommentView` and reuse it inside of a `CommentConnectionView`:
|
|
412
|
+
|
|
413
|
+
```tsx
|
|
414
|
+
import { useListView, ViewRef } from 'react-fate';
|
|
415
|
+
|
|
416
|
+
const CommentView = view<Comment>()({
|
|
417
|
+
content: true,
|
|
418
|
+
id: true,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const CommentConnectionView = {
|
|
422
|
+
args: { first: 10 },
|
|
423
|
+
items: {
|
|
424
|
+
node: CommentView,
|
|
425
|
+
},
|
|
426
|
+
} as const;
|
|
427
|
+
|
|
428
|
+
const PostView = view<Post>()({
|
|
429
|
+
comments: CommentConnectionView,
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Now you can apply the `useListView` hook inside of your `PostCard` component to read the list of comments and load more comments when needed:
|
|
434
|
+
|
|
435
|
+
```tsx
|
|
436
|
+
export function PostCard({
|
|
437
|
+
detail,
|
|
438
|
+
post: postRef,
|
|
439
|
+
}: {
|
|
440
|
+
detail?: boolean;
|
|
441
|
+
post: ViewRef<'Post'>;
|
|
442
|
+
}) {
|
|
443
|
+
const post = useView(PostView, postRef);
|
|
444
|
+
const [comments, loadNext] = useListView(
|
|
445
|
+
CommentConnectionView,
|
|
446
|
+
post.comments,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<div>
|
|
451
|
+
{comments.map(({ node }) => (
|
|
452
|
+
<CommentCard comment={node} key={node.id} post={post} />
|
|
453
|
+
))}
|
|
454
|
+
{loadNext ? (
|
|
455
|
+
<Button onClick={loadNext} variant="ghost">
|
|
456
|
+
Load more comments
|
|
457
|
+
</Button>
|
|
458
|
+
) : null}
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
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.
|
|
465
|
+
|
|
466
|
+
## Actions
|
|
467
|
+
|
|
468
|
+
fate does not provide hooks for mutations like traditional data fetching libraries do. Instead, mutations are exposed in two ways:
|
|
469
|
+
|
|
470
|
+
- `fate.actions` for use with [`useActionState`](https://react.dev/reference/react/useActionState) and React Actions.
|
|
471
|
+
- `fate.mutations` for traditional imperative mutation calls.
|
|
472
|
+
|
|
473
|
+
Mutations in your tRPC backend are made available as actions and mutations by fate's generated client.
|
|
474
|
+
|
|
475
|
+
Let's assume that our `Post` entity has a tRPC mutation for liking a post called `post.like`. A `LikeButton` component using fate Actions and an async component library could then look like this:
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
|
|
479
|
+
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<Button action={() => like({ input: { id: post.id } })}>
|
|
483
|
+
{result?.error ? 'Oops!' : 'Like'}
|
|
484
|
+
</Button>
|
|
485
|
+
);
|
|
486
|
+
};
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
If you are not using an async component library, you can use React's `useTransition` to start the action in a transition:
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
|
|
493
|
+
const [, startTransition] = useTransition();
|
|
494
|
+
const [result, like, isPending] = useActionState(
|
|
495
|
+
fate.actions.post.like,
|
|
496
|
+
null,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<button
|
|
501
|
+
disabled={isPending}
|
|
502
|
+
onClick={() => {
|
|
503
|
+
startTransition(() =>
|
|
504
|
+
like({
|
|
505
|
+
input: { id: post.id },
|
|
506
|
+
}),
|
|
507
|
+
);
|
|
508
|
+
}}
|
|
509
|
+
>
|
|
510
|
+
{result?.error ? 'Oops!' : 'Like'}
|
|
511
|
+
</button>
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
By using `useActionState`, fate Actions integrate with Suspense and concurrent rendering.
|
|
517
|
+
|
|
518
|
+
### Optimistic Updates
|
|
519
|
+
|
|
520
|
+
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:
|
|
521
|
+
|
|
522
|
+
```tsx
|
|
523
|
+
like({
|
|
524
|
+
input: { id: post.id },
|
|
525
|
+
optimistic: { likes: post.likes + 1 },
|
|
526
|
+
});
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
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.
|
|
530
|
+
|
|
531
|
+
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.
|
|
532
|
+
|
|
533
|
+
### Inserting New Objects
|
|
534
|
+
|
|
535
|
+
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:
|
|
536
|
+
|
|
537
|
+
```tsx
|
|
538
|
+
const content = 'New Comment text';
|
|
539
|
+
addComment({
|
|
540
|
+
input: { content, postId: post.id },
|
|
541
|
+
optimistic: {
|
|
542
|
+
author: { id: user.id, name: user.name },
|
|
543
|
+
content,
|
|
544
|
+
id: `optimistic:${Date.now().toString(36)}`,
|
|
545
|
+
post: { commentCount: post.commentCount + 1, id: post.id },
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Selecting a View with Actions
|
|
551
|
+
|
|
552
|
+
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:
|
|
553
|
+
|
|
554
|
+
```tsx
|
|
555
|
+
addComment({
|
|
556
|
+
input: { content: 'New Comment text', postId: post.id },
|
|
557
|
+
view: view<Comment>()({
|
|
558
|
+
...CommentView,
|
|
559
|
+
post: { commentCount: true },
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
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:
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
const [result, addComment] = useActionState(fate.actions.comment.add, null);
|
|
568
|
+
|
|
569
|
+
const newComment = result?.result;
|
|
570
|
+
if (newComment) {
|
|
571
|
+
// All the fields selected in the view are available on `newComment`:
|
|
572
|
+
console.log(newComment.post.commentCount);
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Mutations
|
|
577
|
+
|
|
578
|
+
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:
|
|
579
|
+
|
|
580
|
+
```tsx
|
|
581
|
+
const result = await fate.mutations.comment.add({
|
|
582
|
+
input: { content, postId: post.id },
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
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.
|
|
587
|
+
|
|
588
|
+
### Mutation Server Implementation
|
|
589
|
+
|
|
590
|
+
fate Actions & Mutations are backed by regular tRPC mutations on the server. Here is an example implementation of the `like` mutation in the `postRouter`.
|
|
591
|
+
|
|
592
|
+
```tsx
|
|
593
|
+
import { z } from 'zod';
|
|
594
|
+
import { connectionArgs, createResolver } from '@nkzw/fate/server';
|
|
595
|
+
import { procedure, router } from '../init.ts';
|
|
596
|
+
import { postDataView, PostItem } from '../views.ts';
|
|
597
|
+
|
|
598
|
+
export const postRouter = router({
|
|
599
|
+
like: procedure
|
|
600
|
+
.input(
|
|
601
|
+
z.object({
|
|
602
|
+
args: connectionArgs,
|
|
603
|
+
id: z.string().min(1, 'Post id is required.'),
|
|
604
|
+
select: z.array(z.string()),
|
|
605
|
+
}),
|
|
606
|
+
)
|
|
607
|
+
.mutation(async ({ ctx, input }) => {
|
|
608
|
+
const { resolve, select } = createResolver({
|
|
609
|
+
...input,
|
|
610
|
+
ctx,
|
|
611
|
+
view: postDataView,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const updated = await ctx.prisma.post.update({
|
|
615
|
+
data: {
|
|
616
|
+
likes: {
|
|
617
|
+
increment: 1,
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
select,
|
|
621
|
+
where: { id: input.id },
|
|
622
|
+
});
|
|
623
|
+
return resolve(updated as unknown as PostItem);
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
See the [Server Integration](#server-integration) section for more details on how to integrate tRPC routers with fate.
|
|
629
|
+
|
|
630
|
+
### Action & Mutation Error Handling
|
|
631
|
+
|
|
632
|
+
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.
|
|
633
|
+
|
|
634
|
+
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:
|
|
635
|
+
|
|
636
|
+
```tsx
|
|
637
|
+
const [result] = useActionState(fate.actions.post.delete, null);
|
|
638
|
+
|
|
639
|
+
if (result?.error) {
|
|
640
|
+
if (result.error.code === 'NOT_FOUND') {
|
|
641
|
+
// Handle not found error at call site.
|
|
642
|
+
} else {
|
|
643
|
+
// Handle other *expected* errors.
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
However, if an `INTERNAL_SERVER_ERROR` error with code `500` occurs, it will be thrown and can be caught by the nearest React error boundary:
|
|
649
|
+
|
|
650
|
+
```tsx
|
|
651
|
+
<ErrorBoundary FallbackComponent={ErrorComponent}>
|
|
652
|
+
<Suspense fallback={<div>Loading…</div>}>
|
|
653
|
+
<PostPage postId={postId} />
|
|
654
|
+
</Suspense>
|
|
655
|
+
</ErrorBoundary>
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
You can find the error classification behavior in [`mutation.ts`](https://github.com/nkzw-tech/fate/blob/main/packages/fate/src/mutation.ts#L227-L254).
|
|
659
|
+
|
|
660
|
+
### Deleting Records
|
|
661
|
+
|
|
662
|
+
When you want to delete a record using fate Actions, you can pass a `delete: true` flag to the action call. This flag removes the object from the cache and re-renders all views that depend on the deleted data:
|
|
663
|
+
|
|
664
|
+
```tsx
|
|
665
|
+
const [result, deleteAction] = useActionState(fate.actions.post.delete, null);
|
|
666
|
+
|
|
667
|
+
deleteAction({
|
|
668
|
+
input: { id: post.id },
|
|
669
|
+
delete: true,
|
|
670
|
+
});
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Resetting Action State
|
|
674
|
+
|
|
675
|
+
When using `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:
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
679
|
+
|
|
680
|
+
useEffect(() => {
|
|
681
|
+
if (result?.error) {
|
|
682
|
+
// Reset the action state after 3 seconds.
|
|
683
|
+
const timeout = setTimeout(
|
|
684
|
+
() => startTransition(() => like('reset')),
|
|
685
|
+
3000,
|
|
686
|
+
);
|
|
687
|
+
return () => clearTimeout(timeout);
|
|
688
|
+
}
|
|
689
|
+
}, [like, result]);
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
## Server Integration
|
|
693
|
+
|
|
694
|
+
Until now, we have focused on the client-side API of fate. You'll need a tRPC backend that follows some conventions so you can generate a typed client using fate's CLI.
|
|
695
|
+
|
|
696
|
+
### Conventions & Object Identity
|
|
697
|
+
|
|
698
|
+
fate expects that data is served by a tRPC backend that follows these conventions:
|
|
699
|
+
|
|
700
|
+
- A `byId` query for each data type to fetch individual objects by their unique identifier (`id`).
|
|
701
|
+
- A `list` query for fetching lists of objects with support for pagination.
|
|
702
|
+
|
|
703
|
+
Objects are identified by their ID and type name (`__typename`, e.g. `Post`, `User`), and stored by `__typename:id` (e.g. "Post:123") in the client cache. fate keeps list orderings under stable keys derived from the backend procedure and args. Relations are stored as IDs and returned to components as ViewRef tokens.
|
|
704
|
+
|
|
705
|
+
fate's type definitions might seem verbose at first glance. However, with fate's minimal API surface, AI tools can easily generate this code for you. For example, fate has a minimal CLI that generates types for the client, but you can also let your LLM write it by hand if you prefer.
|
|
706
|
+
|
|
707
|
+
> [!NOTE]
|
|
708
|
+
> You can adopt _fate_ incrementally in an existing tRPC codebase without changing your existing schema by adding these queries alongside your existing procedures.
|
|
709
|
+
|
|
710
|
+
### Data Views
|
|
711
|
+
|
|
712
|
+
To continue with our client example, let's assume we have a `post.ts` file with a tRPC router that exposes a `byId` query for selecting objects by id, and a root `list` query to fetch a list of posts.
|
|
713
|
+
|
|
714
|
+
Since clients can send arbitrary selection objects to the server, we need to implement a way to translate these selection objects into database queries without exposing raw database queries and private data to the client. On the client, we define views to select fields on each type. We can do the same on the server using fate data views and the `dataView` function from `@nkzw/fate/server`.
|
|
715
|
+
|
|
716
|
+
Create a `views.ts` file next to your root tRPC router that exports the data views for each type. Here is how you can define a `User` data view for Prisma's `User` model:
|
|
717
|
+
|
|
718
|
+
```tsx
|
|
719
|
+
import { dataView, DataViewResult } from '@nkzw/fate/server';
|
|
720
|
+
import type { User as PrismaUser } from '../prisma/prisma-client/client.ts';
|
|
721
|
+
|
|
722
|
+
export const userDataView = dataView<PrismaUser>('User')({
|
|
723
|
+
id: true,
|
|
724
|
+
name: true,
|
|
725
|
+
username: true,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
export type User = DataViewResult<typeof userDataView> & {
|
|
729
|
+
__typename: 'User';
|
|
730
|
+
};
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
_Note: Currently, fate provides helpers to integrate with Prisma, but the framework is not coupled to any particular ORM or database. We hope to provide more direct integrations in the future, and are always open to contributions._
|
|
734
|
+
|
|
735
|
+
### tRPC Router Implementation
|
|
736
|
+
|
|
737
|
+
We can apply the above data view in our tRPC router and resolve the client's selection against it using `createResolver`. Here is an example implementation of the `byId` query for the `User` type which allows fetching multiple users by `id`:
|
|
738
|
+
|
|
739
|
+
```tsx
|
|
740
|
+
import { connectionArgs, createResolver } from '@nkzw/fate/server';
|
|
741
|
+
import { z } from 'zod';
|
|
742
|
+
import type { UserFindManyArgs } from '../../prisma/prisma-client/models.ts';
|
|
743
|
+
import { procedure, router } from '../init.ts';
|
|
744
|
+
import { userDataView } from '../views.ts';
|
|
745
|
+
|
|
746
|
+
export const userRouter = router({
|
|
747
|
+
byId: procedure
|
|
748
|
+
.input(
|
|
749
|
+
z.object({
|
|
750
|
+
args: connectionArgs,
|
|
751
|
+
ids: z.array(z.string().min(1)).nonempty(),
|
|
752
|
+
select: z.array(z.string()),
|
|
753
|
+
}),
|
|
754
|
+
)
|
|
755
|
+
.query(async ({ ctx, input }) => {
|
|
756
|
+
const { resolveMany, select } = createResolver({
|
|
757
|
+
...input,
|
|
758
|
+
ctx,
|
|
759
|
+
view: userDataView,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const users = await ctx.prisma.user.findMany({
|
|
763
|
+
select: select,
|
|
764
|
+
where: { id: { in: input.ids } },
|
|
765
|
+
} as UserFindManyArgs);
|
|
766
|
+
|
|
767
|
+
return await resolveMany(users);
|
|
768
|
+
}),
|
|
769
|
+
});
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Now that we apply `userDataView` to the `byId` query, the server limits the selection to the fields defined in the data view, keeping private fields hidden from the client, and providing type safety for client views:
|
|
773
|
+
|
|
774
|
+
```tsx
|
|
775
|
+
const UserData = view<User>()({
|
|
776
|
+
// Type-error + ignored during runtime.
|
|
777
|
+
password: true,
|
|
778
|
+
});
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### tRPC List Implementation
|
|
782
|
+
|
|
783
|
+
To implement the `list` query for fetching a paginated list of posts, we can use fate's `createConnectionProcedure` helper. This helper simplifies the implementation of pagination. Here is an example implementation of the `postRouter` with a `list` query:
|
|
784
|
+
|
|
785
|
+
```tsx
|
|
786
|
+
import { createResolver } from '@nkzw/fate/server';
|
|
787
|
+
import type { PostFindManyArgs } from '../../prisma/prisma-client/models.ts';
|
|
788
|
+
import { createConnectionProcedure } from '../connection.ts';
|
|
789
|
+
import { router } from '../init.ts';
|
|
790
|
+
import { postDataView } from '../views.ts';
|
|
791
|
+
|
|
792
|
+
export const postRouter = router({
|
|
793
|
+
list: createConnectionProcedure({
|
|
794
|
+
query: async ({ ctx, cursor, direction, input, skip, take }) => {
|
|
795
|
+
const { resolveMany, select } = createResolver({
|
|
796
|
+
...input,
|
|
797
|
+
ctx,
|
|
798
|
+
view: postDataView,
|
|
799
|
+
});
|
|
800
|
+
const findOptions: PostFindManyArgs = {
|
|
801
|
+
orderBy: { createdAt: 'desc' },
|
|
802
|
+
select,
|
|
803
|
+
take: direction === 'forward' ? take : -take,
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
if (cursor) {
|
|
807
|
+
findOptions.cursor = { id: cursor };
|
|
808
|
+
findOptions.skip = skip;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const items = await ctx.prisma.post.findMany(findOptions);
|
|
812
|
+
return resolveMany(direction === 'forward' ? items : items.reverse());
|
|
813
|
+
},
|
|
814
|
+
}),
|
|
815
|
+
});
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### Data View Composition
|
|
819
|
+
|
|
820
|
+
Similar to client-side views, data views can be composed of other data views:
|
|
821
|
+
|
|
822
|
+
```tsx
|
|
823
|
+
export const postDataView = dataView<PostItem>('Post')({
|
|
824
|
+
author: userDataView,
|
|
825
|
+
content: true,
|
|
826
|
+
id: true,
|
|
827
|
+
title: true,
|
|
828
|
+
} as const;
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### Data View Lists
|
|
832
|
+
|
|
833
|
+
Use the `list` helper to define list fields:
|
|
834
|
+
|
|
835
|
+
```tsx
|
|
836
|
+
import { list } from '@nkzw/fate/server';
|
|
837
|
+
|
|
838
|
+
export const commentDataView = dataView<CommentItem>('Comment')({
|
|
839
|
+
content: true,
|
|
840
|
+
id: true,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
export const postDataView = dataView<PostItem>('Post')({
|
|
844
|
+
author: userDataView,
|
|
845
|
+
comments: list(commentDataView),
|
|
846
|
+
});
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
We can also define root-level lists by exporting a `Lists` object from our `views.ts` file:
|
|
850
|
+
|
|
851
|
+
```tsx
|
|
852
|
+
export const Lists = {
|
|
853
|
+
posts: postDataView,
|
|
854
|
+
};
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
This makes it possible to fetch a list of posts from the client using `useRequest`.
|
|
858
|
+
|
|
859
|
+
#### Custom Root Lists
|
|
860
|
+
|
|
861
|
+
You might want to define custom root lists that don't directly map to a single data view. For example, a search endpoint that returns a list of posts based on a search query:
|
|
862
|
+
|
|
863
|
+
```tsx
|
|
864
|
+
export const Lists = {
|
|
865
|
+
// …
|
|
866
|
+
postSearch: { procedure: 'search', view: postDataView },
|
|
867
|
+
// …
|
|
868
|
+
};
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
This maps the `postSearch` list to a `search` procedure on your post router.
|
|
872
|
+
|
|
873
|
+
### Data View Resolvers
|
|
874
|
+
|
|
875
|
+
fate data views support resolvers for computed fields. If we want to add a `commentCount` field to our `Post` data view, we can use the `resolver` helper that defines a Prisma selection for the database query together with a `resolve` function:
|
|
876
|
+
|
|
877
|
+
```tsx
|
|
878
|
+
export const postDataView = dataView<PostItem>('Post')({
|
|
879
|
+
author: userDataView,
|
|
880
|
+
commentCount: resolver<PostItem>({
|
|
881
|
+
resolve: ({ item }) => item._count?.comments ?? 0,
|
|
882
|
+
select: () => ({
|
|
883
|
+
_count: { select: { comments: true } },
|
|
884
|
+
}),
|
|
885
|
+
}),
|
|
886
|
+
comments: list(commentDataView),
|
|
887
|
+
id: true,
|
|
888
|
+
} as const;
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
This definition makes the `commentCount` field available to your client-side views.
|
|
892
|
+
|
|
893
|
+
### Generating a typed client
|
|
894
|
+
|
|
895
|
+
Now that we have defined our client views and our tRPC server, we need to connect them with some glue code. We recommend using fate's CLI for convenience.
|
|
896
|
+
|
|
897
|
+
First, make sure our tRPC `router.ts` file exports the `appRouter` object, `AppRouter` type and all the views we have defined:
|
|
898
|
+
|
|
899
|
+
```tsx
|
|
900
|
+
import { router } from './init.ts';
|
|
901
|
+
import { postRouter } from './routers/post.ts';
|
|
902
|
+
import { userRouter } from './routers/user.ts';
|
|
903
|
+
|
|
904
|
+
export const appRouter = router({
|
|
905
|
+
post: postRouter,
|
|
906
|
+
user: userRouter,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
export type AppRouter = typeof appRouter;
|
|
910
|
+
|
|
911
|
+
export * from './views.ts';
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
_Note: We try to keep magic to a minimum and you can handwrite the [generated client](https://github.com/nkzw-tech/fate/blob/main/example/client/src/lib/fate.generated.ts) if you prefer._
|
|
915
|
+
|
|
916
|
+
```bash
|
|
917
|
+
pnpm fate generate @your-org/server/trpc/router.ts client/src/lib/fate.generated.ts
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
_Note: fate uses the specified server module name to extract the server types it needs and uses the same module name to import the views into the generated client. Make sure that the module is available both at the root where you are running the CLI and in the client package._
|
|
921
|
+
|
|
922
|
+
### Creating a _fate_ Client
|
|
923
|
+
|
|
924
|
+
Now that we have generated the client types, all that remains is creating the instance of the fate client, and using it in our React app using the `FateClient` context provider.
|
|
925
|
+
|
|
926
|
+
Create a `fate.ts` file:
|
|
927
|
+
|
|
928
|
+
```tsx
|
|
929
|
+
import { createFateClient } from './lib/fate.generated';
|
|
930
|
+
|
|
931
|
+
export const fate = createFateClient({
|
|
932
|
+
links: [
|
|
933
|
+
httpBatchLink({
|
|
934
|
+
fetch: (input, init) =>
|
|
935
|
+
fetch(input, {
|
|
936
|
+
...init,
|
|
937
|
+
credentials: 'include',
|
|
938
|
+
}),
|
|
939
|
+
url: `${env('SERVER_URL')}/trpc`,
|
|
940
|
+
}),
|
|
941
|
+
],
|
|
942
|
+
});
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
Now wrap your app with the `FateClient` provider:
|
|
946
|
+
|
|
947
|
+
```tsx
|
|
948
|
+
import { FateClient } from 'react-fate';
|
|
949
|
+
import { fate } from './fate.ts';
|
|
950
|
+
|
|
951
|
+
export function App() {
|
|
952
|
+
return <FateClient client={fate}>{/* Components go here */}</FateClient>;
|
|
953
|
+
}
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
_And you are all set. Happy building!_
|
|
957
|
+
|
|
958
|
+
## Frequently Asked Questions
|
|
959
|
+
|
|
960
|
+
### Is this serious software?
|
|
961
|
+
|
|
962
|
+
[In an alternate reality](https://github.com/phacility/javelin), _fate_ can be described like this:
|
|
963
|
+
|
|
964
|
+
**_fate_** is an ambitious React data library that tries to blend Relay-style ideas with tRPC, held together by equal parts vision and vibes. It aims to fix problems you definitely wouldn't have if you enjoy writing the same fetch logic in three different places with imperative loading state and error handling. fate promises predictable data flow, minimal APIs, and "no magic", though you may occasionally suspect otherwise.
|
|
965
|
+
|
|
966
|
+
**_fate_** is almost certainly worse than actual sync engines, but will hopefully be better than existing React data-fetching libraries eventually. Use it if you have a high tolerance for pain and want to help shape the future of data fetching in React.
|
|
967
|
+
|
|
968
|
+
### Is _fate_ better than Relay?
|
|
969
|
+
|
|
970
|
+
Absolutely not.
|
|
971
|
+
|
|
972
|
+
### Is _fate_ better than using GraphQL?
|
|
973
|
+
|
|
974
|
+
Probably. One day. _Maybe._
|
|
975
|
+
|
|
976
|
+
### How was fate built?
|
|
977
|
+
|
|
978
|
+
> [!NOTE]
|
|
979
|
+
> 80% of _fate_'s code was written by OpenAI's Codex – four versions per task, carefully curated by a human. The remaining 20% was written by [@cnakazawa](https://x.com/cnakazawa). You get to decide which parts are the good ones. The docs were 100% written by a human.
|
|
980
|
+
|
|
981
|
+
## Future
|
|
982
|
+
|
|
983
|
+
**_fate_** is not complete yet. It lacks core features such as garbage collection, a compiler to extract view definitions statically and ahead of time, and there is too much backend boilerplate. The current implementation of _fate_ is not tied to tRPC or Prisma, those are just the ones we are starting with. We welcome contributions and ideas to improve fate. Here are some features we'd like to add:
|
|
984
|
+
|
|
985
|
+
- Support for Drizzle
|
|
986
|
+
- Support backends other than tRPC
|
|
987
|
+
- Better code generation and less type repetition
|
|
988
|
+
- Support for live views and real-time updates via `useLiveView` and SSE
|
|
989
|
+
- Implement garbage collection for the cache
|
|
990
|
+
- Add persistent storage for offline support
|
|
991
|
+
|
|
992
|
+
## Acknowledgements
|
|
993
|
+
|
|
994
|
+
- [Relay](https://relay.dev/), [Isograph](https://isograph.dev/) & [GraphQL](https://graphql.org/) for inspiration
|
|
995
|
+
- [Ricky Hanlon](https://x.com/rickyfm) for guidance on Async React
|
package/lib/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@nkzw/fate/cli";
|
package/lib/cli.mjs
ADDED
package/lib/index.d.mts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ConnectionRef, FateClient as FateClient$1, Pagination, Request, RequestOptions, RequestResult, View, ViewData, ViewEntity, ViewEntityName, ViewRef, ViewRef as ViewRef$1, ViewSelection, createClient, createTRPCTransport, mutation, view } from "@nkzw/fate";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/context.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Provider component that supplies a configured `FateClient` to React hooks.
|
|
8
|
+
*/
|
|
9
|
+
declare function FateClient({
|
|
10
|
+
children,
|
|
11
|
+
client
|
|
12
|
+
}: {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
client: FateClient$1;
|
|
15
|
+
}): react_jsx_runtime0.JSX.Element;
|
|
16
|
+
/**
|
|
17
|
+
* Returns the nearest `FateClient` from context.
|
|
18
|
+
*/
|
|
19
|
+
declare function useFateClient(): FateClient$1;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/useView.d.ts
|
|
22
|
+
type ViewEntityWithTypename<V extends View<any, any>> = ViewEntity<V> & {
|
|
23
|
+
__typename: ViewEntityName<V>;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a reference against a view and subscribes to updates for that selection.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const post = useView(PostView, postRef);
|
|
30
|
+
*/
|
|
31
|
+
declare function useView<V extends View<any, any>>(view: V, ref: ViewRef$1<ViewEntityName<V>>): ViewData<ViewEntityWithTypename<V>, ViewSelection<V>>;
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/useRequest.d.ts
|
|
34
|
+
/**
|
|
35
|
+
* Declares the data a screen needs and kicks off fetching, suspending while the
|
|
36
|
+
* request resolves.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const { posts } = useRequest({ posts: { root: PostView, type: 'Post' } as const });
|
|
40
|
+
*/
|
|
41
|
+
declare function useRequest<R extends Request>(request: R, options?: RequestOptions): RequestResult<R>;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/useListView.d.ts
|
|
44
|
+
type ConnectionItems<C> = C extends {
|
|
45
|
+
items?: ReadonlyArray<infer Item>;
|
|
46
|
+
} ? ReadonlyArray<Item> : ReadonlyArray<never>;
|
|
47
|
+
type LoadMoreFn = () => Promise<void>;
|
|
48
|
+
type ConnectionSelection = {
|
|
49
|
+
items?: {
|
|
50
|
+
node?: unknown;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Subscribes to a connection field, returning the current items and pagination
|
|
55
|
+
* helpers to load the next or previous page.
|
|
56
|
+
*/
|
|
57
|
+
declare function useListView<C extends {
|
|
58
|
+
items?: ReadonlyArray<any>;
|
|
59
|
+
pagination?: Pagination;
|
|
60
|
+
} | null | undefined>(selection: ConnectionSelection, connection: C): [ConnectionItems<NonNullable<C>>, LoadMoreFn | null, LoadMoreFn | null];
|
|
61
|
+
//#endregion
|
|
62
|
+
export { type ConnectionRef, FateClient, type ViewRef, createClient, createTRPCTransport, mutation, useFateClient, useListView, useRequest, useView, view };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { ConnectionTag, createClient, createTRPCTransport, isViewTag, mutation, view } from "@nkzw/fate";
|
|
2
|
+
import { createContext, use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/context.tsx
|
|
6
|
+
const FateContext = createContext(null);
|
|
7
|
+
/**
|
|
8
|
+
* Provider component that supplies a configured `FateClient` to React hooks.
|
|
9
|
+
*/
|
|
10
|
+
function FateClient({ children, client }) {
|
|
11
|
+
return /* @__PURE__ */ jsx(FateContext, {
|
|
12
|
+
value: client,
|
|
13
|
+
children
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Returns the nearest `FateClient` from context.
|
|
18
|
+
*/
|
|
19
|
+
function useFateClient() {
|
|
20
|
+
const context = use(FateContext);
|
|
21
|
+
if (!context) throw new Error(`react-fate: '<FateContext value={client}>' is missing.`);
|
|
22
|
+
return context;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/useView.tsx
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a reference against a view and subscribes to updates for that selection.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const post = useView(PostView, postRef);
|
|
32
|
+
*/
|
|
33
|
+
function useView(view$1, ref) {
|
|
34
|
+
const client = useFateClient();
|
|
35
|
+
const snapshotRef = useRef(null);
|
|
36
|
+
const getSnapshot = useCallback(() => {
|
|
37
|
+
const snapshot = client.readView(view$1, ref);
|
|
38
|
+
snapshotRef.current = snapshot.status === "fulfilled" ? snapshot.value : null;
|
|
39
|
+
return snapshot;
|
|
40
|
+
}, [
|
|
41
|
+
client,
|
|
42
|
+
view$1,
|
|
43
|
+
ref
|
|
44
|
+
]);
|
|
45
|
+
return use(useDeferredValue(useSyncExternalStore(useCallback((onStoreChange) => {
|
|
46
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
47
|
+
const onChange = () => {
|
|
48
|
+
updateSubscriptions();
|
|
49
|
+
onStoreChange();
|
|
50
|
+
};
|
|
51
|
+
const subscribe = (entityId, paths) => {
|
|
52
|
+
if (!subscriptions.has(entityId)) subscriptions.set(entityId, client.store.subscribe(entityId, paths, onChange));
|
|
53
|
+
};
|
|
54
|
+
const cleanup = (nextIds) => {
|
|
55
|
+
for (const [entityId, unsubscribe] of subscriptions) if (!nextIds.has(entityId)) {
|
|
56
|
+
unsubscribe();
|
|
57
|
+
subscriptions.delete(entityId);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const updateSubscriptions = () => {
|
|
61
|
+
if (snapshotRef.current) {
|
|
62
|
+
for (const [entityId, paths] of snapshotRef.current.coverage) subscribe(entityId, paths);
|
|
63
|
+
cleanup(new Set(snapshotRef.current.coverage.map(([id]) => id)));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
updateSubscriptions();
|
|
67
|
+
return () => {
|
|
68
|
+
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
69
|
+
subscriptions.clear();
|
|
70
|
+
};
|
|
71
|
+
}, [client.store]), getSnapshot, getSnapshot))).data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/useRequest.tsx
|
|
76
|
+
/**
|
|
77
|
+
* Declares the data a screen needs and kicks off fetching, suspending while the
|
|
78
|
+
* request resolves.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const { posts } = useRequest({ posts: { root: PostView, type: 'Post' } as const });
|
|
82
|
+
*/
|
|
83
|
+
function useRequest(request, options) {
|
|
84
|
+
const client = useFateClient();
|
|
85
|
+
const promise = client.request(request, options);
|
|
86
|
+
const mode = options?.mode ?? "cache-first";
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (mode === "network-only" || mode === "stale-while-revalidate") return () => {
|
|
89
|
+
client.releaseRequest(request, mode);
|
|
90
|
+
};
|
|
91
|
+
}, [
|
|
92
|
+
client,
|
|
93
|
+
mode,
|
|
94
|
+
request
|
|
95
|
+
]);
|
|
96
|
+
return use(useDeferredValue(promise));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/useListView.tsx
|
|
101
|
+
const getNodeView = (view$1) => {
|
|
102
|
+
const maybeView = view$1?.items?.node;
|
|
103
|
+
if (maybeView) {
|
|
104
|
+
for (const key of Object.keys(maybeView)) if (isViewTag(key)) return maybeView;
|
|
105
|
+
}
|
|
106
|
+
return view$1;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Subscribes to a connection field, returning the current items and pagination
|
|
110
|
+
* helpers to load the next or previous page.
|
|
111
|
+
*/
|
|
112
|
+
function useListView(selection, connection) {
|
|
113
|
+
const client = useFateClient();
|
|
114
|
+
const nodeView = useMemo(() => getNodeView(selection), [selection]);
|
|
115
|
+
const metadata = connection && typeof connection === "object" ? connection[ConnectionTag] : null;
|
|
116
|
+
const subscribe = useCallback((onStoreChange) => metadata ? client.store.subscribeList(metadata.key, onStoreChange) : () => {}, [client, metadata]);
|
|
117
|
+
const getSnapshot = useCallback(() => metadata ? client.store.getListState(metadata.key) : void 0, [client, metadata]);
|
|
118
|
+
const listState = useDeferredValue(useSyncExternalStore(subscribe, getSnapshot, getSnapshot));
|
|
119
|
+
const pagination = listState?.pagination ?? connection?.pagination;
|
|
120
|
+
const hasNext = Boolean(pagination?.hasNext);
|
|
121
|
+
const hasPrevious = Boolean(pagination?.hasPrevious);
|
|
122
|
+
const nextCursor = pagination?.nextCursor;
|
|
123
|
+
const previousCursor = pagination?.previousCursor;
|
|
124
|
+
return [
|
|
125
|
+
useMemo(() => {
|
|
126
|
+
if (metadata?.root && listState) return listState.ids.map((id, index) => ({
|
|
127
|
+
cursor: listState.cursors?.[index],
|
|
128
|
+
node: client.rootListRef(id, nodeView)
|
|
129
|
+
}));
|
|
130
|
+
return connection?.items;
|
|
131
|
+
}, [
|
|
132
|
+
client,
|
|
133
|
+
connection?.items,
|
|
134
|
+
listState,
|
|
135
|
+
metadata?.root,
|
|
136
|
+
nodeView
|
|
137
|
+
]),
|
|
138
|
+
useMemo(() => {
|
|
139
|
+
if (!metadata || !hasNext || !nextCursor) return null;
|
|
140
|
+
return async () => {
|
|
141
|
+
const { before, first, last, ...values } = metadata.args || {};
|
|
142
|
+
const nextPageSize = first ?? last;
|
|
143
|
+
await client.loadConnection(nodeView, metadata, {
|
|
144
|
+
...values,
|
|
145
|
+
after: nextCursor,
|
|
146
|
+
...nextPageSize !== void 0 ? { first: nextPageSize } : null
|
|
147
|
+
}, { direction: "forward" });
|
|
148
|
+
};
|
|
149
|
+
}, [
|
|
150
|
+
client,
|
|
151
|
+
hasNext,
|
|
152
|
+
nodeView,
|
|
153
|
+
metadata,
|
|
154
|
+
nextCursor
|
|
155
|
+
]),
|
|
156
|
+
useMemo(() => {
|
|
157
|
+
if (!metadata || !hasPrevious || !previousCursor) return null;
|
|
158
|
+
return async () => {
|
|
159
|
+
const { after, ...values } = metadata.args || {};
|
|
160
|
+
await client.loadConnection(nodeView, metadata, {
|
|
161
|
+
...values,
|
|
162
|
+
before: previousCursor
|
|
163
|
+
}, { direction: "backward" });
|
|
164
|
+
};
|
|
165
|
+
}, [
|
|
166
|
+
client,
|
|
167
|
+
hasPrevious,
|
|
168
|
+
nodeView,
|
|
169
|
+
metadata,
|
|
170
|
+
previousCursor
|
|
171
|
+
])
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
export { FateClient, createClient, createTRPCTransport, mutation, useFateClient, useListView, useRequest, useView, view };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-fate",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "fate is a modern data client for React.",
|
|
5
5
|
"homepage": "https://github.com/nkzw-tech/fate",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -13,12 +13,34 @@
|
|
|
13
13
|
"email": "christoph.pojer@gmail.com"
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
|
-
"
|
|
17
|
-
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/index.d.mts",
|
|
19
|
+
"default": "./lib/index.mjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"main": "./lib/index.mjs",
|
|
23
|
+
"types": "./lib/index.d.mts",
|
|
24
|
+
"bin": {
|
|
25
|
+
"fate": "./lib/cli.mjs"
|
|
26
|
+
},
|
|
18
27
|
"files": [
|
|
19
28
|
"lib"
|
|
20
29
|
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@nkzw/fate": "^0.0.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^19.2.7",
|
|
35
|
+
"@types/react-dom": "^19.2.3",
|
|
36
|
+
"react": "^19.2.1",
|
|
37
|
+
"react-dom": "^19.2.1"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "^19.2.0",
|
|
41
|
+
"react-dom": "^19.2.0"
|
|
42
|
+
},
|
|
21
43
|
"scripts": {
|
|
22
|
-
"build": "tsdown -d lib --target=node24"
|
|
44
|
+
"build": "tsdown -d lib --target=node24 src/index.tsx src/cli.ts"
|
|
23
45
|
}
|
|
24
46
|
}
|