agentleFS
charanjit-singhcharanjit-singh/lovable-skills/skills/typed-api-hooks-forms/SKILL.md

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.

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
  1. Very specific:

    Never: call supabase.from(...) directly inside components.

    line 55
  2. Distinctive:

    Use tuple keys so SWR dedupes identical requests across the tree:

    line 99
  3. Very specific:

    Put unstable objects in keys without normalizing (e.g.

    line 126
  4. Distinctive:

    If many hooks share behavior, wrap the app once:

    line 132
  5. Very specific:

    When several UI surfaces always need the same Supabase graph together, prefer one fetcher with a single .select() that includes every …

    line 144
  6. Very specific:

    After mutations, call mutate on every affected key (or mutate(key => Array.isArray(key) && key[0] === 'posts') for broad invalidation).

    line 179
  7. Very specific:

    Keep mutations in api/ (e.g.

    line 185
  8. Very specific:

    Do not leave lists stale after create/update/delete.

    line 191
  9. Very specific:

    Every new or edited form must use a Zod schema and react-hook-form with zodResolver.

    line 197
  10. Very specific:

    Use untyped useForm() without PostFormValues.

    line 261

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 usesUse this skill plus
TanStack Query (Lovable default)tanstack-query-alternative for hooks, this skill for forms
SWRThis skill end-to-end

Never mix TanStack Query and SWR in the same app.

Stack defaults

ConcernUse
Client cache (TanStack project)@tanstack/react-query — see tanstack-query-alternative
Client cache (SWR project)SWR (useSWR)
BackendSupabase client + generated Database types
Formsreact-hook-form + @hookform/resolvers/zod + Zod
UI wiringshadcn 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.ts

Rules

  • Fetchers live in api/ — async functions that return typed data or throw.
  • Hooks live in hooks/ — only useSWR + argument handling + derived return shape.
  • Never call supabase.from(...) directly inside components; go through a hook.

2. Type safety (required)

  1. Regenerate or use existing Database types from Supabase (supabase gen types typescript).
  2. 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;
}
  1. For joined/nested selects, define an explicit type (do not use any):
export type PostWithAuthor = Post & {
  author: Pick<User, "id" | "display_name" | "avatar_url">;
};
  1. Hook return types must be explicit or inferred from the fetcher — never data: any.
  2. Mutations: type insert/update payloads with TablesInsert / TablesUpdate from 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-" + id vs ["user", id]).
  • Fetch inside useEffect when 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

SituationApproach
Same entity, same fields, many componentsOne hook + shared SWR key
Same table, different filtersOne usePosts(filters); distinct keys per filter tuple
List page + detail page need overlapping rowsList 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 screenSingle 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:

  1. Call mutation function.
  2. await refresh() from the hook, or mutate global keys for that entity.
  3. 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 required or manual if (!title) checks.
  • Duplicate schema rules in component state.
  • Use untyped useForm() without PostFormValues.
  • Submit FormData without 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 in hooks/ use stable tuple keys.
  • No duplicate Supabase round trips for the same screen when one joined select suffices.
  • Components use hooks (useUser, usePosts, …), not raw Supabase calls.
  • Forms use Zod + zodResolver + typed useForm<…>.
  • Mutations invalidate or mutate the right SWR keys.
  • Loading and error states exposed from hooks and shown in UI.

8. Avoid

  • useEffect + fetch for server data that SWR should own.
  • Inline Supabase queries in JSX/event handlers (except rare one-off admin scripts).
  • any, @ts-ignore, or untyped data from Supabase.
  • Multiple incompatible cache keys for the same resource.
  • Forms without Zod (when this skill applies).

For deeper copy-paste templates, see examples.md.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.