User Profile
A complete user profile page with avatar upload, editable fields, and TanStack Query mutations.
Overview
This template provides:
- Profile form with validation
- Avatar upload with preview
- Optimistic updates
- Password change section
- Success/error notifications
- Loading states
Dependencies
npm install react-hook-form zod @hookform/resolvers @tanstack/react-query
npx shadcn-ui@latest add button input label card avatar tabs separatorCode
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { AvatarUpload } from './AvatarUpload';
import { PasswordChange } from './PasswordChange';
import { useUpdateProfile } from '../hooks/useUpdateProfile';
import { useAuth } from '@/features/auth';
const profileSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
bio: z.string().max(160, 'Bio must be 160 characters or less').optional(),
website: z.string().url('Invalid URL').optional().or(z.literal('')),
location: z.string().max(50).optional(),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function UserProfile() {
const { user } = useAuth();
const { mutate: updateProfile, isPending } = useUpdateProfile();
const {
register,
handleSubmit,
formState: { errors, isDirty },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
bio: user?.bio ?? '',
website: user?.website ?? '',
location: user?.location ?? '',
},
});
const onSubmit = (data: ProfileFormData) => {
updateProfile(data);
};
return (
<div className="container max-w-2xl py-10">
<div className="mb-8">
<h1 className="text-3xl font-bold">Profile Settings</h1>
<p className="text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<Tabs defaultValue="profile">
<TabsList className="mb-6">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your profile details and public information
</CardDescription>
</CardHeader>
<CardContent>
{/* Avatar Section */}
<div className="mb-6">
<Label className="mb-2 block">Profile Photo</Label>
<AvatarUpload
currentAvatar={user?.avatar}
onUpload={(url) => updateProfile({ avatar: url })}
/>
</div>
<Separator className="my-6" />
{/* Profile Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
{...register('name')}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p className="text-sm text-red-500">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register('email')}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<textarea
id="bio"
className="min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="Tell us about yourself"
{...register('bio')}
/>
{errors.bio && (
<p className="text-sm text-red-500">{errors.bio.message}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
type="url"
placeholder="https://example.com"
{...register('website')}
/>
{errors.website && (
<p className="text-sm text-red-500">{errors.website.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Input
id="location"
placeholder="San Francisco, CA"
{...register('location')}
/>
</div>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={!isDirty || isPending}>
{isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="password">
<PasswordChange />
</TabsContent>
</Tabs>
</div>
);
}Features
Avatar Upload
- File type and size validation
- Preview before upload
- Loading state during upload
- Error handling
Optimistic Updates
Profile changes appear instantly while saving in the background:
- Snapshot current state before mutation
- Apply changes optimistically
- Rollback on error
- Show success/error toast
Form Validation
| Field | Rules |
|---|---|
| Name | Required, min 2 characters |
| Required, valid email | |
| Bio | Max 160 characters |
| Website | Valid URL or empty |
| Password | Min 8 chars, uppercase, lowercase, number |
Related
- Auth Provider - Authentication state
- Login Form - Login component