From nextjs-supabase-ai-sdk-dev
This skill should be used when the user asks to "add client interactivity", "implement form validation", "add event handlers", "use client state", "add Zod validation", "implement React hooks", "add local state", "make component interactive", "add form with validation", "use React Hook Form", or needs guidance on client-side events, form handling, optimistic updates, or when to add "use client" directive.
npx claudepluginhub constellos/claude-code --plugin nextjs-supabase-ai-sdk-devThis skill uses the workspace's default tool permissions.
UI Interaction handles the client-side interactivity layer of Next.js applications. This skill covers adding client-side events, managing local state, implementing form validation with Zod, and using React Hook Form for complex forms.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
UI Interaction handles the client-side interactivity layer of Next.js applications. This skill covers adding client-side events, managing local state, implementing form validation with Zod, and using React Hook Form for complex forms.
Key principles:
Official Documentation:
Add the "use client" directive only when the component uses:
Pattern: Minimal Client Boundary
Keep "use client" components as small as possible:
// components/counter-button.tsx
"use client";
import { useState } from "react";
export function CounterButton() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
// app/page.tsx (Server Component - no "use client")
import { CounterButton } from "@/components/counter-button";
export default function Page() {
// Server-side data fetching, no client JS here
return (
<main>
<h1>Welcome</h1>
<CounterButton /> {/* Client boundary starts here */}
</main>
);
}
Analyze the UI to identify elements requiring client-side behavior:
Add the directive at the top of the file, before any imports:
"use client";
import { useState } from "react";
// ... rest of imports
Use appropriate React hooks for state:
"use client";
import { useState, useCallback } from "react";
export function ToggleButton({ initialState = false }: { initialState?: boolean }) {
const [isOn, setIsOn] = useState(initialState);
const toggle = useCallback(() => {
setIsOn(prev => !prev);
}, []);
return (
<button
onClick={toggle}
aria-pressed={isOn}
className={isOn ? "bg-green-500" : "bg-gray-300"}
>
{isOn ? "On" : "Off"}
</button>
);
}
Define Zod schemas for form validation:
import { z } from "zod";
// Define schema once, use for client AND server validation
export const contactFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export type ContactFormData = z.infer<typeof contactFormSchema>;
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/[0-9]/, "Password must contain a number"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof formSchema>;
export function SignUpForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
{...register("email")}
type="email"
id="email"
className="mt-1 block w-full rounded-md border-gray-300"
aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
{...register("password")}
type="password"
id="password"
className="mt-1 block w-full rounded-md border-gray-300"
aria-invalid={errors.password ? "true" : "false"}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600" role="alert">
{errors.password.message}
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
Confirm Password
</label>
<input
{...register("confirmPassword")}
type="password"
id="confirmPassword"
className="mt-1 block w-full rounded-md border-gray-300"
aria-invalid={errors.confirmPassword ? "true" : "false"}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Signing up..." : "Sign Up"}
</button>
</form>
);
}
"use client";
import { useCallback } from "react";
export function DeleteButton({ itemId, onDelete }: {
itemId: string;
onDelete: (id: string) => Promise<void>;
}) {
const handleDelete = useCallback(async () => {
if (confirm("Are you sure you want to delete this item?")) {
await onDelete(itemId);
}
}, [itemId, onDelete]);
return (
<button
onClick={handleDelete}
className="text-red-600 hover:text-red-800"
aria-label="Delete item"
>
Delete
</button>
);
}
"use client";
import { useCallback, KeyboardEvent } from "react";
export function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
onSearch(e.currentTarget.value);
}
}, [onSearch]);
return (
<input
type="search"
placeholder="Search..."
onKeyDown={handleKeyDown}
className="rounded-md border px-4 py-2"
aria-label="Search"
/>
);
}
Provide immediate feedback while server action processes:
"use client";
import { useOptimistic, useTransition } from "react";
interface Todo {
id: string;
text: string;
completed: boolean;
}
export function TodoItem({
todo,
toggleAction
}: {
todo: Todo;
toggleAction: (id: string) => Promise<void>;
}) {
const [isPending, startTransition] = useTransition();
const [optimisticTodo, setOptimisticTodo] = useOptimistic(
todo,
(state, completed: boolean) => ({ ...state, completed })
);
const handleToggle = () => {
startTransition(async () => {
setOptimisticTodo(!optimisticTodo.completed);
await toggleAction(todo.id);
});
};
return (
<div className={isPending ? "opacity-50" : ""}>
<input
type="checkbox"
checked={optimisticTodo.completed}
onChange={handleToggle}
aria-label={`Mark "${todo.text}" as ${optimisticTodo.completed ? "incomplete" : "complete"}`}
/>
<span className={optimisticTodo.completed ? "line-through" : ""}>
{todo.text}
</span>
</div>
);
}
"use client";
import { useState, useCallback } from "react";
export function Accordion({ title, children }: {
title: string;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
return (
<div className="border rounded-md">
<button
onClick={toggle}
aria-expanded={isOpen}
className="w-full px-4 py-2 text-left font-medium"
>
{title}
<span className="float-right">{isOpen ? "−" : "+"}</span>
</button>
{isOpen && (
<div className="px-4 py-2 border-t">
{children}
</div>
)}
</div>
);
}
"use client";
import { useState, ChangeEvent, useCallback } from "react";
export function ControlledInput({
initialValue = "",
onChange
}: {
initialValue?: string;
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(initialValue);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
onChange?.(newValue);
}, [onChange]);
return (
<input
type="text"
value={value}
onChange={handleChange}
className="rounded-md border px-4 py-2"
/>
);
}
DO:
DON'T:
| Hook | Use Case |
|---|---|
| useState | Local component state |
| useCallback | Memoize event handlers |
| useMemo | Expensive computations |
| useRef | DOM references, mutable values |
| useOptimistic | Optimistic UI updates |
| useTransition | Non-blocking state updates |
| Validator | Example |
|---|---|
| string | z.string().min(1).max(100) |
z.string().email() | |
| number | z.number().int().positive() |
| enum | z.enum(["a", "b", "c"]) |
| optional | z.string().optional() |
| nullable | z.string().nullable() |
| refine | schema.refine(val => condition, "message") |
To add interactivity to a component: