Docs /ai-agents/examples/react-component

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

  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

Reading this with an agent? /docs/ai-agents/examples/react-component.md serves the raw markdown.

All docs