typed-api-hooks-forms
Skill10 starsChanged 6 months agoMITView on GitHub
Use when adding API hooks (useUser, usePost) on an SWR project, or any time the task includes Zod + react-hook-form on the client. For new Lovable projects, prefer TanStack Query (tanstack-query-alternative) — Lovable's default. Not for styling-only changes or auth provider setup.
In this repository56 other files it gives its agents.
- accessibility-passSkill
- analytics-eventsSkill
- api-error-handlingSkill
- audit-logging-backendSkill
- background-jobs-and-cronSkill
- dark-mode-and-themingSkill
- dashboard-charts-rechartsSkill
- edge-functions-and-webhooksSkill
- email-and-notificationsSkill
- error-states-and-empty-uiSkill
- feature-flags-and-togglesSkill
- file-upload-storageSkill
- i18n-multi-languageSkill
- lovable-prompting-playbookSkill
- lovable-ship-checklistSkill
- migration-playbookSkill
- multi-tenant-backendSkill
- onboarding-copySkill
- optimistic-updatesSkill
- payment-failed-and-recoverySkill
- payment-webhook-idempotencySkill
- payment-webhook-testingSkill
- performance-budgetSkill
- pg-cron-recipesSkill
- pg-cron-scheduled-jobsSkill
- pg-cron-with-pg-net-edgeSkill
- postgres-full-text-searchSkill
- postgres-triggers-and-functionsSkill
- pricing-and-billingSkill
- programmatic-seo-content-qualitySkill
- programmatic-seo-pagesSkill
- rate-limiting-edgeSkill
- realtime-and-subscriptionsSkill
- refactor-safe-diffSkill
- release-notesSkill
- seo-core-web-vitalsSkill
- seo-landing-pageSkill
- seo-meta-tags-spaSkill
- seo-sitemap-generationSkill
- seo-ssr-and-prerenderingSkill
- seo-structured-dataSkill
- server-input-validationSkill
- shadcn-patternsSkill
- soft-delete-and-archivingSkill
- stripe-one-time-payment-webhooksSkill
- stripe-payment-webhooksSkill
- supabase-auth-flowsSkill
- supabase-database-designSkill
- supabase-rls-and-authSkill
- support-replySkill
- tanstack-infinite-queriesSkill
- tanstack-mutations-and-invalidationSkill
- tanstack-prefetch-and-hydrationSkill
- tanstack-query-alternativeSkill
- tanstack-router-data-loadersSkill
- tanstack-start-server-routesSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 2 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 55
Never: call supabase.from(...) directly inside components.
- Distinctive: line 99
Use tuple keys so SWR dedupes identical requests across the tree:
- Very specific: line 126
Put unstable objects in keys without normalizing (e.g.
- Distinctive: line 132
If many hooks share behavior, wrap the app once:
- Very specific: line 144
When several UI surfaces always need the same Supabase graph together, prefer one fetcher with a single .select() that includes every …
- Very specific: line 179
After mutations, call mutate on every affected key (or mutate(key => Array.isArray(key) && key[0] === 'posts') for broad invalidation).
- Very specific: line 185
Keep mutations in api/ (e.g.
- Very specific: line 191
Do not leave lists stale after create/update/delete.
- Very specific: line 197
Every new or edited form must use a Zod schema and react-hook-form with zodResolver.
- Very specific: line 261
Use untyped useForm() without PostFormValues.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Typed API hooks and forms
This skill covers the forms half (Zod + react-hook-form) and an SWR-based hooks pattern. Pick one cache library per project:
| If the project uses | Use this skill plus |
| TanStack Query (Lovable default) | tanstack-query-alternative for hooks, this skill for forms |
| SWR | This skill end-to-end |
Never mix TanStack Query and SWR in the same app.
Stack defaults
| Concern | Use |
| Client cache (TanStack project) | @tanstack/react-query — see tanstack-query-alternative |
| Client cache (SWR project) | SWR (useSWR) |
| Backend | Supabase client + generated Database types |
| Forms | react-hook-form + @hookform/resolvers/zod + Zod |
| UI wiring | shadcn Form, FormField, FormMessage |
Install only if missing: cache library, zod, react-hook-form, @hookform/resolvers.
1. Data layer layout
src/
lib/
supabase.ts # createClient (or existing)
types/
database.ts # generated Supabase types (do not hand-edit)
api/
users.ts # pure fetchers (no React)
posts.ts
hooks/
useUser.ts # thin useSWR wrappers
usePosts.tsRules
- Fetchers live in
api/— async functions that return typed data or throw. - Hooks live in
hooks/— onlyuseSWR+ argument handling + derived return shape. - Never call
supabase.from(...)directly inside components; go through a hook.
2. Type safety (required)
- Regenerate or use existing
Databasetypes from Supabase (supabase gen types typescript). - Type every fetcher return value:
import type { Database } from "@/types/database";
type User = Database["public"]["Tables"]["users"]["Row"];
type Post = Database["public"]["Tables"]["posts"]["Row"];
export async function fetchUser(id: string): Promise<User | null> {
const { data, error } = await supabase
.from("users")
.select("*")
.eq("id", id)
.maybeSingle();
if (error) throw error;
return data;
}- For joined/nested selects, define an explicit type (do not use
any):
export type PostWithAuthor = Post & {
author: Pick<User, "id" | "display_name" | "avatar_url">;
};- Hook return types must be explicit or inferred from the fetcher — never
data: any. - Mutations: type insert/update payloads with
TablesInsert/TablesUpdatefrom generated types.
3. SWR hooks pattern
Stable cache keys
Use tuple keys so SWR dedupes identical requests across the tree:
// hooks/useUser.ts
import useSWR from "swr";
import { fetchUser } from "@/api/users";
export function useUser(userId: string | null | undefined) {
const key = userId ? (["user", userId] as const) : null;
const { data, error, isLoading, mutate } = useSWR(key, () => fetchUser(userId!));
return { user: data ?? null, error, isLoading, refresh: mutate };
}// hooks/usePosts.ts
export function usePosts(filters: { authorId?: string; status?: string }) {
const key = ["posts", filters] as const;
const { data, error, isLoading, mutate } = useSWR(key, () => fetchPosts(filters));
return { posts: data ?? [], error, isLoading, refresh: mutate };
}Naming: useUser, usePost, usePosts, useComments — entity name matches the hook; plural hook returns arrays.
Do not
- Put unstable objects in keys without normalizing (e.g. new
{}every render). Serialize filters:["posts", authorId, status]. - Duplicate keys for the same resource (
"user-" + idvs["user", id]). - Fetch inside
useEffectwhen a hook already exists for that resource.
Global SWR config (optional)
If many hooks share behavior, wrap the app once:
<SWRConfig value={{ revalidateOnFocus: false, dedupingInterval: 2000 }}>4. One fetch when the same data is needed in many places
SWR dedupes by key — multiple components calling useUser(id) with the same id share one request. Ensure keys match.
When several UI surfaces always need the same Supabase graph together, prefer one fetcher with a single .select() that includes every column and relation needed, instead of three parallel hooks hitting the same row:
// api/posts.ts — one round trip
export async function fetchPostDetail(postId: string): Promise<PostWithAuthorAndComments> {
const { data, error } = await supabase
.from("posts")
.select(`
*,
author:users!posts_author_id_fkey ( id, display_name, avatar_url ),
comments ( id, body, created_at, author:users ( id, display_name ) )
`)
.eq("id", postId)
.single();
if (error) throw error;
return data;
}export function usePostDetail(postId: string | null | undefined) {
const key = postId ? (["post-detail", postId] as const) : null;
return useSWR(key, () => fetchPostDetail(postId!));
}Choose one strategy
| Situation | Approach |
| Same entity, same fields, many components | One hook + shared SWR key |
| Same table, different filters | One usePosts(filters); distinct keys per filter tuple |
| List page + detail page need overlapping rows | List hook for list; detail hook with richer select — do not refetch list fields in a second hook on the detail page unless you need independent cache TTL |
| Multiple components need full post + author + comments on one screen | Single fetchPostDetail / usePostDetail |
After mutations, call mutate on every affected key (or mutate(key => Array.isArray(key) && key[0] === 'posts') for broad invalidation).
5. Mutations
Keep mutations in api/ (e.g. createPost, updateUser). From components:
- Call mutation function.
await refresh()from the hook, ormutateglobal keys for that entity.- Surface errors to the UI (toast or form-level).
Do not leave lists stale after create/update/delete.
6. Forms — Zod + react-hook-form (required)
Every new or edited form must use a Zod schema and react-hook-form with zodResolver.
Schema
import { z } from "zod";
export const postFormSchema = z.object({
title: z.string().min(1, "Title is required").max(120),
body: z.string().min(1, "Body is required"),
status: z.enum(["draft", "published"]),
});
export type PostFormValues = z.infer<typeof postFormSchema>;Reuse PostFormValues for props and API payloads. Align enums and max lengths with the database/check constraints.
Form component
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { postFormSchema, type PostFormValues } from "@/schemas/post";
export function PostForm({ defaultValues, onSubmit }: {
defaultValues?: Partial<PostFormValues>;
onSubmit: (values: PostFormValues) => Promise<void>;
}) {
const form = useForm<PostFormValues>({
resolver: zodResolver(postFormSchema),
defaultValues: { title: "", body: "", status: "draft", ...defaultValues },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* remaining fields */}
<Button type="submit" disabled={form.formState.isSubmitting}>
Save
</Button>
</form>
</Form>
);
}Do not
- Validate only with HTML
requiredor manualif (!title)checks. - Duplicate schema rules in component state.
- Use untyped
useForm()withoutPostFormValues. - Submit
FormDatawithout parsing through the Zod schema when the rest of the app uses this pattern.
Edit forms
Set defaultValues from the SWR hook (usePost, useUser). Reset when async data arrives:
useEffect(() => {
if (post) form.reset(postFormSchema.parse(mapPostToForm(post)));
}, [post, form]);Map DB nullables to form defaults explicitly in mapPostToForm.
7. Implementation checklist
Before finishing, confirm:
- Fetchers in
api/are typed; hooks inhooks/use stable tuple keys. - No duplicate Supabase round trips for the same screen when one joined
selectsuffices. - Components use hooks (
useUser,usePosts, …), not raw Supabase calls. - Forms use Zod +
zodResolver+ typeduseForm<…>. - Mutations invalidate or
mutatethe right SWR keys. - Loading and error states exposed from hooks and shown in UI.
8. Avoid
useEffect+fetchfor server data that SWR should own.- Inline Supabase queries in JSX/event handlers (except rare one-off admin scripts).
any,@ts-ignore, or untypeddatafrom Supabase.- Multiple incompatible cache keys for the same resource.
- Forms without Zod (when this skill applies).
For deeper copy-paste templates, see examples.md.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

