---
description: Example of creating React components with best practices
version: 1.0.0
lastUpdated: 2025-01-10
tags: [example, react, component, frontend]
---

# Example: React Component

This document demonstrates how to create React components following best practices and patterns used in the Izri codebase.

---

## Basic Component Structure

```typescript
// File: apps/web/app/components/features/ProjectCard.tsx
import { Card, CardHeader, CardTitle, CardDescription, CardFooter } from '~/components/ui/card';
import { Button } from '~/components/ui/button';
import type { Project } from '@izri/database/schema';

interface ProjectCardProps {
  project: Project;
  onView?: (id: string) => void;
  onEdit?: (id: string) => void;
  onDelete?: (id: string) => void;
  className?: string;
}

export function ProjectCard({
  project,
  onView,
  onEdit,
  onDelete,
  className,
}: ProjectCardProps) {
  return (
    <Card className={className}>
      <CardHeader>
        <CardTitle>{project.name}</CardTitle>
        {project.description && (
          <CardDescription>{project.description}</CardDescription>
        )}
      </CardHeader>
      
      <CardFooter className="flex gap-2">
        {onView && (
          <Button variant="outline" onClick={() => onView(project.id)}>
            View
          </Button>
        )}
        {onEdit && (
          <Button variant="outline" onClick={() => onEdit(project.id)}>
            Edit
          </Button>
        )}
        {onDelete && (
          <Button variant="destructive" onClick={() => onDelete(project.id)}>
            Delete
          </Button>
        )}
      </CardFooter>
    </Card>
  );
}
```

---

## Component with State Management

```typescript
// File: apps/web/app/components/features/ProjectList.tsx
import { useState, useMemo } from 'react';
import { Input } from '~/components/ui/input';
import { Select } from '~/components/ui/select';
import { ProjectCard } from './ProjectCard';
import { useProjects } from '~/hooks/useProjects';
import { useNavigate } from 'react-router-dom';

type SortOption = 'name' | 'createdAt' | 'updatedAt';
type SortOrder = 'asc' | 'desc';

export function ProjectList() {
  // State
  const [search, setSearch] = useState('');
  const [sortBy, setSortBy] = useState<SortOption>('createdAt');
  const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
  
  // Hooks
  const navigate = useNavigate();
  const { data: projects, isLoading, error } = useProjects({
    search,
    sortBy,
    sortOrder,
  });
  
  // Derived state
  const projectCount = useMemo(() => {
    return projects?.items.length ?? 0;
  }, [projects]);
  
  // Event handlers
  const handleView = (id: string) => {
    navigate(`/projects/${id}`);
  };
  
  const handleEdit = (id: string) => {
    navigate(`/projects/${id}/edit`);
  };
  
  const handleDelete = async (id: string) => {
    if (!confirm('Are you sure you want to delete this project?')) {
      return;
    }
    // Delete logic handled by mutation hook
  };
  
  // Loading state
  if (isLoading) {
    return <div>Loading projects...</div>;
  }
  
  // Error state
  if (error) {
    return <div>Error loading projects: {error.message}</div>;
  }
  
  // Empty state
  if (projectCount === 0 && !search) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 mb-4">No projects yet</p>
        <Button onClick={() => navigate('/projects/new')}>
          Create your first project
        </Button>
      </div>
    );
  }
  
  // Main render
  return (
    <div className="space-y-6">
      {/* Filters */}
      <div className="flex gap-4">
        <Input
          placeholder="Search projects..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="flex-1"
        />
        
        <Select value={sortBy} onValueChange={setSortBy}>
          <option value="name">Name</option>
          <option value="createdAt">Created Date</option>
          <option value="updatedAt">Updated Date</option>
        </Select>
        
        <Select value={sortOrder} onValueChange={setSortOrder}>
          <option value="asc">Ascending</option>
          <option value="desc">Descending</option>
        </Select>
      </div>
      
      {/* Results count */}
      <p className="text-sm text-gray-600">
        Showing {projectCount} project{projectCount !== 1 ? 's' : ''}
      </p>
      
      {/* Project grid */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {projects?.items.map((project) => (
          <ProjectCard
            key={project.id}
            project={project}
            onView={handleView}
            onEdit={handleEdit}
            onDelete={handleDelete}
          />
        ))}
      </div>
      
      {/* Pagination (if needed) */}
      {projects?.hasMore && (
        <div className="text-center">
          <Button variant="outline" onClick={() => {/* Load more */}}>
            Load More
          </Button>
        </div>
      )}
    </div>
  );
}
```

---

## Form Component

```typescript
// File: apps/web/app/components/features/ProjectForm.tsx
import { useState } from 'react';
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 { Textarea } from '~/components/ui/textarea';
import { Label } from '~/components/ui/label';
import { useCreateProject, useUpdateProject } from '~/hooks/useProjects';
import type { Project } from '@izri/database/schema';

// Form validation schema
const projectFormSchema = z.object({
  name: z.string().min(1, 'Name is required').max(255),
  description: z.string().max(1000).optional(),
  githubUrl: z.string().url('Invalid URL').optional().or(z.literal('')),
});

type ProjectFormData = z.infer<typeof projectFormSchema>;

interface ProjectFormProps {
  project?: Project;
  onSuccess?: (project: Project) => void;
  onCancel?: () => void;
}

export function ProjectForm({ project, onSuccess, onCancel }: ProjectFormProps) {
  const isEditMode = !!project;
  
  // Form setup
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<ProjectFormData>({
    resolver: zodResolver(projectFormSchema),
    defaultValues: project ? {
      name: project.name,
      description: project.description || '',
      githubUrl: project.githubUrl || '',
    } : undefined,
  });
  
  // Mutations
  const createProject = useCreateProject();
  const updateProject = useUpdateProject();
  
  // Submit handler
  const onSubmit = async (data: ProjectFormData) => {
    try {
      if (isEditMode) {
        const updated = await updateProject.mutateAsync({
          id: project.id,
          data,
        });
        onSuccess?.(updated);
      } else {
        const created = await createProject.mutateAsync(data);
        onSuccess?.(created);
        reset(); // Reset form after successful creation
      }
    } catch (error) {
      // Error handling (show toast, etc.)
      console.error('Failed to save project:', error);
    }
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      {/* Name field */}
      <div className="space-y-2">
        <Label htmlFor="name">
          Project Name <span className="text-red-500">*</span>
        </Label>
        <Input
          id="name"
          {...register('name')}
          placeholder="My Awesome Project"
        />
        {errors.name && (
          <p className="text-sm text-red-500">{errors.name.message}</p>
        )}
      </div>
      
      {/* Description field */}
      <div className="space-y-2">
        <Label htmlFor="description">Description</Label>
        <Textarea
          id="description"
          {...register('description')}
          placeholder="Describe your project..."
          rows={4}
        />
        {errors.description && (
          <p className="text-sm text-red-500">{errors.description.message}</p>
        )}
      </div>
      
      {/* GitHub URL field */}
      <div className="space-y-2">
        <Label htmlFor="githubUrl">GitHub URL</Label>
        <Input
          id="githubUrl"
          {...register('githubUrl')}
          placeholder="https://github.com/username/repo"
          type="url"
        />
        {errors.githubUrl && (
          <p className="text-sm text-red-500">{errors.githubUrl.message}</p>
        )}
      </div>
      
      {/* Actions */}
      <div className="flex gap-3">
        <Button
          type="submit"
          disabled={isSubmitting}
        >
          {isSubmitting
            ? 'Saving...'
            : isEditMode
            ? 'Update Project'
            : 'Create Project'}
        </Button>
        
        {onCancel && (
          <Button type="button" variant="outline" onClick={onCancel}>
            Cancel
          </Button>
        )}
      </div>
    </form>
  );
}
```

---

## Modal/Dialog Component

```typescript
// File: apps/web/app/components/features/DeleteProjectDialog.tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '~/components/ui/dialog';
import { Button } from '~/components/ui/button';
import { useDeleteProject } from '~/hooks/useProjects';
import type { Project } from '@izri/database/schema';

interface DeleteProjectDialogProps {
  project: Project;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onSuccess?: () => void;
}

export function DeleteProjectDialog({
  project,
  open,
  onOpenChange,
  onSuccess,
}: DeleteProjectDialogProps) {
  const deleteProject = useDeleteProject();
  
  const handleDelete = async () => {
    try {
      await deleteProject.mutateAsync({ id: project.id });
      onOpenChange(false);
      onSuccess?.();
    } catch (error) {
      console.error('Failed to delete project:', error);
    }
  };
  
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Delete Project</DialogTitle>
          <DialogDescription>
            Are you sure you want to delete <strong>{project.name}</strong>?
            This action cannot be undone.
          </DialogDescription>
        </DialogHeader>
        
        <DialogFooter>
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            disabled={deleteProject.isPending}
          >
            Cancel
          </Button>
          <Button
            variant="destructive"
            onClick={handleDelete}
            disabled={deleteProject.isPending}
          >
            {deleteProject.isPending ? 'Deleting...' : 'Delete'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

// Usage example:
function ProjectCard({ project }: { project: Project }) {
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  
  return (
    <>
      <Card>
        {/* Card content */}
        <Button onClick={() => setDeleteDialogOpen(true)}>Delete</Button>
      </Card>
      
      <DeleteProjectDialog
        project={project}
        open={deleteDialogOpen}
        onOpenChange={setDeleteDialogOpen}
        onSuccess={() => {
          // Handle successful deletion
        }}
      />
    </>
  );
}
```

---

## Data Table Component

```typescript
// File: apps/web/app/components/features/ProjectsTable.tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '~/components/ui/table';
import { Button } from '~/components/ui/button';
import { MoreHorizontal, Pencil, Trash } from 'lucide-react';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu';
import { useProjects } from '~/hooks/useProjects';
import { formatDate } from '~/lib/utils';
import type { Project } from '@izri/database/schema';

export function ProjectsTable() {
  const { data: projects, isLoading } = useProjects();
  
  if (isLoading) {
    return <div>Loading...</div>;
  }
  
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Name</TableHead>
          <TableHead>Description</TableHead>
          <TableHead>Created</TableHead>
          <TableHead>Updated</TableHead>
          <TableHead className="w-[50px]"></TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {projects?.items.map((project) => (
          <TableRow key={project.id}>
            <TableCell className="font-medium">{project.name}</TableCell>
            <TableCell className="text-gray-500">
              {project.description || 'No description'}
            </TableCell>
            <TableCell>{formatDate(project.createdAt)}</TableCell>
            <TableCell>{formatDate(project.updatedAt)}</TableCell>
            <TableCell>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button variant="ghost" size="icon">
                    <MoreHorizontal className="h-4 w-4" />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem onClick={() => {/* View */}}>
                    View
                  </DropdownMenuItem>
                  <DropdownMenuItem onClick={() => {/* Edit */}}>
                    <Pencil className="mr-2 h-4 w-4" />
                    Edit
                  </DropdownMenuItem>
                  <DropdownMenuItem
                    onClick={() => {/* Delete */}}
                    className="text-red-600"
                  >
                    <Trash className="mr-2 h-4 w-4" />
                    Delete
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

---

## Loading States

```typescript
// File: apps/web/app/components/features/ProjectListWithLoading.tsx
import { Skeleton } from '~/components/ui/skeleton';
import { useProjects } from '~/hooks/useProjects';
import { ProjectCard } from './ProjectCard';

export function ProjectListWithLoading() {
  const { data: projects, isLoading, error } = useProjects();
  
  // Loading skeleton
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-6 space-y-4">
            <Skeleton className="h-6 w-3/4" />
            <Skeleton className="h-4 w-full" />
            <Skeleton className="h-4 w-5/6" />
            <div className="flex gap-2 pt-2">
              <Skeleton className="h-9 w-20" />
              <Skeleton className="h-9 w-20" />
            </div>
          </div>
        ))}
      </div>
    );
  }
  
  // Error state
  if (error) {
    return (
      <div className="text-center py-12">
        <p className="text-red-500 mb-4">Failed to load projects</p>
        <Button onClick={() => window.location.reload()}>
          Try Again
        </Button>
      </div>
    );
  }
  
  // Empty state
  if (projects?.items.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 mb-4">No projects found</p>
        <Button onClick={() => {/* Navigate to create */}}>
          Create Project
        </Button>
      </div>
    );
  }
  
  // Success state
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {projects.items.map((project) => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}
```

---

## Optimistic Updates

```typescript
// File: apps/web/app/components/features/ProjectCardWithOptimistic.tsx
import { useState } from 'react';
import { Button } from '~/components/ui/button';
import { trpc } from '~/lib/trpc';
import type { Project } from '@izri/database/schema';

export function ProjectCardWithOptimistic({ project }: { project: Project }) {
  const [isStarred, setIsStarred] = useState(project.isStarred);
  const utils = trpc.useUtils();
  
  const starMutation = trpc.projects.toggleStar.useMutation({
    // Optimistic update
    onMutate: async () => {
      // Cancel outgoing refetches
      await utils.projects.getById.cancel({ id: project.id });
      
      // Snapshot previous value
      const previousProject = utils.projects.getById.getData({ id: project.id });
      
      // Optimistically update
      setIsStarred(!isStarred);
      
      // Return context with previous value
      return { previousProject };
    },
    
    // Rollback on error
    onError: (err, variables, context) => {
      setIsStarred(context?.previousProject?.isStarred ?? isStarred);
    },
    
    // Always refetch after error or success
    onSettled: () => {
      utils.projects.getById.invalidate({ id: project.id });
    },
  });
  
  return (
    <div>
      <h3>{project.name}</h3>
      <Button
        variant="ghost"
        onClick={() => starMutation.mutate({ id: project.id })}
      >
        {isStarred ? '⭐' : '☆'}
      </Button>
    </div>
  );
}
```

---

## Compound Component Pattern

```typescript
// File: apps/web/app/components/features/ProjectCard/index.tsx
import { createContext, useContext } from 'react';
import { Card } from '~/components/ui/card';
import type { Project } from '@izri/database/schema';

// Context
const ProjectCardContext = createContext<Project | null>(null);

function useProjectCardContext() {
  const context = useContext(ProjectCardContext);
  if (!context) {
    throw new Error('ProjectCard compound components must be used within ProjectCard');
  }
  return context;
}

// Main component
export function ProjectCard({
  project,
  children,
}: {
  project: Project;
  children: React.ReactNode;
}) {
  return (
    <ProjectCardContext.Provider value={project}>
      <Card>{children}</Card>
    </ProjectCardContext.Provider>
  );
}

// Sub-components
ProjectCard.Header = function ProjectCardHeader() {
  const project = useProjectCardContext();
  return (
    <div className="p-4 border-b">
      <h3 className="text-lg font-semibold">{project.name}</h3>
    </div>
  );
};

ProjectCard.Description = function ProjectCardDescription() {
  const project = useProjectCardContext();
  if (!project.description) return null;
  return <p className="p-4 text-gray-600">{project.description}</p>;
};

ProjectCard.Footer = function ProjectCardFooter({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="p-4 border-t flex gap-2">{children}</div>;
};

// Usage:
function ProjectList() {
  const { data: projects } = useProjects();
  
  return (
    <>
      {projects?.items.map((project) => (
        <ProjectCard key={project.id} project={project}>
          <ProjectCard.Header />
          <ProjectCard.Description />
          <ProjectCard.Footer>
            <Button>View</Button>
            <Button>Edit</Button>
          </ProjectCard.Footer>
        </ProjectCard>
      ))}
    </>
  );
}
```

---

## Testing Components

```typescript
// File: apps/web/app/components/features/__tests__/ProjectCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ProjectCard } from '../ProjectCard';

const mockProject = {
  id: '1',
  name: 'Test Project',
  description: 'Test description',
  userId: 'user-1',
  createdAt: new Date(),
  updatedAt: new Date(),
};

describe('ProjectCard', () => {
  it('should render project name', () => {
    render(<ProjectCard project={mockProject} />);
    expect(screen.getByText('Test Project')).toBeInTheDocument();
  });
  
  it('should render project description', () => {
    render(<ProjectCard project={mockProject} />);
    expect(screen.getByText('Test description')).toBeInTheDocument();
  });
  
  it('should call onView when view button is clicked', () => {
    const onView = vi.fn();
    render(<ProjectCard project={mockProject} onView={onView} />);
    
    fireEvent.click(screen.getByText('View'));
    
    expect(onView).toHaveBeenCalledWith('1');
  });
  
  it('should not render action buttons when handlers are not provided', () => {
    render(<ProjectCard project={mockProject} />);
    
    expect(screen.queryByText('View')).not.toBeInTheDocument();
    expect(screen.queryByText('Edit')).not.toBeInTheDocument();
  });
});
```

---

## Best Practices Summary

### ✅ Do

1. **Type props explicitly** with interfaces
2. **Use proper event handlers** (onClick, onChange)
3. **Handle loading states** (show skeletons)
4. **Handle error states** (show error messages)
5. **Handle empty states** (show empty messages)
6. **Use proper form validation** (react-hook-form + zod)
7. **Extract reusable logic** into custom hooks
8. **Use composition** for complex components
9. **Add accessibility attributes** (aria-label, etc.)
10. **Test component behavior** not implementation

### ❌ Don't

1. **Don't use inline styles** (use Tailwind classes)
2. **Don't forget key props** in lists
3. **Don't mutate props** directly
4. **Don't use index as key** in dynamic lists
5. **Don't create unnecessary state**
6. **Don't forget to cleanup** in useEffect
7. **Don't ignore TypeScript errors**

---

## Component Checklist

- [ ] Props interface defined
- [ ] Loading state handled
- [ ] Error state handled
- [ ] Empty state handled
- [ ] Proper TypeScript types
- [ ] Event handlers implemented
- [ ] Accessibility attributes added
- [ ] Responsive design considered
- [ ] Tests written
- [ ] Documentation added

---

**Version**: 1.0.0  
**Last Updated**: 2025-01-10