Component Development Guide š§©
Build beautiful, accessible React components for CrittrHavens. A comprehensive guide for creating and maintaining UI components.

Component Architecture
How we build modular, reusable UI in CrittrHavens.
Component Philosophy
Core Principles:
- Single Responsibility: Each component does one thing well
- Composition over Inheritance: Build complex UIs from simple parts
- Props over State: Prefer controlled components
- Accessibility First: WCAG compliant from the start
- Mobile Responsive: Works on all screen sizes
Component Categories
Component Types:
components/
āāā ui/ # Base primitives (Button, Input, Card)
āāā care/ # Care-specific (CareLogForm, FeedingCard)
āāā inventory/ # Inventory features (StockAlert, ItemCard)
āāā reports/ # Reporting components (VetReport, Chart)
āāā tasks/ # Task management (TaskCard, ScheduleView)
Component Structure
Anatomy of a well-structured component.
Basic Component Template
Standard Structure:
// CrittrCard.tsx
import { FC } from 'react';
import { cn } from '@/lib/utils';
import { Card } from '@/components/ui/card';
interface CrittrCardProps {
id: string;
name: string;
species: string;
imageUrl?: string;
onClick?: () => void;
className?: string;
}
export const CrittrCard: FC<CrittrCardProps> = ({
name,
species,
imageUrl,
onClick,
className
}) => {
return (
<Card
className={cn(
"p-4 cursor-pointer hover:shadow-lg transition-shadow",
className
)}
onClick={onClick}
>
{imageUrl && (
<img
src={imageUrl}
alt={`Photo of ${name}`}
className="w-full h-48 object-cover rounded-md"
/>
)}
<h3 className="mt-2 font-semibold">{name}</h3>
<p className="text-sm text-muted-foreground">{species}</p>
</Card>
);
};
File Organization
Component Files:
components/care/
āāā CareLogForm.tsx # Main component
āāā CareLogForm.test.tsx # Unit tests
āāā CareLogForm.stories.tsx # Storybook stories
āāā care-log.types.ts # TypeScript types
āāā index.ts # Public exports
TypeScript Interfaces
Type-safe component development.
Defining Props
Interface Best Practices:
// ā
Good: Clear, specific types
interface HavenCardProps {
haven: Haven;
crittrs: Crittr[];
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
isLoading?: boolean;
className?: string;
}
// ā Bad: Too generic
interface CardProps {
data: any;
onClick: Function;
}
Extending Native Elements
HTML Element Props:
// Extend native button props
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
variant?: 'default' | 'destructive' | 'outline';
}
// Extend with omit for control
interface CustomInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label: string;
error?: string;
type: 'text' | 'email' | 'password'; // Restrict types
}
Generic Components
Flexible Types:
// Generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
if (items.length === 0) {
return <div>{emptyMessage || 'No items'};
}
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
Using shadcn/ui
Leveraging our component library.
shadcn/ui Integration
Available Components:
- Form controls (Input, Select, Checkbox)
- Layout (Card, Dialog, Sheet)
- Navigation (Tabs, Menu)
- Feedback (Toast, Alert)
- Data display (Table, Badge)
Customizing shadcn Components
Extending Base Components:
// Custom button variant
import { Button } from '@/components/ui/button';
export const PrimaryButton = ({ children, ...props }) => (
<Button
variant="default"
className="bg-primary hover:bg-primary/90"
{...props}
>
{children}
</Button>
);
Form Components
React Hook Form Integration:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
species: z.string().min(1, 'Species is required'),
});
export function CrittrForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: { name: '', species: '' }
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<Input {...field} />
</FormItem>
)}
/>
</form>
</Form>
);
}
Custom Hooks
Encapsulating component logic.
Creating Custom Hooks
Hook Patterns:
// Data fetching hook
export function useCrittrs(havenId: string) {
return useQuery({
queryKey: ['crittrs', havenId],
queryFn: () => fetchCrittrs(havenId),
enabled: !!havenId,
});
}
// Form state hook
export function useCrittrForm(crittr?: Crittr) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
defaultValues: crittr || { name: '', species: '' }
});
const handleSubmit = async (data: CrittrFormData) => {
setIsSubmitting(true);
try {
await saveCrittr(data);
} finally {
setIsSubmitting(false);
}
};
return { form, handleSubmit, isSubmitting };
}
Hook Composition
Combining Hooks:
// Composed hook for complete feature
export function useCrittrManagement(havenId: string) {
const { data: crittrs, isLoading } = useCrittrs(havenId);
const createMutation = useCreateCrittr();
const deleteMutation = useDeleteCrittr();
return {
crittrs,
isLoading,
createCrittr: createMutation.mutate,
deleteCrittr: deleteMutation.mutate,
isCreating: createMutation.isLoading,
isDeleting: deleteMutation.isLoading,
};
}
Component Testing
Ensuring reliability and correctness.
Unit Testing Setup
Basic Component Test:
import { render, screen, fireEvent } from '@testing-library/react';
import { CrittrCard } from './CrittrCard';
describe('CrittrCard', () => {
it('renders crittr information', () => {
render(
<CrittrCard
name="Sunny"
species="Ball Python"
/>
);
expect(screen.getByText('Sunny')).toBeInTheDocument();
expect(screen.getByText('Ball Python')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(
<CrittrCard
name="Sunny"
species="Ball Python"
onClick={handleClick}
/>
);
fireEvent.click(screen.getByText('Sunny'));
expect(handleClick).toHaveBeenCalled();
});
});
Testing Async Components
Async Behavior:
import { waitFor } from '@testing-library/react';
it('loads and displays data', async () => {
render(<CrittrList havenId="123" />);
// Wait for loading to finish
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Check data is displayed
expect(screen.getByText('Sunny')).toBeInTheDocument();
});
Performance Optimization
Building fast, efficient components.
Memoization Strategies
When to Memoize:
// Memoize expensive computations
const expensiveValue = useMemo(() => {
return calculateComplexMetrics(data);
}, [data]);
// Memoize callbacks passed to children
const handleClick = useCallback((id: string) => {
router.push(`/crittrs/${id}`);
}, [router]);
// Memoize components with stable props
const MemoizedCard = memo(CrittrCard, (prev, next) => {
return prev.id === next.id && prev.name === next.name;
});
Code Splitting
Lazy Loading:
// Lazy load heavy components
const VetReport = lazy(() => import('./VetReport'));
// Use with Suspense
<Suspense fallback={<Skeleton />}>
<VetReport crittrId={id} />
</Suspense>
Virtual Scrolling
Large Lists:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualCrittrList({ crittrs }) {
const virtualizer = useVirtualizer({
count: crittrs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
});
return (
<div ref={parentRef} className="h-[400px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<CrittrCard
key={crittrs[virtualItem.index].id}
crittr={crittrs[virtualItem.index]}
style={{
transform: `translateY(${virtualItem.start}px)`,
}}
/>
))}
);
}
Accessibility Requirements
Building for all users.
ARIA Attributes
Semantic Markup:
// Accessible form control
<label htmlFor="species-select">
Species
<span className="sr-only">(required)</span>
</label>
<select
id="species-select"
aria-required="true"
aria-invalid={!!errors.species}
aria-describedby={errors.species ? "species-error" : undefined}
>
<option value="">Select species</option>
{/* options */}
</select>
{errors.species && (
<span id="species-error" role="alert" className="text-red-500">
{errors.species}
</span>
)}
Keyboard Navigation
Focus Management:
// Trap focus in modal
useEffect(() => {
if (isOpen) {
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
}, [isOpen]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
Mobile-Responsive Design
Components that work everywhere.
Responsive Patterns
Mobile-First CSS:
// Tailwind responsive classes
<div className="
grid grid-cols-1 // Mobile: 1 column
sm:grid-cols-2 // Small: 2 columns
lg:grid-cols-3 // Large: 3 columns
gap-4
">
{items.map(item => <Card key={item.id} />)}
Touch Interactions
Touch-Friendly UI:
// Larger touch targets for mobile
<Button
className="min-h-[44px] min-w-[44px]" // iOS touch target size
onClick={handleClick}
>
<Icon className="h-5 w-5" />
<span className="sr-only">Delete</span>
</Button>
// Swipe gestures
const [touchStart, setTouchStart] = useState(0);
const handleTouchStart = (e: TouchEvent) => {
setTouchStart(e.touches[0].clientX);
};
const handleTouchEnd = (e: TouchEvent) => {
const touchEnd = e.changedTouches[0].clientX;
if (touchStart - touchEnd > 50) {
// Swiped left
handleDelete();
}
};
Component Documentation
Documenting for other developers.
JSDoc Comments
Component Documentation:
/**
* Display card for individual reptile/amphibian
*
* @component
* @example
* <CrittrCard
* name="Sunny"
* species="Ball Python"
* imageUrl="/sunny.jpg"
* onClick={() => console.log('clicked')}
* />
*/
export const CrittrCard: FC<CrittrCardProps> = ({ ... }) => {
Storybook Stories
Visual Documentation:
// CrittrCard.stories.tsx
export default {
title: 'Components/CrittrCard',
component: CrittrCard,
argTypes: {
onClick: { action: 'clicked' },
},
};
export const Default = {
args: {
name: 'Sunny',
species: 'Ball Python',
},
};
export const WithImage = {
args: {
...Default.args,
imageUrl: '/sample-snake.jpg',
},
};
export const Loading = {
args: {
...Default.args,
isLoading: true,
},
};
Best Practices
Component development guidelines.
Do's and Don'ts
ā DO:
- Keep components small and focused
- Use TypeScript for all components
- Add proper ARIA labels
- Test edge cases
- Document complex logic
ā DON'T:
- Put business logic in components
- Use inline styles
- Ignore accessibility
- Skip error boundaries
- Forget mobile testing
Component Checklist
Before marking component complete:
- TypeScript interfaces defined
- Props documented with JSDoc
- Unit tests written
- Accessibility tested
- Mobile responsive
- Error states handled
- Loading states shown
- Storybook story created
Common Patterns
Reusable solutions for common needs.
Loading States
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
Error Handling
if (error) {
return (
<Alert variant="destructive">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
Empty States
if (!data || data.length === 0) {
return (
<EmptyState
icon={<InboxIcon />}
title="No crittrs yet"
description="Add your first reptile to get started"
action={<Button onClick={onAdd}>Add Crittr</Button>}
/>
);
}
Habitat Visual System
4-Layer compositing system for rich habitat visualizations.
Layer Architecture
Visual Layer Structure:
// src/integrations/supabase/types.ts
export interface HabitatVisualLayers {
habitat: string; // H layer (01-99)
background: string; // BG layer (01-99)
foreground: string; // FG layer (01-99)
glassReflection: string; // GR layer (01-99)
shape: HabitatShape; // Wide | ExtraWide | Square | Tall
}
Image Path Generation
Helper Functions:
// src/lib/habitat-visual-helpers.ts
import { HabitatVisualLayers } from '@/integrations/supabase/types';
// Generate path for specific layer
export function getLayerImagePath(
type: 'H' | 'BG' | 'FG' | 'GR',
number: string,
shape: HabitatShape
): string {
return `/HabitatImages/Layers/${type}_${number}_${shape}.png`;
}
// Get all paths for a habitat configuration
export function getHabitatImagePaths(visual: HabitatVisualLayers) {
return {
background: getLayerImagePath('BG', visual.background, visual.shape),
habitat: getLayerImagePath('H', visual.habitat, visual.shape),
foreground: getLayerImagePath('FG', visual.foreground, visual.shape),
glassReflection: getLayerImagePath('GR', visual.glassReflection, visual.shape),
};
}
Rendering Layered Images
Stack Component Example:
// HabitatVisualDisplay.tsx
export function HabitatVisualDisplay({ visual }: { visual: HabitatVisualLayers }) {
const paths = getHabitatImagePaths(visual);
return (
<div className="relative w-full h-full">
{/* Background Layer */}
<img
src={paths.background}
className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: 0 }}
/>
{/* Habitat Layer */}
<img
src={paths.habitat}
className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: 1 }}
/>
{/* Foreground Layer */}
<img
src={paths.foreground}
className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: 2 }}
/>
{/* Glass Reflection Layer */}
<img
src={paths.glassReflection}
className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: 3 }}
/>
);
}
Backwards Compatibility
Legacy Visual Support:
// Handle both old string format and new layered format
export function getHabitatVisual(visual: any): HabitatVisualLayers {
if (isLayeredVisual(visual)) {
return visual;
}
if (isLegacyVisual(visual)) {
return convertLegacyVisual(visual);
}
return DEFAULT_HABITAT_VISUAL;
}
// Default configuration
export const DEFAULT_HABITAT_VISUAL: HabitatVisualLayers = {
habitat: '01',
background: '01',
foreground: '01',
glassReflection: '01',
shape: 'Wide'
};
Performance Considerations
Image Optimization:
- Lazy Loading: Use intersection observer for off-screen images
- Preloading: Preload adjacent options in character editor
- Caching: Browser caching with proper headers
- Progressive Loading: Start with low-res placeholder
- WebP Format: Consider WebP for better compression
Component Optimization:
// Optimized with React.memo and lazy loading
const HabitatVisual = React.memo(({ visual }) => {
const [loaded, setLoaded] = useState(false);
const containerRef = useRef(null);
const isVisible = useIntersectionObserver(containerRef);
return (
<div ref={containerRef}>
{isVisible && <HabitatVisualDisplay visual={visual} />}
);
});
Next Steps
Continue learning component development.
Creating delightful UI components that scale with your reptile collection. š¦