@mittwald/react-ghostmaker 1.5.0-alpha.1 β 1.5.1
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 +871 -0
- package/dist/esm/index.js +7 -7
- package/dist/esm/invalidate.js +4 -8
- package/dist/esm/makeGhost.js +8 -6
- package/dist/esm/makeGhost.test-d.js +1 -1
- package/dist/esm/makeGhost.test.js +11 -9
- package/dist/esm/maybeGhost/asGhost.js +2 -2
- package/dist/esm/maybeGhost/asGhost.test.js +2 -2
- package/dist/esm/maybeGhost/index.js +2 -2
- package/dist/esm/maybeGhost/types.test-d.js +1 -1
- package/dist/esm/polytype.js +2 -2
- package/dist/esm/queries.js +3 -3
- package/dist/esm/queries.test.js +1 -1
- package/dist/esm/reset.js +6 -0
- package/dist/esm/resolveGhostChain.js +4 -11
- package/dist/esm/testMocks.js +2 -2
- package/dist/esm/useGhostChain.js +66 -34
- package/dist/esm/withEachGhost.js +10 -0
- package/dist/types/index.d.ts +8 -8
- package/dist/types/invalidate.d.ts +1 -1
- package/dist/types/makeGhost.d.ts +1 -1
- package/dist/types/maybeGhost/asGhost.d.ts +2 -2
- package/dist/types/maybeGhost/index.d.ts +2 -2
- package/dist/types/maybeGhost/types.d.ts +1 -1
- package/dist/types/modelIdentifier.d.ts +1 -1
- package/dist/types/polytype.d.ts +1 -1
- package/dist/types/queries.d.ts +3 -3
- package/dist/types/reset.d.ts +3 -0
- package/dist/types/resolveGhostChain.d.ts +1 -1
- package/dist/types/testMocks.d.ts +2 -2
- package/dist/types/types.d.ts +8 -3
- package/dist/types/useGhostChain.d.ts +2 -2
- package/dist/types/withEachGhost.d.ts +2 -0
- package/package.json +23 -23
package/README.md
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
# π» React Ghost Maker
|
|
2
|
+
|
|
3
|
+
Transform your async models into React Suspense-ready ghosts
|
|
4
|
+
|
|
5
|
+
React Ghost Maker creates intelligent proxy objects (ghosts) from your domain
|
|
6
|
+
models that seamlessly integrate with React Suspense and TanStack Query. These
|
|
7
|
+
ghosts automatically handle async operations, caching, and loading states,
|
|
8
|
+
making your code cleaner and more declarative.
|
|
9
|
+
|
|
10
|
+
## π Table of Contents
|
|
11
|
+
|
|
12
|
+
- [β¨ Features](#-features)
|
|
13
|
+
- [π Quick Start](#-quick-start)
|
|
14
|
+
- [π Basic Usage](#-basic-usage)
|
|
15
|
+
- [π¨ Advanced Features](#-advanced-features)
|
|
16
|
+
- [π Error Handling & Loading States](#-error-handling--loading-states)
|
|
17
|
+
- [ποΈ Working with Domain Models](#οΈ-working-with-domain-models)
|
|
18
|
+
- [π Flexible Component Props with MaybeReactGhost](#-flexible-component-props-with-maybereactghost)
|
|
19
|
+
- [π§ Cache Management](#-cache-management)
|
|
20
|
+
- [π Complete Example](#-complete-example)
|
|
21
|
+
- [π API Reference](#-api-reference)
|
|
22
|
+
- [π€ Requirements](#-requirements)
|
|
23
|
+
- [π License](#-license)
|
|
24
|
+
|
|
25
|
+
## β¨ Features
|
|
26
|
+
|
|
27
|
+
π **Ghost Proxies**: Transform any objectβmodels, existing API clients,
|
|
28
|
+
services, or utilities into a suspense-ready ghost
|
|
29
|
+
|
|
30
|
+
- β‘ **Lazy Execution**: Ghosts don't execute until `.use()` or `.render()` is
|
|
31
|
+
called
|
|
32
|
+
- π― **Precise Loading States**: Control exactly where Suspense boundaries
|
|
33
|
+
trigger
|
|
34
|
+
- π **TanStack Query Integration**: Built-in caching and query management
|
|
35
|
+
- π **No Query Key Management**: Automatic query key generation - no manual key
|
|
36
|
+
handling required
|
|
37
|
+
- π‘οΈ **Type Safe**: Full TypeScript support with preserved method signatures
|
|
38
|
+
- π **Method Chaining**: Chain async method calls naturally
|
|
39
|
+
- π¨ **Transform & Render**: Transform data and render components seamlessly
|
|
40
|
+
- π¦ **Minimal Dependencies**: Only peer dependencies on React and TanStack
|
|
41
|
+
Query
|
|
42
|
+
|
|
43
|
+
## π Quick Start
|
|
44
|
+
|
|
45
|
+
### Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install @mittwald/react-ghostmaker @tanstack/react-query
|
|
49
|
+
# or
|
|
50
|
+
pnpm add @mittwald/react-ghostmaker @tanstack/react-query
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Setup
|
|
54
|
+
|
|
55
|
+
Wrap your app with TanStack Query's `QueryClientProvider`:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
59
|
+
import { StrictMode } from "react";
|
|
60
|
+
import { createRoot } from "react-dom/client";
|
|
61
|
+
import App from "./App";
|
|
62
|
+
|
|
63
|
+
const queryClient = new QueryClient({
|
|
64
|
+
defaultOptions: {
|
|
65
|
+
queries: {
|
|
66
|
+
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
createRoot(document.getElementById("root")!).render(
|
|
72
|
+
<StrictMode>
|
|
73
|
+
<QueryClientProvider client={queryClient}>
|
|
74
|
+
<App />
|
|
75
|
+
</QueryClientProvider>
|
|
76
|
+
</StrictMode>,
|
|
77
|
+
);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Why React Ghost Maker?
|
|
81
|
+
|
|
82
|
+
**No More Query Key Management!** π
|
|
83
|
+
|
|
84
|
+
With traditional TanStack Query, you need to manually manage query keys:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// β Traditional TanStack Query - Manual key management
|
|
88
|
+
function BlogComponent({ blogId }: { blogId: string }) {
|
|
89
|
+
const { data: blog } = useQuery({
|
|
90
|
+
queryKey: ["blog", blogId],
|
|
91
|
+
queryFn: () => fetchBlog(blogId),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const { data: author } = useQuery({
|
|
95
|
+
queryKey: ["blog", blogId, "author"],
|
|
96
|
+
queryFn: () => fetchAuthor(blog.authorId),
|
|
97
|
+
enabled: !!blog,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// More queries = more key management complexity...
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
With React Ghost Maker, query keys are handled automatically:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
// β
React Ghost Maker - Zero key management
|
|
108
|
+
function BlogComponent({ blogId }: { blogId: string }) {
|
|
109
|
+
const blogGhost = BlogGhost.ofId(blogId);
|
|
110
|
+
|
|
111
|
+
// Automatic query keys based on method chains
|
|
112
|
+
const author = blogGhost.getDetailed().getAuthor().use();
|
|
113
|
+
|
|
114
|
+
// No keys to manage, no dependencies to track!
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Lazy Execution & Precise Loading States!** β‘
|
|
119
|
+
|
|
120
|
+
Ghosts are completely lazy - they don't execute until you call `.use()` or
|
|
121
|
+
`.render()`. This means you can:
|
|
122
|
+
|
|
123
|
+
- **Define ghosts anywhere** in your application
|
|
124
|
+
- **Pass them down** through component trees without triggering requests
|
|
125
|
+
- **Control exactly where** Suspense boundaries trigger
|
|
126
|
+
- **Create precise loading states** deep in your component hierarchy
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
// β
Ghost creation is instant - no network request yet
|
|
130
|
+
function App() {
|
|
131
|
+
const blogGhost = BlogGhost.ofId("123").getDetailed();
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Layout>
|
|
135
|
+
<Sidebar />
|
|
136
|
+
<MainContent>
|
|
137
|
+
{/* Suspense only triggers HERE when data is actually needed */}
|
|
138
|
+
<Suspense fallback={<BlogSkeleton />}>
|
|
139
|
+
<BlogDetails blog={blogGhost} />
|
|
140
|
+
</Suspense>
|
|
141
|
+
</MainContent>
|
|
142
|
+
</Layout>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function BlogDetails({ blog }: { blog: ReactGhost<DetailedBlog> }) {
|
|
147
|
+
// Network request happens HERE, not in App component
|
|
148
|
+
const blogData = blog.use();
|
|
149
|
+
|
|
150
|
+
return <article>{blogData.title}</article>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// vs. Traditional approach - immediate execution
|
|
154
|
+
function TraditionalApp() {
|
|
155
|
+
// β Query executes immediately, forces Suspense at top level
|
|
156
|
+
const { data: blog } = useQuery({
|
|
157
|
+
queryKey: ["blog", "123"],
|
|
158
|
+
queryFn: () => fetchBlog("123"),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Must handle loading at this level
|
|
162
|
+
if (!blog) return <GlobalLoader />;
|
|
163
|
+
|
|
164
|
+
return <Layout>...</Layout>;
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## π Basic Usage
|
|
169
|
+
|
|
170
|
+
### Creating Your First Ghost
|
|
171
|
+
|
|
172
|
+
React Ghost Maker can turn any object into a ghostβnot just domain models, but
|
|
173
|
+
also existing API clients, service objects, or utility classes. This makes it
|
|
174
|
+
easy to add Suspense and caching to legacy code or third-party libraries.
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { makeGhost } from "@mittwald/react-ghostmaker";
|
|
178
|
+
import { Suspense } from "react";
|
|
179
|
+
|
|
180
|
+
// Example: Wrapping an API client
|
|
181
|
+
class BlogApiClient {
|
|
182
|
+
async fetchBlog(id: string) {
|
|
183
|
+
const response = await fetch(`/api/blogs/${id}`);
|
|
184
|
+
return response.json();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const BlogApiGhost = makeGhost(new BlogApiClient());
|
|
189
|
+
|
|
190
|
+
function BlogView() {
|
|
191
|
+
// Use ghostified API client
|
|
192
|
+
const blog = BlogApiGhost.fetchBlog("123").use();
|
|
193
|
+
return <article>{blog.title}</article>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ...existing code...
|
|
197
|
+
|
|
198
|
+
// You can also use domain models as shown below:
|
|
199
|
+
class Blog {
|
|
200
|
+
constructor(public id: string) {}
|
|
201
|
+
static ofId(id: string): Blog {
|
|
202
|
+
return new Blog(id);
|
|
203
|
+
}
|
|
204
|
+
async getDetailed() {
|
|
205
|
+
const response = await fetch(`/api/blogs/${this.id}`);
|
|
206
|
+
const data = await response.json();
|
|
207
|
+
return new DetailedBlog(data);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
class DetailedBlog extends Blog {
|
|
212
|
+
constructor(data: { id: string; title: string; author: string }) {
|
|
213
|
+
super(data.id);
|
|
214
|
+
this.title = data.title;
|
|
215
|
+
this.author = data.author;
|
|
216
|
+
}
|
|
217
|
+
public readonly title: string;
|
|
218
|
+
public readonly author: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const BlogGhost = makeGhost(Blog);
|
|
222
|
+
|
|
223
|
+
function BlogViewModel() {
|
|
224
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
225
|
+
const { value: blogTitle, invalidate } = blogGhost
|
|
226
|
+
.getDetailed()
|
|
227
|
+
.title.transform((title) => title.toUpperCase())
|
|
228
|
+
.useGhost();
|
|
229
|
+
return (
|
|
230
|
+
<article>
|
|
231
|
+
<h2>{blogTitle}</h2>
|
|
232
|
+
<button onClick={invalidate}>Refresh</button>
|
|
233
|
+
</article>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Wrap with Suspense
|
|
238
|
+
function App() {
|
|
239
|
+
return (
|
|
240
|
+
<Suspense fallback={<div>Loading blog...</div>}>
|
|
241
|
+
<BlogView />
|
|
242
|
+
<BlogViewModel />
|
|
243
|
+
</Suspense>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Method Chaining
|
|
249
|
+
|
|
250
|
+
Ghosts support natural method chaining for complex async operations:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
class BlogService {
|
|
254
|
+
async getBlog(id: string) {
|
|
255
|
+
return new Blog(id);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
class Blog {
|
|
260
|
+
constructor(public id: string) {}
|
|
261
|
+
|
|
262
|
+
async getAuthor() {
|
|
263
|
+
const response = await fetch(`/api/blogs/${this.id}/author`);
|
|
264
|
+
return new Author(await response.json());
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
class Author {
|
|
269
|
+
constructor(public data: any) {}
|
|
270
|
+
|
|
271
|
+
async getProfile() {
|
|
272
|
+
const response = await fetch(`/api/authors/${this.data.id}/profile`);
|
|
273
|
+
return response.json();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const BlogServiceGhost = makeGhost(new BlogService());
|
|
278
|
+
|
|
279
|
+
function BlogAuthorProfile() {
|
|
280
|
+
// Chain multiple async operations seamlessly
|
|
281
|
+
const authorProfile = BlogServiceGhost.getBlog("123")
|
|
282
|
+
.getAuthor()
|
|
283
|
+
.getProfile()
|
|
284
|
+
.use();
|
|
285
|
+
|
|
286
|
+
return <div>{authorProfile.bio}</div>;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## π¨ Advanced Features
|
|
291
|
+
|
|
292
|
+
### Transform Data
|
|
293
|
+
|
|
294
|
+
Transform your data before rendering:
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
function BlogTitle() {
|
|
298
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
299
|
+
|
|
300
|
+
const title = blogGhost
|
|
301
|
+
.getDetailed()
|
|
302
|
+
.title.transform((title) => title.toUpperCase())
|
|
303
|
+
.use();
|
|
304
|
+
|
|
305
|
+
return <h1>{title}</h1>;
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Render Method
|
|
310
|
+
|
|
311
|
+
Use the `render` method for inline rendering:
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
function BlogContent() {
|
|
315
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
316
|
+
|
|
317
|
+
return <div>{blogGhost.getDetailed().title.render()}</div>;
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Custom Query Options
|
|
322
|
+
|
|
323
|
+
Pass TanStack Query options for fine-grained control:
|
|
324
|
+
|
|
325
|
+
```tsx
|
|
326
|
+
function CachedBlogData() {
|
|
327
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
328
|
+
|
|
329
|
+
const blogData = blogGhost.getDetailed().use({
|
|
330
|
+
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
331
|
+
retry: 3,
|
|
332
|
+
refetchOnWindowFocus: false,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return <div>{blogData.title}</div>;
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Access Query State with useGhost
|
|
340
|
+
|
|
341
|
+
Use `useGhost` for full query state access and invalidation:
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
function BlogWithControls() {
|
|
345
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
346
|
+
|
|
347
|
+
const { value: blogData, invalidate } = blogGhost.getDetailed().useGhost({
|
|
348
|
+
staleTime: 5 * 60 * 1000,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div>
|
|
353
|
+
<h1>{blogData.title}</h1>
|
|
354
|
+
<p>By: {blogData.author}</p>
|
|
355
|
+
<button onClick={invalidate}>Refresh Blog</button>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Direct Await Outside React
|
|
362
|
+
|
|
363
|
+
You can directly await ghosts outside of React components for imperative
|
|
364
|
+
operations:
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
const BlogGhost = makeGhost(Blog);
|
|
368
|
+
|
|
369
|
+
// Direct await for form submission
|
|
370
|
+
async function handleBlogUpdate(blogId: string, updates: any) {
|
|
371
|
+
try {
|
|
372
|
+
const blogGhost = BlogGhost.ofId(blogId);
|
|
373
|
+
const blogData = await blogGhost.getDetailed();
|
|
374
|
+
|
|
375
|
+
await updateBlog(blogData.id, updates);
|
|
376
|
+
|
|
377
|
+
// Invalidate cache after update
|
|
378
|
+
invalidateGhostsById(`blog-${blogData.id}`);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error("Failed to update blog:", error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Use in event handlers
|
|
385
|
+
function DeleteButton() {
|
|
386
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
387
|
+
|
|
388
|
+
const handleDelete = async () => {
|
|
389
|
+
if (confirm("Are you sure?")) {
|
|
390
|
+
const blog = await blogGhost;
|
|
391
|
+
await deleteBlog(blog.id);
|
|
392
|
+
invalidateGhostsById(`blog-${blog.id}`);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return <button onClick={handleDelete}>Delete Blog</button>;
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## π Error Handling & Loading States
|
|
401
|
+
|
|
402
|
+
### Error Boundaries
|
|
403
|
+
|
|
404
|
+
Handle errors with React Error Boundaries:
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
import { ErrorBoundary } from "react-error-boundary";
|
|
408
|
+
|
|
409
|
+
function ErrorFallback({ error, resetErrorBoundary }: any) {
|
|
410
|
+
return (
|
|
411
|
+
<div role="alert">
|
|
412
|
+
<h2>Something went wrong:</h2>
|
|
413
|
+
<pre>{error.message}</pre>
|
|
414
|
+
<button onClick={resetErrorBoundary}>Try again</button>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function App() {
|
|
420
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
424
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
425
|
+
<BlogView />
|
|
426
|
+
</Suspense>
|
|
427
|
+
</ErrorBoundary>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Nested Suspense Boundaries
|
|
433
|
+
|
|
434
|
+
Create granular loading states with nested Suspense:
|
|
435
|
+
|
|
436
|
+
```tsx
|
|
437
|
+
function BlogPage() {
|
|
438
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<article>
|
|
442
|
+
<Suspense fallback={<div>Loading title...</div>}>
|
|
443
|
+
<BlogTitle ghost={blogGhost} />
|
|
444
|
+
</Suspense>
|
|
445
|
+
|
|
446
|
+
<Suspense fallback={<div>Loading content...</div>}>
|
|
447
|
+
<BlogContent ghost={blogGhost} />
|
|
448
|
+
</Suspense>
|
|
449
|
+
|
|
450
|
+
<Suspense fallback={<div>Loading author...</div>}>
|
|
451
|
+
<BlogAuthor ghost={blogGhost} />
|
|
452
|
+
</Suspense>
|
|
453
|
+
</article>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
## ποΈ Working with Domain Models
|
|
460
|
+
|
|
461
|
+
### Model Identification
|
|
462
|
+
|
|
463
|
+
#### Using the `GhostMakerModel` Decorator (Recommended)
|
|
464
|
+
|
|
465
|
+
You can now use the `GhostMakerModel` decorator to define model names and IDs for automatic query key generation. This is the recommended and most convenient way to ensure stable and meaningful cache keys for your models.
|
|
466
|
+
|
|
467
|
+
```tsx
|
|
468
|
+
import { GhostMakerModel } from "@mittwald/react-ghostmaker";
|
|
469
|
+
|
|
470
|
+
@GhostMakerModel({
|
|
471
|
+
name: "User",
|
|
472
|
+
getId: (user) => user.id,
|
|
473
|
+
})
|
|
474
|
+
class User {
|
|
475
|
+
constructor(
|
|
476
|
+
public id: string,
|
|
477
|
+
public name: string,
|
|
478
|
+
) {}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
@GhostMakerModel({
|
|
482
|
+
name: "Blog",
|
|
483
|
+
getId: (blog) => blog.id,
|
|
484
|
+
})
|
|
485
|
+
class Blog {
|
|
486
|
+
constructor(
|
|
487
|
+
public id: string,
|
|
488
|
+
public title: string,
|
|
489
|
+
) {}
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Advantages:**
|
|
494
|
+
|
|
495
|
+
- Declarative, directly on the model class
|
|
496
|
+
- No need for central registration
|
|
497
|
+
- Name and ID can be set explicitly (for readable query keys)
|
|
498
|
+
- Works for multiple models and inheritance
|
|
499
|
+
|
|
500
|
+
#### Alternative: `registerModelIdentifier` (deprecated)
|
|
501
|
+
|
|
502
|
+
The previous `registerModelIdentifier` function is still available but marked as deprecated. It can be used to centrally register IDs for models:
|
|
503
|
+
|
|
504
|
+
```tsx
|
|
505
|
+
import { registerModelIdentifier } from "@mittwald/react-ghostmaker";
|
|
506
|
+
|
|
507
|
+
registerModelIdentifier((model) => {
|
|
508
|
+
if (model instanceof User) return `user-${model.id}`;
|
|
509
|
+
if (model instanceof Blog) return `blog-${model.id}`;
|
|
510
|
+
return undefined;
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
> **Note:** Prefer the `GhostMakerModel` decorator for new projects.
|
|
515
|
+
|
|
516
|
+
## π Flexible Component Props with MaybeReactGhost
|
|
517
|
+
|
|
518
|
+
The `MaybeReactGhost` pattern allows your components to accept both ghost
|
|
519
|
+
objects and regular objects, making them more flexible and reusable.
|
|
520
|
+
|
|
521
|
+
### Basic Usage
|
|
522
|
+
|
|
523
|
+
Use `MaybeReactGhost<T>` for component props that should work with both ghosts
|
|
524
|
+
and regular objects:
|
|
525
|
+
|
|
526
|
+
```tsx
|
|
527
|
+
import { type MaybeReactGhost, asGhostProps } from "@mittwald/react-ghostmaker";
|
|
528
|
+
|
|
529
|
+
interface Props {
|
|
530
|
+
blog: MaybeReactGhost<Blog>;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function BlogCard(props: Props) {
|
|
534
|
+
// Automatically handles both ghost and regular objects
|
|
535
|
+
const { blogGhost } = asGhostProps(props);
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<div>
|
|
539
|
+
{blogGhost.getDetailed().title.render()}
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Works with both ghosts and regular objects
|
|
545
|
+
<BlogCard blog={BlogGhost.ofId("123")} />
|
|
546
|
+
<BlogCard blog={new Blog("123")} />
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Advanced Patterns
|
|
550
|
+
|
|
551
|
+
You can build more complex component hierarchies where data can be passed down
|
|
552
|
+
either as resolved values or as ghosts:
|
|
553
|
+
|
|
554
|
+
```tsx
|
|
555
|
+
// This component can work with both resolved and unresolved blog data
|
|
556
|
+
function BlogDisplay(props: { blog: MaybeReactGhost<Blog> }) {
|
|
557
|
+
const { blogGhost } = asGhostProps(props);
|
|
558
|
+
|
|
559
|
+
const { value: blogData, invalidate } = blogGhost.getDetailed().useGhost();
|
|
560
|
+
|
|
561
|
+
return (
|
|
562
|
+
<article>
|
|
563
|
+
<h1>{blogData.title}</h1>
|
|
564
|
+
<p>By: {blogData.author}</p>
|
|
565
|
+
<button onClick={invalidate}>Refresh</button>
|
|
566
|
+
{/* Pass down as props - can be either resolved or ghost */}
|
|
567
|
+
<BlogComments blog={blogGhost} />
|
|
568
|
+
<BlogSidebar blog={blogData} /> {/* Already resolved */}
|
|
569
|
+
</article>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function BlogComments(props: { blog: MaybeReactGhost<Blog> }) {
|
|
574
|
+
const { blogGhost } = asGhostProps(props);
|
|
575
|
+
const comments = blogGhost.getComments().use();
|
|
576
|
+
|
|
577
|
+
return (
|
|
578
|
+
<div>
|
|
579
|
+
{comments.map((comment) => (
|
|
580
|
+
<div key={comment.id}>{comment.text}</div>
|
|
581
|
+
))}
|
|
582
|
+
</div>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function BlogSidebar(props: { blog: Blog }) {
|
|
587
|
+
// This component expects resolved data
|
|
588
|
+
return (
|
|
589
|
+
<aside>
|
|
590
|
+
<h3>About this blog</h3>
|
|
591
|
+
<p>Blog ID: {props.blog.id}</p>
|
|
592
|
+
</aside>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### When to Use MaybeReactGhost
|
|
598
|
+
|
|
599
|
+
Use `MaybeReactGhost<T>` when:
|
|
600
|
+
|
|
601
|
+
- You want components that can work with both async (ghost) and sync (regular)
|
|
602
|
+
data
|
|
603
|
+
- Building reusable components that might receive data in different loading
|
|
604
|
+
states
|
|
605
|
+
- Creating component libraries that should be flexible about data sources
|
|
606
|
+
- Passing data down component trees where some levels might resolve the data
|
|
607
|
+
early
|
|
608
|
+
|
|
609
|
+
Don't use it when:
|
|
610
|
+
|
|
611
|
+
- You always know the data will be a ghost (use `ReactGhost<T>` directly)
|
|
612
|
+
- You always know the data will be resolved (use the plain type `T`)
|
|
613
|
+
- Simple components that don't need this flexibility
|
|
614
|
+
|
|
615
|
+
## π§ Cache Management
|
|
616
|
+
|
|
617
|
+
### Invalidate Queries
|
|
618
|
+
|
|
619
|
+
#### Using `useGhost` Hook
|
|
620
|
+
|
|
621
|
+
The `useGhost` hook returns an `invalidate` function for refreshing specific
|
|
622
|
+
ghost data:
|
|
623
|
+
|
|
624
|
+
```tsx
|
|
625
|
+
function BlogView() {
|
|
626
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
627
|
+
|
|
628
|
+
const { value: blogData, invalidate } = blogGhost.getDetailed().useGhost();
|
|
629
|
+
|
|
630
|
+
const handleRefresh = () => {
|
|
631
|
+
invalidate(); // Refreshes only this specific ghost chain
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<div>
|
|
636
|
+
<h1>{blogData.title}</h1>
|
|
637
|
+
<button onClick={handleRefresh}>Refresh Blog</button>
|
|
638
|
+
</div>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
#### Using Ghost's `invalidate` Method
|
|
644
|
+
|
|
645
|
+
Each ghost has an `invalidate` method that requires a QueryClient:
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
649
|
+
|
|
650
|
+
function UpdateBlogButton() {
|
|
651
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
652
|
+
const queryClient = useQueryClient();
|
|
653
|
+
|
|
654
|
+
const handleUpdate = async () => {
|
|
655
|
+
const blogData = await blogGhost.getDetailed();
|
|
656
|
+
await updateBlog(blogData.id, { title: "Updated Title" });
|
|
657
|
+
|
|
658
|
+
// Invalidate this specific ghost
|
|
659
|
+
blogGhost.invalidate(queryClient);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
return <button onClick={handleUpdate}>Update Blog</button>;
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
#### Global Invalidation by ID
|
|
667
|
+
|
|
668
|
+
Use `invalidateGhostsById` for global cache invalidation:
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
import { invalidateGhostsById } from "@mittwald/react-ghostmaker";
|
|
672
|
+
|
|
673
|
+
async function deleteBlog(blogId: string) {
|
|
674
|
+
await fetch(`/api/blogs/${blogId}`, { method: "DELETE" });
|
|
675
|
+
|
|
676
|
+
// Invalidate all cached data for this blog
|
|
677
|
+
invalidateGhostsById(`blog-${blogId}`);
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
## π Complete Example
|
|
682
|
+
|
|
683
|
+
Here's a comprehensive example showing a blog application:
|
|
684
|
+
|
|
685
|
+
```tsx
|
|
686
|
+
// models/Blog.ts
|
|
687
|
+
export class Blog {
|
|
688
|
+
constructor(public id: string) {}
|
|
689
|
+
|
|
690
|
+
static ofId(id: string): Blog {
|
|
691
|
+
return new Blog(id);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async getDetailed() {
|
|
695
|
+
const response = await fetch(`/api/blogs/${this.id}`);
|
|
696
|
+
const data = await response.json();
|
|
697
|
+
return new DetailedBlog(data);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export class DetailedBlog extends Blog {
|
|
702
|
+
constructor(data: { id: string; title: string; author: string }) {
|
|
703
|
+
super(data.id);
|
|
704
|
+
this.title = data.title;
|
|
705
|
+
this.author = data.author;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
public readonly title: string;
|
|
709
|
+
public readonly author: string;
|
|
710
|
+
|
|
711
|
+
async getAuthor() {
|
|
712
|
+
const response = await fetch(`/api/authors/${this.author}`);
|
|
713
|
+
return response.json();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// models/react/BlogGhost.ts
|
|
718
|
+
import { makeGhost, type ReactGhost } from "@mittwald/react-ghostmaker";
|
|
719
|
+
import { Blog } from "../Blog";
|
|
720
|
+
|
|
721
|
+
export const BlogGhost = makeGhost(Blog);
|
|
722
|
+
export type BlogGhostType = ReactGhost<Blog>;
|
|
723
|
+
|
|
724
|
+
// models/react/init.ts
|
|
725
|
+
import { registerModelIdentifier } from "@mittwald/react-ghostmaker";
|
|
726
|
+
import { Blog, DetailedBlog } from "../Blog";
|
|
727
|
+
|
|
728
|
+
registerModelIdentifier((model) => {
|
|
729
|
+
if (model instanceof Blog) return `blog-${model.id}`;
|
|
730
|
+
if (model instanceof DetailedBlog) return `detailed-blog-${model.id}`;
|
|
731
|
+
return undefined;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// components/BlogPage.tsx
|
|
735
|
+
import { Suspense } from "react";
|
|
736
|
+
import { type ReactGhost } from "@mittwald/react-ghostmaker";
|
|
737
|
+
import { Blog } from "../models/Blog";
|
|
738
|
+
|
|
739
|
+
function BlogPage() {
|
|
740
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
741
|
+
|
|
742
|
+
return (
|
|
743
|
+
<article>
|
|
744
|
+
<Suspense fallback={<div>Loading title...</div>}>
|
|
745
|
+
<BlogTitle ghost={blogGhost} />
|
|
746
|
+
</Suspense>
|
|
747
|
+
|
|
748
|
+
<Suspense fallback={<div>Loading author...</div>}>
|
|
749
|
+
<BlogAuthor ghost={blogGhost} />
|
|
750
|
+
</Suspense>
|
|
751
|
+
</article>
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function BlogTitle(props: { ghost: ReactGhost<Blog> }) {
|
|
756
|
+
const { value: title, invalidate } = props.ghost
|
|
757
|
+
.getDetailed()
|
|
758
|
+
.title.transform((t) => t.charAt(0).toUpperCase() + t.slice(1))
|
|
759
|
+
.useGhost();
|
|
760
|
+
|
|
761
|
+
return (
|
|
762
|
+
<div>
|
|
763
|
+
<h1>{title}</h1>
|
|
764
|
+
<button onClick={invalidate}>Refresh Title</button>
|
|
765
|
+
</div>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function BlogAuthor(props: { ghost: ReactGhost<Blog> }) {
|
|
770
|
+
const author = props.ghost.getDetailed().getAuthor().use();
|
|
771
|
+
|
|
772
|
+
return (
|
|
773
|
+
<div>
|
|
774
|
+
<h3>By: {author.name}</h3>
|
|
775
|
+
<p>{author.bio}</p>
|
|
776
|
+
</div>
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// App.tsx
|
|
781
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
782
|
+
import { ErrorBoundary } from "react-error-boundary";
|
|
783
|
+
import BlogPage from "./components/BlogPage";
|
|
784
|
+
import { BlogGhost } from "./models/react/BlogGhost";
|
|
785
|
+
import "./models/react/init"; // Initialize model identifiers
|
|
786
|
+
|
|
787
|
+
const queryClient = new QueryClient({
|
|
788
|
+
defaultOptions: {
|
|
789
|
+
queries: {
|
|
790
|
+
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
791
|
+
retry: 2,
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
function ErrorFallback({ error, resetErrorBoundary }: any) {
|
|
797
|
+
return (
|
|
798
|
+
<div>
|
|
799
|
+
<h2>Oops! Something went wrong</h2>
|
|
800
|
+
<p>{error.message}</p>
|
|
801
|
+
<button onClick={resetErrorBoundary}>Try Again</button>
|
|
802
|
+
</div>
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export default function App() {
|
|
807
|
+
const blogGhost = BlogGhost.ofId("123");
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
<QueryClientProvider client={queryClient}>
|
|
811
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
812
|
+
<BlogPage />
|
|
813
|
+
</ErrorBoundary>
|
|
814
|
+
</QueryClientProvider>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
## π API Reference
|
|
820
|
+
|
|
821
|
+
### `makeGhost<T>(model: T): ReactGhost<T>`
|
|
822
|
+
|
|
823
|
+
Creates a ghost proxy from any object or class instance.
|
|
824
|
+
|
|
825
|
+
**Key Benefits:**
|
|
826
|
+
|
|
827
|
+
- π **Automatic Query Keys**: Generated based on method chains and model
|
|
828
|
+
identifiers
|
|
829
|
+
- π **Smart Caching**: Each method call in a chain creates a unique,
|
|
830
|
+
deterministic cache key
|
|
831
|
+
- π― **Type Safety**: Preserves all original method signatures and return types
|
|
832
|
+
|
|
833
|
+
### Ghost Methods
|
|
834
|
+
|
|
835
|
+
- **`.use(options?)`** - Suspends until data is available, returns the resolved
|
|
836
|
+
value
|
|
837
|
+
- **`.useGhost(options?)`** - Returns `{ value, invalidate }` with full query
|
|
838
|
+
control
|
|
839
|
+
- **`.render(transform?)`** - Renders the value directly as a React element
|
|
840
|
+
- **`.transform(fn, deps?)`** - Transforms the resolved value
|
|
841
|
+
- **`.invalidate(queryClient)`** - Invalidate this specific ghost's cached data
|
|
842
|
+
- **`await ghost`** - Directly await the ghost to get the resolved value
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
### Utility Functions
|
|
846
|
+
|
|
847
|
+
- **`asGhostProps(props)`** - Converts props to ghost-compatible format
|
|
848
|
+
- **`GhostMakerModel(options)`** - Decorator to define model name and ID for automatic query key generation
|
|
849
|
+
- **`invalidateGhostsById(id)`** - Globally invalidate cached data by ID
|
|
850
|
+
- **`getGhostId(ghost)`** - Get the unique ID of a ghost
|
|
851
|
+
|
|
852
|
+
### Types
|
|
853
|
+
|
|
854
|
+
- **`MaybeReactGhost<T>`** - Type for props that accept both ghosts and regular objects
|
|
855
|
+
- **`ReactGhost<T>`** - Type for ghost proxy objects
|
|
856
|
+
- **`UseGhostReturn<T>`** - Return type of `useGhost()` with `{ value, invalidate }`
|
|
857
|
+
|
|
858
|
+
## π€ Requirements
|
|
859
|
+
|
|
860
|
+
- React >=19.2
|
|
861
|
+
- TanStack Query ^5
|
|
862
|
+
- TypeScript (recommended)
|
|
863
|
+
|
|
864
|
+
## π License
|
|
865
|
+
|
|
866
|
+
MIT Β© [Mittwald CM Service GmbH & Co. KG](https://github.com/mittwald)
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
**Ready to make your async operations disappear like ghosts? π»** Transform your
|
|
871
|
+
React app with suspense-ready domain models today!
|