react-fate 0.0.8 → 0.1.0

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 CHANGED
@@ -18,7 +18,7 @@
18
18
  - **Async React:** fate uses modern Async React features like Actions, Suspense, and `use` to support concurrent rendering and enable a seamless user experience.
19
19
  - **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.
20
20
  - **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.
21
- - **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.
21
+ - **AI-Ready:** fate's minimal, predictable API and explicit data selection enable local reasoning, enabling humans and AI tools to generate stable, type-safe data-fetching code.
22
22
 
23
23
  ### A modern data client for React & tRPC
24
24
 
@@ -32,14 +32,14 @@ However, GraphQL comes with its own type system and query language. If you are a
32
32
 
33
33
  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.
34
34
 
35
- 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. Using _fate_ usually looks like this:
35
+ _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. Using _fate_ usually looks like this:
36
36
 
37
37
  ```tsx
38
38
  export const PostView = view<Post>()({
39
+ author: UserView,
39
40
  content: true,
40
41
  id: true,
41
42
  title: true,
42
- author: UserView,
43
43
  });
44
44
 
45
45
  export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
@@ -65,15 +65,15 @@ Get started with [a ready-made template](https://github.com/nkzw-tech/fate-templ
65
65
 
66
66
  ::: code-group
67
67
 
68
- ```npm
68
+ ```bash [npm]
69
69
  npx giget@latest gh:nkzw-tech/fate-template
70
70
  ```
71
71
 
72
- ```pnpm
72
+ ```bash [pnpm]
73
73
  pnpx giget@latest gh:nkzw-tech/fate-template
74
74
  ```
75
75
 
76
- ```yarn
76
+ ```bash [yarn]
77
77
  yarn dlx giget@latest gh:nkzw-tech/fate-template
78
78
  ```
79
79
 
@@ -87,15 +87,15 @@ yarn dlx giget@latest gh:nkzw-tech/fate-template
87
87
 
88
88
  ::: code-group
89
89
 
90
- ```npm
90
+ ```bash [npm]
91
91
  npm add react-fate
92
92
  ```
93
93
 
94
- ```pnpm
94
+ ```bash [pnpm]
95
95
  pnpm add react-fate
96
96
  ```
97
97
 
98
- ```yarn
98
+ ```bash [yarn]
99
99
  yarn add react-fate
100
100
  ```
101
101
 
@@ -105,15 +105,15 @@ And for your server, install the core `@nkzw/fate` package:
105
105
 
106
106
  ::: code-group
107
107
 
108
- ```npm
108
+ ```bash [npm]
109
109
  npm add @nkzw/fate
110
110
  ```
111
111
 
112
- ```pnpm
112
+ ```bash [pnpm]
113
113
  pnpm add @nkzw/fate
114
114
  ```
115
115
 
116
- ```yarn
116
+ ```bash [yarn]
117
117
  yarn add @nkzw/fate
118
118
  ```
119
119
 
@@ -202,34 +202,20 @@ Components using `useView` listen to changes for all selected fields. When data
202
202
 
203
203
  ### Fetching Data with `useRequest`
204
204
 
205
- 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:
205
+ Now that we defined our view and component, we fetch the data from the server using the `useRequest` hook from fate. This hook allows us to declare what data we need for a specific screen or component tree. At the root of our app, we can request a list of posts like this:
206
206
 
207
207
  ```tsx
208
208
  import { useRequest } from 'react-fate';
209
209
  import { PostCard, PostView } from './PostCard.tsx';
210
210
 
211
- export function HomePage() {
212
- const { posts } = useRequest({
213
- posts: { root: PostView, type: 'Post' },
214
- } as const);
211
+ export function App() {
212
+ const { posts } = useRequest({ posts: { list: PostView } });
215
213
 
216
214
  return posts.map((post) => <PostCard key={post.id} post={post} />);
217
215
  }
218
216
  ```
219
217
 
220
- 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:
221
-
222
- ```tsx
223
- <ErrorBoundary FallbackComponent={ErrorComponent}>
224
- <Suspense fallback={<div>Loading…</div>}>
225
- <HomePage />
226
- </Suspense>
227
- </ErrorBoundary>
228
- ```
229
-
230
- > [!NOTE]
231
- >
232
- > `useRequest` might issue multiple requests which are automatically batched together by tRPC's [HTTP Batch Link](https://trpc.io/docs/client/links/httpBatchLink).
218
+ _Learn more about `useRequest` in the [Requests Guide](/docs/guide/requests.md)._
233
219
 
234
220
  ### Composing Views
235
221
 
@@ -237,7 +223,7 @@ In the above example we are defining a single view for a `Post`. One of fate's c
237
223
 
238
224
  ```tsx
239
225
  import { Suspense } from 'react';
240
- import { useRequest, useView, ViewRef } from 'react-fate';
226
+ import { useView, ViewRef } from 'react-fate';
241
227
 
242
228
  export const PostView = view<Post>()({
243
229
  author: {
@@ -443,23 +429,62 @@ const PostDetail = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
443
429
 
444
430
  ViewRefs carry a set of view names they can resolve. `useView` throws if a ref does not include the required view.
445
431
 
446
- ### Request Modes
432
+ ## Requests
447
433
 
448
- `useRequest` supports different request modes to control caching and data freshness. The available modes are:
434
+ ### Requesting Lists
449
435
 
450
- - `cache-first` (_default_): Returns data from the cache if available, otherwise fetches from the network.
451
- - `stale-while-revalidate`: Returns data from the cache and simultaneously fetches fresh data from the network.
452
- - `network-only`: Always fetches data from the network, bypassing the cache.
436
+ The `useRequest` hook can be used to declare our data needs for a specific screen or component tree. At the root of our app, we can request a list of posts like this:
453
437
 
454
- You can pass the request mode as an option to `useRequest`:
438
+ ```tsx
439
+ import { useRequest } from 'react-fate';
440
+ import { PostCard, PostView } from './PostCard.tsx';
441
+
442
+ export function App() {
443
+ const { posts } = useRequest({ posts: { list: PostView } });
444
+ return posts.map((post) => <PostCard key={post.id} post={post} />);
445
+ }
446
+ ```
447
+
448
+ 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:
455
449
 
456
450
  ```tsx
457
- const { posts } = useRequest(
458
- {
459
- posts: { root: PostView, type: 'Post' },
460
- },
461
- { mode: 'stale-while-revalidate' },
462
- );
451
+ <ErrorBoundary FallbackComponent={ErrorComponent}>
452
+ <Suspense fallback={<div>Loading…</div>}>
453
+ <App />
454
+ </Suspense>
455
+ </ErrorBoundary>
456
+ ```
457
+
458
+ > [!NOTE]
459
+ >
460
+ > `useRequest` might issue multiple requests which are automatically batched together by tRPC's [HTTP Batch Link](https://trpc.io/docs/client/links/httpBatchLink) into a single network request.
461
+
462
+ ### Requesting Objects by ID
463
+
464
+ If you want to fetch data for a single object instead of a list, you can specify the `id` and the associated `view` like this:
465
+
466
+ ```tsx
467
+ const { post } = useRequest({
468
+ post: { id: '12', view: PostView },
469
+ });
470
+ ```
471
+
472
+ If you want to fetch multiple objects by their IDs, you can use the `ids` field:
473
+
474
+ ```tsx
475
+ const { posts } = useRequest({
476
+ posts: { ids: ['6', '7'], view: PostView },
477
+ });
478
+ ```
479
+
480
+ ### Other Types of Requests
481
+
482
+ For any other queries, pass only the `type` and `view`:
483
+
484
+ ```tsx
485
+ const { viewer } = useRequest({
486
+ viewer: { view: UserView },
487
+ });
463
488
  ```
464
489
 
465
490
  ### Request Arguments
@@ -470,12 +495,32 @@ You can pass arguments to `useRequest` calls. This is useful for pagination, fil
470
495
  const { posts } = useRequest({
471
496
  posts: {
472
497
  args: { first: 10 },
473
- root: PostView,
474
- type: 'Post',
498
+ list: PostView,
475
499
  },
476
500
  });
477
501
  ```
478
502
 
503
+ ### Request Modes
504
+
505
+ `useRequest` supports different request modes to control caching and data freshness. The available modes are:
506
+
507
+ - `cache-first` (_default_): Returns data from the cache if available, otherwise fetches from the network.
508
+ - `stale-while-revalidate`: Returns data from the cache and simultaneously fetches fresh data from the network.
509
+ - `network-only`: Always fetches data from the network, bypassing the cache.
510
+
511
+ You can pass the request mode as an option to `useRequest`:
512
+
513
+ ```tsx
514
+ const { posts } = useRequest(
515
+ {
516
+ posts: { list: PostView },
517
+ },
518
+ {
519
+ mode: 'stale-while-revalidate',
520
+ },
521
+ );
522
+ ```
523
+
479
524
  ## List Views
480
525
 
481
526
  ### Pagination with `useListView`
@@ -497,7 +542,7 @@ const CommentConnectionView = {
497
542
  items: {
498
543
  node: CommentView,
499
544
  },
500
- } as const;
545
+ };
501
546
 
502
547
  const PostView = view<Post>()({
503
548
  comments: CommentConnectionView,
@@ -915,7 +960,7 @@ export const postDataView = dataView<PostItem>('Post')({
915
960
  content: true,
916
961
  id: true,
917
962
  title: true,
918
- } as const;
963
+ });
919
964
  ```
920
965
 
921
966
  ### Data View Lists
@@ -950,6 +995,25 @@ export const Root = {
950
995
 
951
996
  Entries that wrap their view in `list(...)` are treated as list resolvers and use the `procedure` name when calling the corresponding router procedure, defaulting to `list`. If you omit `list(...)`, fate treats the entry as a standard query and uses the view type name to infer the router name.
952
997
 
998
+ For the above `Root` definitions, you can make the following requests using `useRequest`:
999
+
1000
+ ```tsx
1001
+ const query = 'Apple';
1002
+
1003
+ const { posts, categories, viewer } = useRequest({
1004
+ // Explicit Root queries:
1005
+ categories: { list: categoryView },
1006
+ commentSearch: { args: { query }, list: commentView },
1007
+ events: { list: eventView },
1008
+ posts: { list: postView },
1009
+ viewer: { view: userView },
1010
+
1011
+ // Queries by id, if those entities have a `byId` query defined:
1012
+ post: { id: '12', view: postView },
1013
+ comment: { ids: ['6', '7'], view: commentView },
1014
+ });
1015
+ ```
1016
+
953
1017
  ### Data View Resolvers
954
1018
 
955
1019
  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:
@@ -1015,34 +1079,30 @@ _Note: fate uses the specified server module name to extract the server types it
1015
1079
 
1016
1080
  ### Creating a _fate_ Client
1017
1081
 
1018
- 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.
1019
-
1020
- Create a `fate.ts` file:
1021
-
1022
- ```tsx
1023
- import { createFateClient } from './lib/fate.generated';
1024
-
1025
- export const fate = createFateClient({
1026
- links: [
1027
- httpBatchLink({
1028
- fetch: (input, init) =>
1029
- fetch(input, {
1030
- ...init,
1031
- credentials: 'include',
1032
- }),
1033
- url: `${env('SERVER_URL')}/trpc`,
1034
- }),
1035
- ],
1036
- });
1037
- ```
1038
-
1039
- Now wrap your app with the `FateClient` provider:
1082
+ Now that we have generated the client types, all that remains is creating an instance of the fate client, and using it in our React app using the `FateClient` context provider:
1040
1083
 
1041
1084
  ```tsx
1085
+ import { httpBatchLink } from '@trpc/client';
1042
1086
  import { FateClient } from 'react-fate';
1043
- import { fate } from './fate.ts';
1087
+ import { createFateClient } from './fate.ts';
1044
1088
 
1045
1089
  export function App() {
1090
+ const fate = useMemo(
1091
+ () =>
1092
+ createFateClient({
1093
+ links: [
1094
+ httpBatchLink({
1095
+ fetch: (input, init) =>
1096
+ fetch(input, {
1097
+ ...init,
1098
+ credentials: 'include',
1099
+ }),
1100
+ url: `${env('SERVER_URL')}/trpc`,
1101
+ }),
1102
+ ],
1103
+ }),
1104
+ [],
1105
+ );
1046
1106
  return <FateClient client={fate}>{/* Components go here */}</FateClient>;
1047
1107
  }
1048
1108
  ```
@@ -1076,14 +1136,14 @@ Probably. One day. _Maybe._
1076
1136
 
1077
1137
  ## Future
1078
1138
 
1079
- **_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:
1139
+ **_fate_** is not complete yet. The library lacks core features such as garbage collection, a compiler to extract view definitions statically 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:
1080
1140
 
1081
1141
  - Support for Drizzle
1082
1142
  - Support backends other than tRPC
1143
+ - Persistent storage for offline support
1144
+ - Implement garbage collection for the cache
1083
1145
  - Better code generation and less type repetition
1084
1146
  - Support for live views and real-time updates via `useLiveView` and SSE
1085
- - Implement garbage collection for the cache
1086
- - Add persistent storage for offline support
1087
1147
 
1088
1148
  ## Acknowledgements
1089
1149
 
package/lib/index.d.mts CHANGED
@@ -1,7 +1,18 @@
1
- import { ConnectionRef, FateClient as FateClient$1, FateMutations, Pagination, Request, RequestOptions, RequestResult, View, ViewData, ViewEntity, ViewEntityName, ViewRef, ViewRef as ViewRef$1, ViewSelection, createClient, createTRPCTransport, mutation, view } from "@nkzw/fate";
1
+ import { ConnectionRef, FateClient as FateClient$1, FateMutations, FateRoots, Pagination, Request, RequestOptions, RequestResult, View, ViewData, ViewEntity, ViewEntityName, ViewRef, ViewRef as ViewRef$1, ViewSelection, clientRoot, createClient, createTRPCTransport, mutation, toEntityId, view } from "@nkzw/fate";
2
2
  import { ReactNode } from "react";
3
3
  import * as react_jsx_runtime0 from "react/jsx-runtime";
4
4
 
5
+ //#region src/useRequest.d.ts
6
+ type Roots = keyof ClientRoots extends never ? FateRoots : ClientRoots;
7
+ /**
8
+ * Declares the data a screen needs and kicks off fetching, suspending while the
9
+ * request resolves.
10
+ *
11
+ * @example
12
+ * const { posts } = useRequest({ posts: { list: PostView } });
13
+ */
14
+ declare function useRequest<R extends Request, O extends FateRoots = Roots>(request: R, options?: RequestOptions): RequestResult<O, R>;
15
+ //#endregion
5
16
  //#region src/context.d.ts
6
17
  type Mutations = keyof ClientMutations extends never ? FateMutations : ClientMutations;
7
18
  /**
@@ -12,12 +23,12 @@ declare function FateClient({
12
23
  client
13
24
  }: {
14
25
  children: ReactNode;
15
- client: FateClient$1<any>;
26
+ client: FateClient$1<any, any>;
16
27
  }): react_jsx_runtime0.JSX.Element;
17
28
  /**
18
29
  * Returns the nearest `FateClient` from context.
19
30
  */
20
- declare function useFateClient<M extends Mutations>(): FateClient$1<M>;
31
+ declare function useFateClient<T extends [Roots, Mutations]>(): FateClient$1<T[0], T[1]>;
21
32
  //#endregion
22
33
  //#region src/useView.d.ts
23
34
  type ViewEntityWithTypename<V extends View<any, any>> = ViewEntity<V> & {
@@ -29,17 +40,7 @@ type ViewEntityWithTypename<V extends View<any, any>> = ViewEntity<V> & {
29
40
  * @example
30
41
  * const post = useView(PostView, postRef);
31
42
  */
32
- declare function useView<V extends View<any, any>>(view: V, ref: ViewRef$1<ViewEntityName<V>>): ViewData<ViewEntityWithTypename<V>, ViewSelection<V>>;
33
- //#endregion
34
- //#region src/useRequest.d.ts
35
- /**
36
- * Declares the data a screen needs and kicks off fetching, suspending while the
37
- * request resolves.
38
- *
39
- * @example
40
- * const { posts } = useRequest({ posts: { root: PostView, type: 'Post' } as const });
41
- */
42
- declare function useRequest<R extends Request>(request: R, options?: RequestOptions): RequestResult<R>;
43
+ declare function useView<V extends View<any, any>, R extends ViewRef$1<ViewEntityName<V>> | null>(view: V, ref: R): R extends null ? null : ViewData<ViewEntityWithTypename<V>, ViewSelection<V>>;
43
44
  //#endregion
44
45
  //#region src/useListView.d.ts
45
46
  type ConnectionItems<C> = C extends {
@@ -62,5 +63,6 @@ declare function useListView<C extends {
62
63
  //#endregion
63
64
  //#region src/index.d.ts
64
65
  interface ClientMutations {}
66
+ interface ClientRoots {}
65
67
  //#endregion
66
- export { ClientMutations, type ConnectionRef, FateClient, type ViewRef, createClient, createTRPCTransport, mutation, useFateClient, useListView, useRequest, useView, view };
68
+ export { ClientMutations, ClientRoots, type ConnectionRef, FateClient, type ViewRef, clientRoot, createClient, createTRPCTransport, mutation, toEntityId, useFateClient, useListView, useRequest, useView, view };
package/lib/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { ConnectionTag, createClient, createTRPCTransport, isViewTag, mutation, view } from "@nkzw/fate";
1
+ import { ConnectionTag, clientRoot, createClient, createTRPCTransport, isViewTag, mutation, toEntityId, view } from "@nkzw/fate";
2
2
  import { createContext, use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -24,25 +24,34 @@ function useFateClient() {
24
24
 
25
25
  //#endregion
26
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
- */
27
+ const nullSnapshot = {
28
+ status: "fulfilled",
29
+ then(onfulfilled, onrejected) {
30
+ return Promise.resolve(null).then(onfulfilled, onrejected);
31
+ },
32
+ value: null
33
+ };
33
34
  function useView(view$1, ref) {
34
35
  const client = useFateClient();
35
36
  const snapshotRef = useRef(null);
36
37
  const getSnapshot = useCallback(() => {
37
- const snapshot = client.readView(view$1, ref);
38
- snapshotRef.current = snapshot.status === "fulfilled" ? snapshot.value : null;
39
- return snapshot;
38
+ if (ref === null) {
39
+ snapshotRef.current = null;
40
+ return nullSnapshot;
41
+ }
42
+ const snapshot$1 = client.readView(view$1, ref);
43
+ snapshotRef.current = snapshot$1.status === "fulfilled" ? snapshot$1.value : null;
44
+ return snapshot$1;
40
45
  }, [
41
46
  client,
42
47
  view$1,
43
48
  ref
44
49
  ]);
45
- return use(useDeferredValue(useSyncExternalStore(useCallback((onStoreChange) => {
50
+ const snapshot = use(useDeferredValue(useSyncExternalStore(useCallback((onStoreChange) => {
51
+ if (ref === null) {
52
+ snapshotRef.current = null;
53
+ return () => {};
54
+ }
46
55
  const subscriptions = /* @__PURE__ */ new Map();
47
56
  const onChange = () => {
48
57
  updateSubscriptions();
@@ -68,7 +77,8 @@ function useView(view$1, ref) {
68
77
  for (const unsubscribe of subscriptions.values()) unsubscribe();
69
78
  subscriptions.clear();
70
79
  };
71
- }, [client.store]), getSnapshot, getSnapshot))).data;
80
+ }, [client.store, ref]), getSnapshot, getSnapshot)));
81
+ return snapshot ? snapshot.data : null;
72
82
  }
73
83
 
74
84
  //#endregion
@@ -78,7 +88,7 @@ function useView(view$1, ref) {
78
88
  * request resolves.
79
89
  *
80
90
  * @example
81
- * const { posts } = useRequest({ posts: { root: PostView, type: 'Post' } as const });
91
+ * const { posts } = useRequest({ posts: { list: PostView } });
82
92
  */
83
93
  function useRequest(request, options) {
84
94
  const client = useFateClient();
@@ -173,4 +183,4 @@ function useListView(selection, connection) {
173
183
  }
174
184
 
175
185
  //#endregion
176
- export { FateClient, createClient, createTRPCTransport, mutation, useFateClient, useListView, useRequest, useView, view };
186
+ export { FateClient, clientRoot, createClient, createTRPCTransport, mutation, toEntityId, useFateClient, useListView, useRequest, useView, view };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-fate",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "fate is a modern data client for React.",
5
5
  "homepage": "https://github.com/nkzw-tech/fate",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "lib"
29
29
  ],
30
30
  "dependencies": {
31
- "@nkzw/fate": "^0.0.8"
31
+ "@nkzw/fate": "^0.1.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/react": "^19.2.7",