React Query Hooks
Every resource generated with grit generate resource produces a complete set of React Query hooks for CRUD operations with pagination, search, sorting, cache invalidation, and optimistic updates.
Auto-Generated Hooks Pattern
When you run grit generate resource Post, Grit creates a hook file at hooks/use-posts.ts in both apps/web and apps/admin. Each file contains five hooks that map to the CRUD API endpoints.
| Hook | HTTP Method | Purpose |
|---|---|---|
| usePosts() | GET /api/posts | Paginated list with sorting and search |
| useGetPost(id) | GET /api/posts/:id | Fetch a single post by ID |
| useCreatePost() | POST /api/posts | Create a new post |
| useUpdatePost() | PUT /api/posts/:id | Update an existing post |
| useDeletePost() | DELETE /api/posts/:id | Soft-delete a post |
useList Hook
The list hook accepts pagination, search, and sort parameters. It returns a React Query result with the paginated data and metadata (total, page, pages).
interface UsePostsParams {
page?: number;
pageSize?: number;
search?: string;
sortBy?: string;
sortOrder?: string;
}
export function usePosts({
page = 1,
pageSize = 20,
search = "",
sortBy = "created_at",
sortOrder = "desc",
}: UsePostsParams = {}) {
return useQuery<PostsResponse>({
queryKey: ["posts", { page, pageSize, search, sortBy, sortOrder }],
queryFn: async () => {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
sort_by: sortBy,
sort_order: sortOrder,
});
if (search) {
params.set("search", search);
}
const { data } = await apiClient.get(`/api/posts?${params}`);
return data;
},
});
}useGetById Hook
Fetches a single resource by ID. The query is disabled when no valid ID is provided, preventing unnecessary API calls. The query key includes the resource name and ID for precise cache targeting.
export function useGetPost(id: number) {
return useQuery<Post>({
queryKey: ["posts", id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/posts/${id}`);
return data.data;
},
enabled: !!id,
});
}useCreate, useUpdate, useDelete
All mutation hooks use useMutation from React Query. On success, each mutation automatically invalidates the list query cache, causing the table to refetch fresh data from the server.
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: Record<string, unknown>) => {
const { data } = await apiClient.post("/api/posts", input);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
export function useUpdatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
...input
}: { id: number } & Record<string, unknown>) => {
const { data } = await apiClient.put(`/api/posts/${id}`, input);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/api/posts/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}Query Key Patterns
Grit follows a consistent query key structure that enables precise cache management. The first element is always the resource name (plural), and optional subsequent elements narrow the scope.
| Query Key | Scope |
|---|---|
| ["posts"] | All post queries (invalidates everything) |
| ["posts", {...params}] | Specific list page with filters |
| ["posts", 42] | Single post by ID |
When a mutation calls invalidateQueries({queryKey: ["posts"]}), React Query invalidates all queries whose key starts with ["posts"] -- including all paginated list queries and individual item queries. This is why your table automatically refreshes after creating, updating, or deleting a post.
Cache Invalidation on Mutations
Grit's generated hooks use the onSuccess callback to invalidate related queries after every mutation. This pattern ensures the UI always reflects the latest server state without manual refetching.
User clicks "Delete" on row
-> useDeletePost.mutate(42)
-> DELETE /api/posts/42
-> 200 OK
-> onSuccess fires
-> queryClient.invalidateQueries(["posts"])
-> usePosts() refetches from server
-> Table re-renders with updated dataPagination + Search Parameters
The list hook accepts all pagination and filter parameters as a single options object. Each combination of parameters creates a unique cache entry, so navigating between pages is instant on revisit.
// Fetch page 2 with 10 items, search for "react", sort by title
const { data, isLoading, error } = usePosts({
page: 2,
pageSize: 10,
search: "react",
sortBy: "title",
sortOrder: "asc",
});
// Access the paginated response
console.log(data?.data); // Post[]
console.log(data?.meta.total); // 42
console.log(data?.meta.pages); // 5
console.log(data?.meta.page); // 2Using Hooks in Components
Here is a complete example of a posts list page using the generated hooks, including search, pagination, and delete functionality.
"use client";
import { useState } from "react";
import { usePosts, useDeletePost } from "@/hooks/use-posts";
import { toast } from "sonner";
export default function PostsPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState("desc");
const { data, isLoading, error } = usePosts({
page,
pageSize: 20,
search,
sortBy,
sortOrder,
});
const deletePost = useDeletePost();
const handleDelete = (id: number) => {
deletePost.mutate(id, {
onSuccess: () => toast.success("Post deleted"),
onError: () => toast.error("Failed to delete post"),
});
};
const handleSort = (column: string) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("asc");
}
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading posts</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Posts</h1>
<input
type="text"
placeholder="Search posts..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1); // Reset to first page on search
}}
className="px-3 py-2 rounded-lg border border-border bg-bg-secondary"
/>
</div>
{/* Table */}
<table className="w-full">
<thead>
<tr>
<th onClick={() => handleSort("title")}>Title</th>
<th onClick={() => handleSort("created_at")}>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{data?.data.map((post) => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{new Date(post.created_at).toLocaleDateString()}</td>
<td>
<button onClick={() => handleDelete(post.id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>
Page {data?.meta.page} of {data?.meta.pages}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= (data?.meta.pages ?? 1)}
>
Next
</button>
</div>
</div>
);
}Custom Hooks Beyond Generated Ones
The generated hooks cover standard CRUD operations. For custom endpoints, queries, or business logic, create additional hooks following the same pattern. Use the sharedapiClient instance to ensure JWT tokens are automatically injected.
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
interface PostStats {
total: number;
published: number;
draft: number;
viewsThisWeek: number;
}
export function usePostStats() {
return useQuery<PostStats>({
queryKey: ["posts", "stats"],
queryFn: async () => {
const { data } = await apiClient.get("/api/posts/stats");
return data.data;
},
staleTime: 60 * 1000, // 1 minute
});
}
// Bulk publish mutation
export function useBulkPublish() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (ids: number[]) => {
const { data } = await apiClient.post("/api/posts/bulk-publish", {
ids,
});
return data;
},
onSuccess: () => {
// Invalidate both posts list and stats
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}Convention: Always place hooks in the hooks/ directory, name them with the use- prefix and kebab-case file names, and follow the query key pattern ["resource", ...specifics].