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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
- Type props explicitly with interfaces
- Use proper event handlers (onClick, onChange)
- Handle loading states (show skeletons)
- Handle error states (show error messages)
- Handle empty states (show empty messages)
- Use proper form validation (react-hook-form + zod)
- Extract reusable logic into custom hooks
- Use composition for complex components
- Add accessibility attributes (aria-label, etc.)
- Test component behavior not implementation
❌ Don't
- Don't use inline styles (use Tailwind classes)
- Don't forget key props in lists
- Don't mutate props directly
- Don't use index as key in dynamic lists
- Don't create unnecessary state
- Don't forget to cleanup in useEffect
- 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