@mittwald/react-ghostmaker 1.4.2 → 1.5.0-alpha.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.
@@ -181,7 +181,7 @@ describe("Hooks", () => {
181
181
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
182
182
  expect(customerMocks.getName).toHaveBeenCalledTimes(1);
183
183
  result.current?.invalidate();
184
- await vitest.runOnlyPendingTimersAsync();
184
+ await vitest.advanceTimersByTimeAsync(100000);
185
185
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
186
186
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
187
187
  expect(customerMocks.getName).toHaveBeenCalledTimes(2);
@@ -196,7 +196,7 @@ describe("Hooks", () => {
196
196
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
197
197
  expect(customerMocks.getName).toHaveBeenCalledTimes(1);
198
198
  ghost.invalidate(queryClient);
199
- await vitest.runOnlyPendingTimersAsync();
199
+ await vitest.advanceTimersByTimeAsync(10000);
200
200
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
201
201
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
202
202
  expect(customerMocks.getName).toHaveBeenCalledTimes(2);
@@ -211,7 +211,7 @@ describe("Hooks", () => {
211
211
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
212
212
  expect(customerMocks.getName).toHaveBeenCalledTimes(1);
213
213
  invalidateGhosts(queryClient, getCustomerNameGhostIds.current.queryKey);
214
- await vitest.runOnlyPendingTimersAsync();
214
+ await vitest.advanceTimersByTimeAsync(10000);
215
215
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
216
216
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
217
217
  expect(customerMocks.getName).toHaveBeenCalledTimes(2);
@@ -223,7 +223,7 @@ describe("Hooks", () => {
223
223
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
224
224
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
225
225
  ghost.invalidate(queryClient);
226
- await vitest.runOnlyPendingTimersAsync();
226
+ await vitest.advanceTimersByTimeAsync(10000);
227
227
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
228
228
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
229
229
  });
@@ -234,7 +234,7 @@ describe("Hooks", () => {
234
234
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
235
235
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
236
236
  specialGhost.invalidate(queryClient);
237
- await vitest.runOnlyPendingTimersAsync();
237
+ await vitest.advanceTimersByTimeAsync(10000);
238
238
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
239
239
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
240
240
  });
@@ -246,7 +246,7 @@ describe("Hooks", () => {
246
246
  .fn()
247
247
  .mockImplementation((id) => new ProjectDetailed(id, `CHANGED! Project ${id}`, "C1"));
248
248
  projectGhost.invalidate(queryClient);
249
- await vitest.runOnlyPendingTimersAsync();
249
+ await vitest.advanceTimersByTimeAsync(10000);
250
250
  expect(projectMocks.getName).toHaveBeenCalledTimes(2);
251
251
  });
252
252
  });
@@ -1,5 +1,6 @@
1
1
  import is, { assert } from "@sindresorhus/is";
2
2
  import { isReactGhost, transformFnProp, } from "./types";
3
+ import { getQueryContext, ghostFnContext } from "./context";
3
4
  const resolveGhostChainItem = async (target, item) => {
4
5
  const { propName, args } = item;
5
6
  if (propName === transformFnProp) {
@@ -21,13 +22,19 @@ const resolveGhostChainItem = async (target, item) => {
21
22
  return property;
22
23
  }
23
24
  assert.function(property);
24
- const asyncFn = property.bind(target);
25
- return await asyncFn(...args);
25
+ return property.call(target, ...args);
26
26
  };
27
27
  export const resolveGhostChain = async (target, stack) => {
28
28
  let result = target;
29
+ const queryContext = getQueryContext();
29
30
  for (const item of stack) {
30
- result = await resolveGhostChainItem(result, item);
31
+ if (queryContext) {
32
+ const resolveGhostChainItemWithContext = ghostFnContext.bind({ query: queryContext }, resolveGhostChainItem);
33
+ result = await resolveGhostChainItemWithContext(result, item);
34
+ }
35
+ else {
36
+ result = await resolveGhostChainItem(result, item);
37
+ }
31
38
  }
32
39
  return result;
33
40
  };
@@ -1,67 +1,39 @@
1
- import is, { assert } from "@sindresorhus/is";
2
- import { useEffect, useEffectEvent, useMemo, useRef } from "react";
3
- import { transformFnProp, } from "./types";
1
+ import is from "@sindresorhus/is";
2
+ import { useEffect, useEffectEvent, useRef } from "react";
4
3
  import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
5
4
  import { queries } from "./queries";
6
5
  import { ghostFnContext } from "./context";
7
- import { invalidateGhosts } from "./invalidate";
8
6
  import { hashObject } from "./hash";
7
+ import { resolveGhostChain } from "./resolveGhostChain";
9
8
  export const useGhostChain = (target, chain, options) => {
10
9
  const queryClient = useQueryClient();
11
- const context = {
12
- queryKey: queries.target(target),
13
- };
14
- const value = chain.reduce((currentTarget, item) => useGhostChainItem(currentTarget, item, options, context), target);
15
- const invalidate = () => invalidateGhosts(queryClient, queries.ghostChain(target, chain));
16
- return {
17
- value,
18
- invalidate,
19
- };
20
- };
21
- const useGhostChainItem = (target, item, options = {}, context) => {
22
- const { propName, args } = item;
23
- if (propName === transformFnProp) {
24
- assert.array(args);
25
- const transformFn = args[0];
26
- assert.function(transformFn, "transform requires a mapping function");
27
- const transformDependencies = args[1] ?? [];
28
- assert.array(transformDependencies);
29
- return useMemo(() => transformFn(target), [target, ...transformDependencies]);
30
- }
31
- if (is.nullOrUndefined(target)) {
32
- return target;
33
- }
34
- assert.object(target);
35
- const targetPropertyName = propName;
36
- const targetProperty = target[targetPropertyName];
37
- context.queryKey = queries.chainItem(context.queryKey, item);
38
- if (args === undefined) {
39
- // no function call, just a property access
40
- return targetProperty;
41
- }
42
- assert.function(targetProperty);
43
- const targetFn = targetProperty.bind(target);
44
- const queryKey = [...context.queryKey];
45
- const query = useSuspenseQuery({
10
+ const queryKey = queries.ghostChain(target, chain);
11
+ const fn = () => resolveGhostChain(target, chain);
12
+ const queryResult = useSuspenseQuery({
46
13
  ...options,
47
- queryKey,
48
14
  queryFn: async (ctx) => {
49
- const targetFnWithContext = ghostFnContext.bind({ query: ctx }, targetFn);
50
- return { result: await targetFnWithContext(...args) };
15
+ const targetFnWithContext = ghostFnContext.bind({ query: ctx }, fn);
16
+ return { result: await targetFnWithContext() };
51
17
  },
18
+ queryKey,
52
19
  });
53
- useTargetAutoInvalidate(target, queryKey);
54
- if (query.error) {
55
- throw query.error;
20
+ if (queryResult.error) {
21
+ throw queryResult.error;
56
22
  }
57
- return query.data.result;
23
+ useTargetAutoInvalidate(target, queryKey);
24
+ return {
25
+ value: queryResult.data.result,
26
+ invalidate: () => queryClient.invalidateQueries({
27
+ queryKey,
28
+ }),
29
+ };
58
30
  };
59
31
  const useTargetAutoInvalidate = (target, queryKey) => {
60
32
  const queryClient = useQueryClient();
61
33
  const invalidate = () => queryClient.invalidateQueries({
62
34
  queryKey,
63
35
  });
64
- const targetHash = hashObject(target);
36
+ const targetHash = is.object(target) ? hashObject(target) : target;
65
37
  const prevTargetHash = useRef(targetHash);
66
38
  const joinedQueryKey = queryKey.join(".");
67
39
  const prevQueryId = useRef(joinedQueryKey);
@@ -4,6 +4,6 @@ export declare const getModelId: (something: unknown) => string | undefined;
4
4
  export declare const queries: {
5
5
  ghostmaker: () => string[];
6
6
  target: (target: unknown) => string[];
7
- chainItem: (prev: QueryKey, chainItem: GhostChainItem) => unknown[];
8
- ghostChain: (target: unknown, chain: GhostChain) => unknown[];
7
+ chainItem: (prev: QueryKey, chainItem: GhostChainItem) => QueryKey;
8
+ ghostChain: (target: unknown, chain: GhostChain) => QueryKey;
9
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/react-ghostmaker",
3
- "version": "1.4.2",
3
+ "version": "1.5.0-alpha.1",
4
4
  "author": "Mittwald CM Service GmbH & Co. KG <opensource@mittwald.de>",
5
5
  "contributors": [
6
6
  "Marco Falkenberg <m.falkenberg@mittwald.de>"
package/README.md DELETED
@@ -1,871 +0,0 @@
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!