Documentation
Templates
Components
User Management
User Profile

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 separator

Code

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

FieldRules
NameRequired, min 2 characters
EmailRequired, valid email
BioMax 160 characters
WebsiteValid URL or empty
PasswordMin 8 chars, uppercase, lowercase, number

Related