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 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
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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
@@ -0,0 +1,3 @@
1
+ import "@nkzw/fate/cli";
2
+
3
+ export { };
@@ -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.0",
4
- "description": "Fate",
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
- "main": "./lib/index.js",
17
- "types": "./lib/index.d.ts",
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
  }