| name | frontend-query-hooks |
| description | Use when creating or using TanStack Query hooks for data fetching |
Frontend Query Hooks Pattern
TanStack Query hook patterns for data fetching and state management.
CRITICAL RULE: NO DESTRUCTURING
const qOrganization = useQ_PageOrganization_Organization({ organizationId });
qOrganization.query.isLoading;
qOrganization.organization?.projects;
const { query, organization } = useQ_PageOrganization_Organization({ organizationId });
const qPageOrganization_Organization = useQ_PageOrganization_Organization({ organizationId });
Why: Provides namespace, prevents name conflicts, makes refactoring easier.
Hook Naming Convention
Pattern: 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:
- Page:
hooks/Page[Name]/useQ_Page[Name]_[Entity].ts
- Sub-component:
hooks/Page[Name]/Page[Name]_[SubComp]/useQ_Page[Name]_[SubComp]_[Entity].ts
- User:
hooks/Me/useQ_Me.ts
- Tables:
hooks/Tables/useQ_Tables_[Entity].ts
- Options:
hooks/Options/useQ_Options_[Table]_[Field]s.ts
Variable Naming: q[Entity]
const qOrganizations = useQ_PageRoot_Organizations();
const qOrganization = useQ_PageOrganization_Organization({ organizationId });
const qMe = useQ_Me();
const qRoles = useQ_Options_RolePermissions_Roles();
Hook Return Structure
return {
query,
[rawData],
[derivedMaps],
};
Examples: { query, projects, projectsMap } | { query, project } | { query, options, optionsMap }
QueryKeys Pattern - .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";
queryKey: [...QueryKeys.scenes.list(), { setId }];
queryKey: [...QueryKeys.scenes.record(sceneId)];
queryKey: [
...QueryKeys.scenes.record(sceneId),
...QueryKeys.scene_files.list(),
...QueryKeys.files.list(),
];
Join Strategy
| 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 queries MUST use
.list() - fetches multiple rows
- Single-record queries MUST use
.record(id) - fetches one row by ID
- Joined arrays use
.list() - any change invalidates parent
- Known 1:1 IDs use
.record() - if you know the joined ID
Minimal Processing Principle
Query hooks should do MINIMAL data transformation. Let components handle business logic.
✅ ALLOWED Processing
- Null coalescing:
query.data || []
- Map building:
reduce((acc, item) => ({ ...acc, [item.id]: item }), {})
- Simple memoization: Wrapping raw data in useMemo
❌ FORBIDDEN Processing
- Flattening/reshaping data structures
- Extracting nested arrays (e.g.,
organization.projects → projects)
- Business logic (isOwner, role, permissions)
- Cross-hook data merging
- Custom type reshaping
Components Handle Business Logic
return (
<div>
{qOrganization.organization?.owner_id === qMe.user?.id && <OwnerBadge />}
<h1>{qOrganization.organization?.name}</h1>
</div>
);
const isOwner = useMemo(() => organization?.owner_id === qMe.user?.id, [...]);
DRY for Repeated Checks (3+ times)
const isCurrentUserOwner = qOrganization.organization?.owner_id === qMe.user?.id;
const isUserOwner = (userId: string) => qOrganization.organization?.owner_id === userId;
Rules: No useMemo for simple booleans, extract at 3+ repetitions, use helper functions in loops.
Type Safety with QueryData
import { supabase, QueryData } from "@/configs/supabase/config";
const createProjectsQuery = (orgId: string) =>
supabase
.from("projects")
.select("id, name, description, created_at")
.eq("organization_id", orgId);
export type PageOrganization_Projects_QueryData = QueryData<ReturnType<typeof createProjectsQuery>>;
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.
Query Key Factory for Mutation Reuse
Export query key factory when mutations need cache access:
export const PageSet_SceneFile_QueryKey = (sceneFileId: string) =>
[...QueryKeys.scene_files.record(sceneFileId), ...QueryKeys.files.list()] as const;
queryKey: PageSet_SceneFile_QueryKey(sceneFileId);
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
Options Query Hooks Pattern
For dropdown/select options from database tables.
When to Use
- ✅ Options from database (roles, categories, statuses)
- ✅ Options that may change dynamically
- ❌ Static/hardcoded options (use const array)
Naming: useQ_Options_[Table]_[Field]s
useQ_Options_RolePermissions_Roles;
useQ_Options_Files_MimeTypes;
Return Structure
return {
query,
options,
optionsMap,
};
Usage
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.
Hook Composition
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 });
return { query: qMe.query, organizations, organizationsMap };
};
Profile Batch Query Pattern
See frontend-profile-batch-query skill for the cross-schema join workaround pattern when fetching multiple user profiles.
Detection Checklist
Hook Structure:
Options Hook:
Common Mistakes
- ❌ Destructuring hooks → Use namespace pattern
- ❌ Using
select("*") → Use explicit columns with QueryData
- ❌ Business logic in hooks → Move to components
- ❌ Flattening data → Return raw query.data
- ❌ Deep Supabase joins → Use hook composition
- ❌ Missing QueryKeys → Always use
.list() or .record()
- ❌ Not memoizing → Always memoize derived data