การใช้ร่วมกับ React Query
ปัญหาเรื่อง "จะวาง Keys ไว้ที่ไหน"
วิธีแก้ — แบ่งตาม Entities
ถ้าโปรเจกต์มีการแบ่งเป็น Entities อยู่แล้ว และแต่ละ Request ตรงกับ Entity เดียว การแบ่งที่สะอาดที่สุดคือแบ่งตาม Entity ในกรณีนี้ เราแนะนำให้ใช้โครงสร้างดังนี้:
└── src/ #
├── app/ #
| ... #
├── pages/ #
| ... #
├── entities/ #
| ├── {entity}/ #
| ... └── api/ #
| ├── `{entity}.query` # Query-factory ที่เก็บ Keys และ Functions
| ├── `get-{entity}` # Entity getter function
| ├── `create-{entity}` # Entity creation function
| ├── `update-{entity}` # Entity update function
| ├── `delete-{entity}` # Entity delete function
| ... #
| #
├── features/ #
| ... #
├── widgets/ #
| ... #
└── shared/ #
... #
ถ้ามีการเชื่อมต่อกันระหว่าง Entities (เช่น Entity Country มี field-list ของ City entities),
คุณสามารถใช้ Public API for cross-imports หรือพิจารณาทางเลือกอื่นข้างล่าง
ทางเลือกอื่น — เก็บไว้ใน Shared
ในกรณีที่การแยก Entity ไม่เหมาะสม สามารถพิจารณาโครงสร้างต่อไปนี้:
└── src/ #
... #
└── shared/ #
├── api/ #
... ├── `queries` # Query-factories
| ├── `document.ts` #
| ├── `background-jobs.ts` #
| ... #
└── index.ts #
จากนั้นใน @/shared/api/index.ts:
export { documentQueries } from "./queries/document";
ปัญหาเรื่อง "จะแทรก Mutations ตรงไหน?"
ไม่แนะนำให้ผสม Mutations กับ Queries มีสองทางเลือก:
1. Define custom hook ใน api segment ใกล้ๆ กับที่ใช้งาน
export const useUpdateTitle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, newTitle }) =>
apiClient
.patch(`/posts/${id}`, { title: newTitle })
.then((data) => console.log(data)),
onSuccess: (newPost) => {
queryClient.setQueryData(postsQueries.ids(id), newPost);
},
});
};
2. Define mutation function ที่อื่น (Shared หรือ Entities) และใช้ useMutation โดยตรงใน Component
const { mutateAsync, isPending } = useMutation({
mutationFn: postApi.createPost,
});
export const CreatePost = () => {
const { classes } = useStyles();
const [title, setTitle] = useState("");
const { mutate, isPending } = useMutation({
mutationFn: postApi.createPost,
});
const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
mutate({ title, userId: DEFAULT_USER_ID });
};
return (
<form className={classes.create_form} onSubmit={handleSubmit}>
<TextField onChange={handleChange} value={title} />
<LoadingButton type="submit" variant="contained" loading={isPending}>
Create
</LoadingButton>
</form>
);
};
การจัดการ Requests
Query Factory
Query factory คือ Object ที่ค่าของ Key คือฟังก์ชันที่ Return รายการของ Query keys นี่คือวิธีใช้:
const keyFactory = {
all: () => ["entity"],
lists: () => [...postQueries.all(), "list"],
};
queryOptions เป็น Built-in utility ใน react-query@v5 (Optional)
queryOptions({
queryKey,
...options,
});
เพื่อความ Type safety ที่มากขึ้น, การเข้ากันได้กับ React-query เวอร์ชันในอนาคต, และการเข้าถึง Functions และ Query keys ที่ง่ายขึ้น
คุณสามารถใช้ฟังก์ชัน queryOptions ที่มากับ “@tanstack/react-query” ได้
(รายละเอียดเพิ่มเติมที่นี่)
1. การสร้าง Query Factory
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";
import { getDetailPost } from "./get-detail-post";
import { PostDetailQuery } from "./query/post.query";
export const postQueries = {
all: () => ["posts"],
lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),
details: () => [...postQueries.all(), "detail"],
detail: (query?: PostDetailQuery) =>
queryOptions({
queryKey: [...postQueries.details(), query?.id],
queryFn: () => getDetailPost({ id: query?.id }),
staleTime: 5000,
}),
};
2. การใช้ Query Factory ใน Application Code
import { useParams } from "react-router-dom";
import { postApi } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";
type Params = {
postId: string;
};
export const PostPage = () => {
const { postId } = useParams<Params>();
const id = parseInt(postId || "");
const {
data: post,
error,
isLoading,
isError,
} = useQuery(postApi.postQueries.detail({ id }));
if (isLoading) {
return <div>Loading...</div>;
}
if (isError || !post) {
return <>{error?.message}</>;
}
return (
<div>
<p>Post id: {post.id}</p>
<div>
<h1>{post.title}</h1>
<div>
<p>{post.body}</p>
</div>
</div>
<div>Owner: {post.userId}</div>
</div>
);
};
ประโยชน์ของการใช้ Query Factory
- จัดระเบียบ Request: Factory ช่วยให้คุณจัดการ API requests ทั้งหมดไว้ที่เดียว ทำให้โค้ดอ่านง่ายและดูแลรักษาง่าย
- เข้าถึง Queries และ Keys ได้สะดวก: Factory มี Methods ที่สะดวกสำหรับเข้าถึง Queries ประเภทต่างๆ และ Keys ของมัน
- ความสามารถในการ Refetch Query: Factory ช่วยให้ Refetch ได้ง่ายโดยไม่ต้องเปลี่ยน Query keys ในส่วนต่างๆ ของ Application
Pagination
ในส่วนนี้ เราจะดูตัวอย่างฟังก์ชัน getPosts ซึ่งทำการ API Request เพื่อดึง Post entities โดยใช้ Pagination
1. สร้างฟังก์ชัน getPosts
ฟังก์ชัน getPosts อยู่ในไฟล์ get-posts.ts ซึ่งอยู่ใน api segment
import { apiClient } from "@/shared/api/base";
import { PostWithPaginationDto } from "./dto/post-with-pagination.dto";
import { PostQuery } from "./query/post.query";
import { mapPost } from "./mapper/map-post";
import { PostWithPagination } from "../model/post-with-pagination";
const calculatePostPage = (totalCount: number, limit: number) =>
Math.floor(totalCount / limit);
export const getPosts = async (
page: number,
limit: number,
): Promise<PostWithPagination> => {
const skip = page * limit;
const query: PostQuery = { skip, limit };
const result = await apiClient.get<PostWithPaginationDto>("/posts", query);
return {
posts: result.posts.map((post) => mapPost(post)),
limit: result.limit,
skip: result.skip,
total: result.total,
totalPages: calculatePostPage(result.total, limit),
};
};
2. Query factory สำหรับ Pagination
postQueries query factory กำหนด Query options ต่างๆ สำหรับทำงานกับ Posts
รวมถึงการ Request รายการ Posts แบบระบุ Page และ Limit
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";
export const postQueries = {
all: () => ["posts"],
lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),
};
3. ใช้ใน Application Code
export const HomePage = () => {
const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN;
const [page, setPage] = usePageParam(DEFAULT_PAGE);
const { data, isFetching, isLoading } = useQuery(
postApi.postQueries.list(page, itemsOnScreen),
);
return (
<>
<Pagination
onChange={(_, page) => setPage(page)}
page={page}
count={data?.totalPages}
variant="outlined"
color="primary"
/>
<Posts posts={data?.posts} />
</>
);
};
ตัวอย่างถูกทำให้ง่ายขึ้น เวอร์ชันเต็มดูได้ที่ GitHub
QueryProvider สำหรับจัดการ Queries
ในไกด์นี้ เราจะดูวิธีการจัดระเบียบ QueryProvider
1. สร้าง QueryProvider
ไฟล์ query-provider.tsx อยู่ที่ path @/app/providers/query-provider.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactNode } from "react";
type Props = {
children: ReactNode;
client: QueryClient;
};
export const QueryProvider = ({ client, children }: Props) => {
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
};
2. สร้าง QueryClient
QueryClient คือ Instance ที่ใช้จัดการ API requests
ไฟล์ query-client.ts อยู่ที่ @/shared/api/query-client.ts
QueryClient ถูกสร้างด้วย Settings บางอย่างสำหรับ Query caching
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});
Code generation
มีเครื่องมือที่สามารถ Generate API code ให้คุณได้ แต่มันยืดหยุ่นน้อยกว่าวิธี Manual ที่อธิบายข้างต้น
ถ้า Swagger file ของคุณมีโครงสร้างที่ดี
และคุณใช้หนึ่งในเครื่องมือเหล่านี้ มันอาจสมเหตุสมผลที่จะ Generate โค้ดทั้งหมดในไดเรกทอรี @/shared/api
คำแนะนำเพิ่มเติมสำหรับจัดระเบียบ RQ
API Client
การใช้ Custom API client class ใน Shared layer, คุณสามารถทำให้ Configuration และการทำงานกับ API ในโปรเจกต์เป็นมาตรฐานเดียวกันได้ สิ่งนี้ช่วนให้คุณจัดการ Logging, Headers และ Data exchange format (เช่น JSON หรือ XML) ได้จากที่เดียว วิธีนี้ทำให้ดูแลรักษาและพัฒนาโปรเจกต์ง่ายขึ้นเพราะมันทำให้การเปลี่ยนและอัปเดตการปฏิสัมพันธ์กับ API ง่ายขึ้น
import { API_URL } from "@/shared/config";
export class ApiClient {
private baseUrl: string;
constructor(url: string) {
this.baseUrl = url;
}
async handleResponse<TResult>(response: Response): Promise<TResult> {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
try {
return await response.json();
} catch (error) {
throw new Error("Error parsing JSON response");
}
}
public async get<TResult = unknown>(
endpoint: string,
queryParams?: Record<string, string | number>,
): Promise<TResult> {
const url = new URL(endpoint, this.baseUrl);
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value.toString());
});
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
return this.handleResponse<TResult>(response);
}
public async post<TResult = unknown, TData = Record<string, unknown>>(
endpoint: string,
body: TData,
): Promise<TResult> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return this.handleResponse<TResult>(response);
}
}
export const apiClient = new ApiClient(API_URL);