Use when creating or using TanStack Query hooks for data fetching
TanStack Query hook patterns for data fetching and state management.
// ✅ CORRECT - Store entire hook result with short variable name
const qOrganization = useQ_PageOrganization_Organization({ organizationId });
qOrganization.query.isLoading;
qOrganization.organization?.projects;
// ❌ WRONG - Never destructure
const { query, organization } = useQ_PageOrganization_Organization({ organizationId });
// ❌ WRONG - Variable name too verbose
const qPageOrganization_Organization = useQ_PageOrganization_Organization({ organizationId });
Why: Provides namespace, prevents name conflicts, makes refactoring easier.
useQ_[Scope]_[Entity]Scopes:
Page[Name] - Page-level data (e.g., useQ_PageRoot_Organizations)Page[Name]_[SubComp] - Sub-component data (e.g., useQ_PageProjectShot_Files_Files)Me - User-specific data (e.g., useQ_Me)Tables - Direct table access (e.g., useQ_Tables_Organizations)Options - Dropdown/select options from database (e.g., useQ_Options_RolePermissions_Roles)Entity: Plural for lists (Organizations), singular for single items (Organization)
Folder Structure:
hooks/Page[Name]/useQ_Page[Name]_[Entity].tshooks/Page[Name]/Page[Name]_[SubComp]/useQ_Page[Name]_[SubComp]_[Entity].tshooks/Me/useQ_Me.tshooks/Tables/useQ_Tables_[Entity].tshooks/Options/useQ_Options_[Table]_[Field]s.tsq[Entity]const qOrganizations = useQ_PageRoot_Organizations();
const qOrganization = useQ_PageOrganization_Organization({ organizationId });
const qMe = useQ_Me();
const qRoles = useQ_Options_RolePermissions_Roles();
return {
query, // Always return full query object
[rawData], // Raw data array or single item
[derivedMaps], // Optional: Maps for quick lookup
};
Examples: { query, projects, projectsMap } | { query, project } | { query, options, optionsMap }
.list() vs .record()CRITICAL: Use .list() for list queries and .record() for single-record queries. Enables granular invalidation in realtime sync.
import { QueryKeys } from "@/utils/query/queryKeys";
// LIST query - fetches multiple rows, invalidates on ANY table change
queryKey: [...QueryKeys.scenes.list(), { setId }];
// RECORD query - fetches one row by ID, invalidates ONLY when that record changes
queryKey: [...QueryKeys.scenes.record(sceneId)];
// Single record with joins (Case 3: one scene + many files)
queryKey: [
...QueryKeys.scenes.record(sceneId),
...QueryKeys.scene_files.list(),
...QueryKeys.files.list(),
];
| Case | Relationship | Pattern |
|---|---|---|
| Case 1 | A (1) + B (1:1, ID known) | A.record(aId), B.record(bId) |
| Case 2 | As (list) + Bs (list) | A.list(), B.list() |
| Case 3 | A (1) + Bs (1:N array) | A.record(aId), B.list() |
| Case 1b | A (1) + B (1:1, ID unknown) | A.record(aId), B.list() |
Rules:
.list() - fetches multiple rows.record(id) - fetches one row by ID.list() - any change invalidates parent.record() - if you know the joined IDQuery hooks should do MINIMAL data transformation. Let components handle business logic.
query.data || []reduce((acc, item) => ({ ...acc, [item.id]: item }), {})organization.projects → projects)// ✅ CORRECT - Use raw data directly inline
return (
<div>
{qOrganization.organization?.owner_id === qMe.user?.id && <OwnerBadge />}
<h1>{qOrganization.organization?.name}</h1>
</div>
);
// ❌ WRONG - Don't create intermediate variables in hooks
const isOwner = useMemo(() => organization?.owner_id === qMe.user?.id, [...]);
// ✅ Extract const when same check repeats 3+ times
const isCurrentUserOwner = qOrganization.organization?.owner_id === qMe.user?.id;
// ✅ Helper functions for loops with different parameters
const isUserOwner = (userId: string) => qOrganization.organization?.owner_id === userId;
Rules: No useMemo for simple booleans, extract at 3+ repetitions, use helper functions in loops.
import { supabase, QueryData } from "@/configs/supabase/config";
// 1. Extract query function OUTSIDE hook
const createProjectsQuery = (orgId: string) =>
supabase
.from("projects")
.select("id, name, description, created_at") // Explicit columns only!
.eq("organization_id", orgId);
// 2. Infer type from query shape
export type PageOrganization_Projects_QueryData = QueryData<ReturnType<typeof createProjectsQuery>>;
// 3. Use in hook
export const useQ_PageOrganization_Organization = ({ organizationId }: Params) => {
const { message } = useApp();
const query = useQuery({
enabled: !!organizationId,
queryKey: [...QueryKeys.projects.list(), { organizationId }],
queryFn: async () => {
const sb_FromProjects_Select = await createProjectsQuery(organizationId);
if (sb_FromProjects_Select.error) {
console.error(sb_FromProjects_Select.error);
message.error("Failed to fetch projects!");
throw sb_FromProjects_Select.error;
}
return sb_FromProjects_Select.data;
},
});
const projects = useMemo(() => query.data || [], [query.data]);
const projectsMap = useMemo(
() =>
projects.reduce(
(acc, p) => ({ ...acc, [p.id]: p }),
{} as Record<string, PageOrganization_Projects_QueryData[number]>
),
[projects]
);
return { query, projects, projectsMap };
};
Benefits: Compile-time safety, better performance, self-documenting, refactor-safe.
JSONB Columns: Use database override pattern with MergeDeep from type-fest. See supabase-schema-design skill.
Export query key factory when mutations need cache access:
// Export factory (NOT a hook)
export const PageSet_SceneFile_QueryKey = (sceneFileId: string) =>
[...QueryKeys.scene_files.record(sceneFileId), ...QueryKeys.files.list()] as const;
// Use in query hook
queryKey: PageSet_SceneFile_QueryKey(sceneFileId);
// Use in mutation for optimistic updates
import {
PageSet_SceneFile_QueryKey,
type PageSet_SceneFile_QueryData,
} from "./useQ_PageSet_SceneFile";
const queryKey = PageSet_SceneFile_QueryKey(variables.id);
queryClient.setQueryData(queryKey, optimisticValue);
Naming: useQ_PageSet_SceneFile → PageSet_SceneFile_QueryKey
For dropdown/select options from database tables.
useQ_Options_[Table]_[Field]suseQ_Options_RolePermissions_Roles; // Unique roles from role_permissions.role
useQ_Options_Files_MimeTypes; // Unique MIME types from files.mime_type
return {
query, // TanStack Query object
options, // Array<{ label, value }> for Antd Select
optionsMap, // Record<value, { label, value }> for lookups
};
const qRoles = useQ_Options_RolePermissions_Roles();
<Select options={qRoles.options} loading={qRoles.query.isLoading} />
Key patterns: Labels formatted for display, values are raw DB values, use Array.from(new Set(...)) for unique values.
Compose hooks to avoid deeply nested joins:
export const useQ_PageRoot_Organizations = () => {
const qMe = useQ_Me();
const organizationIds = useMemo(
() => qMe.user?.organization_memberships?.map((m) => m.organization_id) || [],
[qMe.user]
);
const qTables_Organizations = useQ_Tables_Organizations({ organization_ids: organizationIds });
// ... combine data
return { query: qMe.query, organizations, organizationsMap };
};
See frontend-profile-batch-query skill for the cross-schema join workaround pattern when fetching multiple user profiles.
Hook Structure:
useQ_[Scope]_[Entity]q[Entity] (drop scope prefix){ query, [data], [maps] } only - no business logicselect("*")).list() or .record())message.error()enabled flag for conditional queriesOptions Hook:
useQ_Options_[Table]_[Field]s{ query, options, optionsMap }Array<{ label: string, value: string }>hooks/Options/select("*") → Use explicit columns with QueryData.list() or .record()