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.
Adds client-side interactivity to Next.js components with React hooks, Zod validation, and React Hook Form.
/plugin marketplace add constellos/claude-code-plugins/plugin install enhanced-context@constellos-localThis skill inherits all available tools. When active, it can use any tool Claude has access to.
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:
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
This skill should be used when the user asks to "create a hookify rule", "write a hook rule", "configure hookify", "add a hookify rule", or needs guidance on hookify rule syntax and patterns.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.