Component Development Guide 🧩

Build beautiful, accessible React components for CrittrHavens. A comprehensive guide for creating and maintaining UI components.

Person coding on a laptop with HTML code on screen, showcasing development work

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. šŸ¦Ž