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.

PropTypeDefaultDescription
pathstringβ€”API endpoint path (e.g., 'courses')
queryKeystring | unknown[]β€”Unique cache key for this query
method"GET" | "POST""GET"HTTP method
filterDataRecord<string, any>{}Query parameters or POST body
tokenstringβ€”Explicit auth token (auto-detected if not provided)
withOutTokenbooleanfalseSkip authentication
enabledbooleantrueEnable/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.

PropTypeDefaultDescription
method"POST" | "PUT" | "PATCH" | "DELETE"β€”HTTP method (required)
pathstringβ€”API endpoint path (or pass in mutate call)
tokenstringβ€”Explicit auth token
safebooleantrueRemove 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
isSuccessToastbooleantrueShow success toast
isErrorToastbooleantrueShow 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.

PropTypeDefaultDescription
pathstringβ€”API endpoint path
queryKeystring | unknown[]β€”Cache key
method"GET" | "POST""GET"HTTP method
filterDataRecord<string, any>{}Base filters (page added automatically)
enabledbooleantrueEnable/disable the query
initialPageParamnumber1Starting 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.

PropTypeDefaultDescription
fieldNamestringβ€”Entity field name in API response
fetchPathstringβ€”Path to fetch current state
updatePathstringβ€”Path to update entity
queryKeystringβ€”Cache key to invalidate after update
timeLimitMinutesnumber15Minutes before requiring confirmation
fetchMethod"GET" | "POST""GET"Method for fetching current state
fetchFilterRecord<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",
});
E-Crystal Hooks Guide Β· React Query + TanStack