@mittwald/react-ghostmaker 1.0.1 → 1.0.3

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