E-Crystal Hooks
queryHooks Guide
Complete guide for data fetching, mutations, pagination, and safe updates using React Query.
Overview
All hooks are exported from a single queryHooks object.
Import
import { queryHooks } from "@/hooks";
// Destructure what you need
const { useFetchData, useApiMutation, useInfiniteFetchData, useSafeUpdate } = queryHooks;useFetchData
Primary hook for fetching data. Supports GET and POST methods with automatic caching.
| Prop | Type | Default | Description |
|---|---|---|---|
| path | string | β | API endpoint path (e.g., 'courses') |
| queryKey | string | unknown[] | β | Unique cache key for this query |
| method | "GET" | "POST" | "GET" | HTTP method |
| filterData | Record<string, any> | {} | Query parameters or POST body |
| token | string | β | Explicit auth token (auto-detected if not provided) |
| withOutToken | boolean | false | Skip authentication |
| enabled | boolean | true | Enable/disable the query |
Basic Usage
const { data, isLoading, error } = queryHooks.useFetchData({
path: "courses",
queryKey: "courses-list",
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <CourseList courses={data.data} />;With Filters
const { data } = queryHooks.useFetchData({
path: "courses",
queryKey: "courses-list",
method: "POST",
filterData: {
status: "active",
page: 1,
limit: 10,
},
});Without Auth Token
const { data } = queryHooks.useFetchData({
path: "public/courses",
queryKey: "public-courses",
withOutToken: true,
});Conditional Fetching
const [courseId, setCourseId] = useState<string | null>(null);
const { data } = queryHooks.useFetchData({
path: `courses/${courseId}`,
queryKey: `course-${courseId}`,
enabled: !!courseId, // Only fetch when courseId is set
});useApiMutation
Hook for write operations β create, update, delete. Supports JSON and FormData.
| Prop | Type | Default | Description |
|---|---|---|---|
| method | "POST" | "PUT" | "PATCH" | "DELETE" | β | HTTP method (required) |
| path | string | β | API endpoint path (or pass in mutate call) |
| token | string | β | Explicit auth token |
| safe | boolean | true | Remove empty fields from payload |
| dataType | "json" | "multipart/form-data" | "json" | Content type for request |
| responseType | "json" | "blob" | "arraybuffer" | "text" | "json" | Expected response format |
| onSuccess | (data: T) => void | β | Success callback |
| onError | (error: ApiMutationError) => void | β | Error callback |
| isSuccessToast | boolean | true | Show success toast |
| isErrorToast | boolean | true | Show error toast |
Create Item
const createCourse = queryHooks.useApiMutation({
method: "POST",
path: "courses",
onSuccess: (data) => {
console.log("Course created:", data);
queryClient.invalidateQueries({ queryKey: ["courses"] });
},
});
// Usage
createCourse.mutate({
title: "New Course",
description: "Course description",
price: 99.99,
});Update Item
const updateCourse = queryHooks.useApiMutation({
method: "PATCH",
// Path can be dynamic
});
// Pass path in mutate call
updateCourse.mutate({
path: "courses/123",
title: "Updated Title",
});Delete Item
const deleteCourse = queryHooks.useApiMutation({
method: "DELETE",
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["courses"] });
},
});
// Usage
deleteCourse.mutate({ path: "courses/123" });File Upload
const uploadFile = queryHooks.useApiMutation({
method: "POST",
path: "upload",
dataType: "multipart/form-data",
isSuccessToast: true,
});
// Usage with FormData
const formData = new FormData();
formData.append("file", file);
formData.append("courseId", "123");
uploadFile.mutate(formData);File Download (Blob)
const downloadFile = queryHooks.useApiMutation({
method: "POST",
path: "export/pdf",
responseType: "blob",
isSuccessToast: false,
});
// Usage
const result = await downloadFile.mutateAsync({ format: "pdf" });
// Create download link
const url = window.URL.createObjectURL(result as Blob);
const a = document.createElement("a");
a.href = url;
a.download = "export.pdf";
a.click();With Loading State
const mutation = queryHooks.useApiMutation({
method: "POST",
path: "courses",
});
return (
<form onSubmit={() => mutation.mutate(formData)}>
{/* form fields */}
<button disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Course"}
</button>
{mutation.isError && (
<p className="text-red-500">{mutation.error.message}</p>
)}
</form>
);useInfiniteFetchData
For paginated data with infinite scroll or 'Load More' functionality.
| Prop | Type | Default | Description |
|---|---|---|---|
| path | string | β | API endpoint path |
| queryKey | string | unknown[] | β | Cache key |
| method | "GET" | "POST" | "GET" | HTTP method |
| filterData | Record<string, any> | {} | Base filters (page added automatically) |
| enabled | boolean | true | Enable/disable the query |
| initialPageParam | number | 1 | Starting page number |
Basic Usage
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = queryHooks.useInfiniteFetchData({
path: "courses",
queryKey: "courses-infinite",
filterData: { status: "active" },
});Render List
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.data?.data?.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? "Loading more..."
: hasNextPage
? "Load More"
: "No more items"}
</button>
</div>
);Infinite Scroll
import { useIntersectionObserver } from "@/hooks";
function InfiniteScrollList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
queryHooks.useInfiniteFetchData({
path: "courses",
queryKey: "courses-infinite",
});
const loadMoreRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
});
return (
<div>
{data?.pages.flatMap((page, i) =>
page.data?.data?.map((item) => (
<CourseCard key={item.id} course={item} />
))
)}
<div ref={loadMoreRef} className="h-10" />
</div>
);
}With Custom Pagination
const { data, fetchNextPage, hasNextPage } =
queryHooks.useInfiniteFetchData({
path: "courses",
queryKey: "courses-custom",
getNextPageParam: (lastPage) => {
// Custom logic to determine next page
const pagination = lastPage?.data?.pagination;
if (!pagination) return undefined;
return pagination.page < pagination.totalPages
? pagination.page + 1
: undefined;
},
});useSafeUpdate
Prevents accidental overwrites by checking if the entity was modified recently.
| Prop | Type | Default | Description |
|---|---|---|---|
| fieldName | string | β | Entity field name in API response |
| fetchPath | string | β | Path to fetch current state |
| updatePath | string | β | Path to update entity |
| queryKey | string | β | Cache key to invalidate after update |
| timeLimitMinutes | number | 15 | Minutes before requiring confirmation |
| fetchMethod | "GET" | "POST" | "GET" | Method for fetching current state |
| fetchFilter | Record<string, any> | β | Filters for the fetch |
| onSuccess | () => void | β | Success callback |
Basic Usage
const {
safeUpdate,
confirmOverride,
needsOverride,
isUpdating,
updateData,
} = queryHooks.useSafeUpdate({
fieldName: "course",
fetchPath: "courses/123",
updatePath: "courses/123",
queryKey: "courses",
timeLimitMinutes: 15,
});
// Attempt update
const result = await safeUpdate({ title: "New Title" });
if (!result.immediate) {
// Show confirmation modal
setShowOverrideModal(true);
}
// User confirms
await confirmOverride();With UI
function EditCourse({ courseId }) {
const {
safeUpdate,
confirmOverride,
needsOverride,
isUpdating,
updateData,
} = queryHooks.useSafeUpdate({
fieldName: "course",
fetchPath: `courses/${courseId}`,
updatePath: `courses/${courseId}`,
queryKey: "courses",
});
const handleSave = async (formData) => {
const result = await safeUpdate(formData);
if (!result.immediate) {
setShowConfirmModal(true);
}
};
return (
<div>
<CourseForm
initialData={updateData}
onSubmit={handleSave}
isLoading={isUpdating}
/>
{needsOverride && (
<ConfirmModal
title="Conflict Detected"
message="This course was recently modified. Do you want to override?"
onConfirm={confirmOverride}
onCancel={() => setShowConfirmModal(false)}
/>
)}
</div>
);
}Common Patterns
Optimistic Updates
const updateCourse = queryHooks.useApiMutation({
method: "PATCH",
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["courses"] });
},
});
// Optimistic update
const handleToggle = async (courseId: string, isActive: boolean) => {
// Update UI immediately
queryClient.setQueryData(["courses"], (old) => {
return old?.map((course) =>
course.id === courseId ? { ...course, isActive: !isActive } : course
);
});
// Send request
try {
await updateCourse.mutateAsync({
path: `courses/${courseId}`,
isActive: !isActive,
});
} catch {
// Revert on error
queryClient.invalidateQueries({ queryKey: ["courses"] });
}
};Dependent Queries
// First query: get user
const { data: user } = queryHooks.useFetchData({
path: "auth/me",
queryKey: "current-user",
});
// Second query: get user's courses (depends on user)
const { data: courses } = queryHooks.useFetchData({
path: `users/${user?.data?.id}/courses`,
queryKey: "user-courses",
enabled: !!user?.data?.id, // Wait for user
});Prefetching
import { useQueryClient } from "@tanstack/react-query";
function CourseList() {
const queryClient = useQueryClient();
const handleHover = (courseId: string) => {
// Prefetch course details on hover
queryClient.prefetchQuery({
queryKey: ["course", courseId],
queryFn: () =>
fetchData({
queryKey: [{}, { path: `courses/${courseId}`, Method: "GET", token: "" }],
}),
staleTime: 60 * 1000, // 1 minute
});
};
return (
<div>
{courses.map((course) => (
<Link
key={course.id}
href={`/courses/${course.id}`}
onMouseEnter={() => handleHover(course.id)}
>
{course.title}
</Link>
))}
</div>
);
}Error Handling
const { data, error, isError } = queryHooks.useFetchData({
path: "courses",
queryKey: "courses-list",
});
if (isError) {
// Handle specific error types
if (error.message.includes("401")) {
router.push("/login");
} else if (error.message.includes("403")) {
router.push("/forbidden");
} else {
toast.error("Failed to load courses");
}
}Mutate with Path
// Single mutation for multiple endpoints
const mutation = queryHooks.useApiMutation({
method: "PATCH",
});
// Update course
mutation.mutate({
path: "courses/123",
title: "Updated Title",
});
// Update user
mutation.mutate({
path: "users/456",
name: "Updated Name",
});
// Update order
mutation.mutate({
path: "orders/789",
status: "completed",
});