Skip to content

Commit cf10a67

Browse files
committed
Add React Query integration with notes API and dashboard example
1 parent 770a3a0 commit cf10a67

File tree

8 files changed

+307
-4
lines changed

8 files changed

+307
-4
lines changed

examples/with-nextjs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"dev": "next dev"
66
},
77
"dependencies": {
8+
"@tanstack/react-query": "^5.59.20",
89
"@yme/api": "workspace:*",
910
"next": "14",
1011
"react": "^18.3.1",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use client';
2+
3+
import { QueryClientProvider } from '@tanstack/react-query';
4+
import { getQueryClient } from '../lib/queryClient';
5+
6+
export default function ReactQueryClientProvider({
7+
children,
8+
}: { children: React.ReactNode }) {
9+
const client = getQueryClient();
10+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
11+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextResponse, type NextRequest } from 'next/server';
2+
3+
const notes = [{ id: 1, title: 'Note 1', content: 'Content 1' }];
4+
5+
export const GET = async (req: NextRequest) => {
6+
await new Promise((resolve) => setTimeout(resolve, 1000));
7+
return NextResponse.json({
8+
records: notes,
9+
});
10+
};
11+
12+
export const POST = async (req: NextRequest) => {
13+
const note = await req.json();
14+
const id = notes.length + 1;
15+
notes.push({ ...note, id });
16+
return NextResponse.json({
17+
id,
18+
});
19+
};
20+
21+
export const DELETE = async (req: NextRequest) => {
22+
const { id } = await req.json();
23+
const index = notes.findIndex((note) => note.id === id);
24+
if (index !== -1) {
25+
notes.splice(index, 1);
26+
}
27+
return new Response(null, { status: 204 });
28+
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use client';
2+
3+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4+
import { notesApi } from '../../lib/apiClient';
5+
6+
export default function Page() {
7+
const qc = useQueryClient();
8+
const { data, isLoading, isFetching } = useQuery({
9+
queryKey: [{ name: 'notes', entity: 'list' }],
10+
queryFn: async ({ signal }) => await notesApi.list({}, { signal }),
11+
});
12+
const $create = useMutation({
13+
mutationFn: notesApi.create,
14+
});
15+
16+
// optimistic update
17+
const $delete = useMutation({
18+
mutationFn: notesApi.delete,
19+
onMutate: async (input) => {
20+
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
21+
await qc.cancelQueries({ queryKey: [{ name: 'notes' }] });
22+
const prevNotes = qc.getQueryData([{ name: 'notes', entity: 'list' }]);
23+
24+
// Optimistically update to the new value
25+
qc.setQueryData([{ name: 'notes', entity: 'list' }], (old: any) => ({
26+
...old,
27+
records: old.records.filter((note) => note.id !== input.id),
28+
}));
29+
return { prevNotes };
30+
},
31+
onError: (err, variables, context) => {
32+
// If the mutation fails, use the context to roll back
33+
qc.setQueryData([{ name: 'notes', entity: 'list' }], context.prevNotes);
34+
},
35+
onSettled: async () => {
36+
await qc.invalidateQueries({ queryKey: [{ name: 'notes' }] });
37+
},
38+
});
39+
40+
return (
41+
<div>
42+
<h1>Dashboard</h1>
43+
<p>Welcome to the dashboard!</p>
44+
<div>
45+
<form
46+
onSubmit={(e) => {
47+
e.preventDefault();
48+
49+
const form = e.currentTarget;
50+
const formData = new FormData(form);
51+
const title = formData.get('title') as string;
52+
const content = formData.get('content') as string;
53+
$create.mutate(
54+
{ title, content },
55+
{
56+
onSuccess: async () => {
57+
form.reset();
58+
await qc.invalidateQueries({
59+
queryKey: [{ name: 'notes' }],
60+
});
61+
},
62+
onError: () => {},
63+
},
64+
);
65+
}}
66+
>
67+
<div
68+
style={{
69+
display: 'inline-flex',
70+
flexDirection: 'column',
71+
gap: 4,
72+
}}
73+
>
74+
<label style={{ display: 'inline-flex', flexDirection: 'column' }}>
75+
Title
76+
<input required type="text" name="title" />
77+
</label>
78+
<label style={{ display: 'inline-flex', flexDirection: 'column' }}>
79+
Content
80+
<textarea required name="content" />
81+
</label>
82+
<button type="submit">Create Note</button>
83+
</div>
84+
</form>
85+
</div>
86+
<div>
87+
<h2>Notes</h2>
88+
{isLoading || isFetching ? <p>Freshing...</p> : null}
89+
<ul
90+
style={{
91+
padding: 0,
92+
margin: 0,
93+
}}
94+
>
95+
{data?.records?.map((note) => (
96+
<li
97+
key={note.id}
98+
style={{
99+
listStyle: 'none',
100+
}}
101+
>
102+
<div
103+
style={{
104+
display: 'flex',
105+
gap: 12,
106+
alignItems: 'center',
107+
}}
108+
>
109+
<div>{note.id}</div>
110+
<div>
111+
<h3>{note.title}</h3>
112+
<p>{note.content}</p>
113+
</div>
114+
<div>
115+
<button
116+
type="button"
117+
onClick={(e) => {
118+
$delete.mutate({
119+
id: note.id,
120+
});
121+
}}
122+
>
123+
delete
124+
</button>
125+
</div>
126+
</div>
127+
</li>
128+
))}
129+
</ul>
130+
</div>
131+
</div>
132+
);
133+
}
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import QueryClientProvider from './QueryClientProvider';
2+
13
export default function RootLayout({
24
children,
35
}: { children: React.ReactNode }) {
46
return (
5-
<html lang='en'>
7+
<html lang="en" suppressHydrationWarning>
68
<head>
79
<title>My App</title>
810
</head>
911
<body>
10-
{children}
12+
<QueryClientProvider>{children}</QueryClientProvider>
1113
</body>
1214
</html>
13-
)
14-
};
15+
);
16+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use client';
2+
3+
import type { HttpApiRequest } from '@yme/api';
4+
import { ApiClient } from '@yme/api/client';
5+
import { replacePathParams } from '@yme/api/client/middleware';
6+
import { logger } from '@yme/api/middleware';
7+
import { z } from 'zod';
8+
9+
export const api = new ApiClient<
10+
HttpApiRequest & {
11+
signal?: AbortSignal;
12+
headers?: Record<string, string>;
13+
},
14+
{}
15+
>({
16+
action: async ({ req }) => {
17+
const url = new URL(`/api/${req.url}`, window.location.origin);
18+
let body: any = null;
19+
20+
if (req.method === 'GET') {
21+
if (req.parsedInput) {
22+
for (const [key, value] of Object.entries(req.parsedInput)) {
23+
url.searchParams.append(key, String(value));
24+
}
25+
}
26+
} else {
27+
body = JSON.stringify(req.parsedInput);
28+
}
29+
30+
const res = await fetch(url, {
31+
method: req.method,
32+
signal: req.signal,
33+
headers: req.headers,
34+
body,
35+
});
36+
37+
if (!res.ok) {
38+
throw new Error(res.statusText);
39+
}
40+
41+
return await res.json();
42+
},
43+
middlewares: [logger(), replacePathParams()],
44+
});
45+
46+
export type NoteType = {
47+
id: number;
48+
title: string;
49+
content: string;
50+
};
51+
52+
//
53+
export const notesApi = {
54+
list: api
55+
.get('notes')
56+
.validator(
57+
z.object({
58+
page: z.number().default(1),
59+
pageSize: z.number().default(10),
60+
}),
61+
)
62+
.T<{ page: number; pageSize: number; records: NoteType[] }>(),
63+
create: api
64+
.post('notes')
65+
.validator(
66+
z.object({
67+
title: z.string().min(1).max(255),
68+
content: z.string().min(1).max(1024),
69+
}),
70+
)
71+
.T<NoteType>(),
72+
delete: api
73+
.delete('notes')
74+
.validator(
75+
z.object({
76+
id: z.number().int(),
77+
}),
78+
)
79+
.T<void>(),
80+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
2+
import { QueryClient, isServer } from '@tanstack/react-query';
3+
4+
function createQueryClient() {
5+
return new QueryClient({
6+
defaultOptions: {
7+
queries: {
8+
staleTime: 60 * 1000,
9+
refetchOnWindowFocus: false,
10+
retry: () => {
11+
// todo retry
12+
return false;
13+
}
14+
},
15+
},
16+
});
17+
}
18+
19+
let queryClient: QueryClient | null = null;
20+
21+
export function getQueryClient() {
22+
if (isServer) {
23+
return createQueryClient();
24+
}
25+
26+
if (!queryClient) {
27+
queryClient = createQueryClient();
28+
}
29+
return queryClient;
30+
}

pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)