Purpose: Prescriptive rules and principles for creating React components in the Metarr frontend. Defines when, where, and how to build components that are maintainable, reusable, and follow atomic design principles.
Related Docs:
- COMPONENT_REFERENCE.md - Component organization and patterns
- ARCHITECTURE.md - Overall frontend architecture
- STYLING_GUIDE.md - Design system and styling
- STATE_MANAGEMENT.md - TanStack Query patterns
- /CLAUDE.md - Coding standards and TypeScript rules
- Create components when: Used 2+ times OR >200 lines OR repeating JSX patterns
- File structure: Directory per component with
index.tsx,types.ts(shared), colocate tests - Naming: PascalCase matching file name, descriptive (not generic)
- Props: Interface per component, no
anytypes, specific over generic - State management:
useStatefor UI-only, TanStack Query hooks for server data - Composition: Build from atoms → molecules → organisms, extract early
- Imports: Use absolute paths (
@/components/...), top-level only
Extract to component when ANY of these apply:
-
2+ usage rule: Used in two or more places
- Avoid DRY violations
- Small extraction is cheaper than duplication
- Example:
MovieCardused on dashboard and in search results
-
Size threshold: Component file exceeds 200 lines
- 200 lines is practical max for comprehension
- Easier to test and reason about
- Clear single responsibility
-
Repeating patterns: JSX pattern repeated 2+ times within a component
map()render patterns- Conditional branches with identical structure
- Form field sequences
- Example: Extract mapped list items into card component
-
Testing complexity: Component is difficult to test as-is
- Simpler components are easier to unit test
- Extraction improves test clarity
-
Reusable logic: Contains logic useful in other contexts
- Custom state management
- Complex conditional rendering
- Domain-specific formatting
-
Single-use components (even if >200 lines): Extraction creates indirection without benefit
- Exception: If likely to be used elsewhere soon, extract proactively
-
Simple conditionals (if/else with different JSX): Inline is clearer than component
- Use early returns in render
-
Trivial wrappers: Single element with styling (use CSS or utility classes instead)
- Exception: If wrapping provides semantic meaning or repeated props
┌─────────────────────────────────┐
│ Can this be a CSS class/utility? │
└──────────┬──────────────────────┘
│ NO
▼
┌─────────────────────────────────┐
│ Used 2+ times or >200 lines? │
└──────────┬──────────────────────┘
│ YES
▼
┌─────────────────────────────────┐
│ CREATE COMPONENT │
└─────────────────────────────────┘
│ NO
▼
┌─────────────────────────────────┐
│ Repeating JSX pattern 2+ times? │
└──────────┬──────────────────────┘
│ YES
▼
┌─────────────────────────────────┐
│ CREATE COMPONENT │
└─────────────────────────────────┘
│ NO
▼
┌─────────────────────────────────┐
│ Keep inline in parent │
└─────────────────────────────────┘
components/
├── ui/ # Atomic UI primitives (no domain knowledge)
│ ├── Button/
│ │ ├── index.tsx
│ │ ├── types.ts
│ │ ├── Button.test.tsx
│ │ └── README.md (optional)
│ ├── Card/
│ │ ├── index.tsx
│ │ └── Card.test.tsx
│ └── [other atoms]
│
├── layout/ # App structure
│ ├── Layout/
│ │ ├── index.tsx
│ │ ├── Layout.test.tsx
│ │ └── README.md
│ └── Sidebar/
│ └── index.tsx
│
├── movie/ # Movie domain (molecules & organisms)
│ ├── MovieCard/
│ │ ├── index.tsx
│ │ ├── types.ts
│ │ ├── MovieCard.test.tsx
│ │ └── README.md
│ ├── VirtualizedMovieTable/
│ │ ├── index.tsx
│ │ ├── types.ts
│ │ ├── helpers.ts
│ │ └── VirtualizedMovieTable.test.tsx
│ ├── MetadataTab/
│ │ ├── index.tsx
│ │ └── MetadataTab.test.tsx
│ └── types.ts # Shared movie component types
│
├── library/ # Library domain
│ ├── LibraryCard/
│ │ ├── index.tsx
│ │ └── LibraryCard.test.tsx
│ └── types.ts
│
└── provider/ # Provider domain
├── ProviderCard/
│ ├── index.tsx
│ └── ProviderCard.test.tsx
└── types.ts
Component file:
- File name matches exported component name
- PascalCase:
MovieCard.tsxexportsMovieCard - Exception:
index.tsxre-exports from same directory
Type files:
- Shared types for a feature domain:
types.ts - Example:
components/movie/types.tscontainsMovieCardProps,MovieRowProps - Single component types: inline in component file OR separate
types.ts - Preference: Keep simple props inline, extract complex/shared types
Test files:
- Colocate next to component:
MovieCard.test.tsx - One test file per component file
- Run with
npm test
Helper files (optional):
- For complex components with utility functions
- Example:
VirtualizedMovieTable/helpers.tsfor rendering helpers - Keep helpers small and focused
README files (optional):
- For complex components with special usage patterns
- Document edge cases, performance considerations
- Example:
VirtualizedMovieTable/README.md
Prefer index.tsx for main export:
// components/MovieCard/index.tsx
export { MovieCard } from './MovieCard.js';
export type { MovieCardProps } from './types.js';Avoid nested component files in the same directory:
- Use separate directories if multiple components
- Exception: Tightly coupled sub-components (rare)
Keep related files together:
- Component + types + tests in same directory
- Related assets (icons, images) in component directory
- Separates concerns by feature, not type
- Easier to find everything related to one feature
Bad (scattered):
components/
├── MovieCard.tsx
├── MovieCard.test.tsx
├── types/MovieCardProps.ts
└── hooks/useMovieCard.ts
Good (colocated):
components/movie/
└── MovieCard/
├── index.tsx
├── types.ts
├── MovieCard.test.tsx
└── helpers.ts
Rule: File name and export name must match, PascalCase, descriptive
Good:
MovieCard- what is itAssetSelectionDialog- descriptive, includes typeEnrichmentHealthBadge- clear purposeVirtualizedMovieTable- clear implementation detailConnectionBadge- specific, not generic
Bad:
Card- too generic (use for atoms only)MovieComponent- redundant "Component"MC- abbreviation hides purposeDialog- too generic (use for atoms only)Item- meaningless
Descriptive names include:
- What it displays or does
- Domain it belongs to (if not obvious from folder)
- Component type only when helpful (Dialog, Modal, Card)
| Type | Pattern | Example |
|---|---|---|
| Lists/Tables | [Domain]List, [Domain]Table |
MovieList, VirtualizedMovieTable |
| Cards | [Domain]Card |
MovieCard, ProviderCard |
| Dialogs/Modals | [Domain][Action]Dialog, [Domain][Action]Modal |
AssetSelectionDialog, LibraryConfigModal |
| Badges/Status | [Descriptor]Badge, [Status]Status |
ConnectionBadge, EnrichmentHealthBadge |
| Forms | [Domain]Form, [Domain]Config |
LibraryConfigModal, ProviderConfig |
| Sections/Tabs | [Domain][Section] |
MetadataTab, EnrichmentStatusSection |
| UI Primitives | Simple name (atoms only) | Button, Card, Dialog |
Convention: [ComponentName]Props
interface MovieCardProps {
movie: Movie;
onEdit?: (id: number) => void;
}
interface AssetSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}Exception: If component exported as default or unusual pattern, document
Props: on[Event] for callback props
Implementation: handle[Event] for internal handlers
interface MovieCardProps {
onEdit?: (id: number) => void; // Callback prop
onDelete?: (id: number) => void;
}
export const MovieCard: React.FC<MovieCardProps> = ({ onEdit, onDelete }) => {
const handleEdit = () => onEdit?.(movie.id); // Internal handler
const handleDelete = () => onDelete?.(movie.id);
};Event naming: Clear and specific
// Good
onMovieSelect, onLibraryScan, onProviderTest, onValueChange
// Bad
onClick, onHandle, onEvent, onDataRule: Use functional components with hooks, never class components
// ✅ Correct
export const MovieCard: React.FC<MovieCardProps> = ({ movie }) => {
const [isHovered, setIsHovered] = useState(false);
return <div>{movie.title}</div>;
};
// ❌ Wrong
export class MovieCard extends React.Component<MovieCardProps> {
// ...
}import React, { useState, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { MovieCardProps } from './types.js';
import { Card } from '@/components/ui/card/index.js';
// Type definitions (if not in types.ts)
// Shared constants
// Sub-components (if any)
// Main component
// Export
/**
* MovieCard displays a single movie with metadata and actions.
* Used on dashboard and search results.
*/
export const MovieCard: React.FC<MovieCardProps> = ({
movie,
onEdit,
onDelete
}) => {
// State management
const [isHovered, setIsHovered] = useState(false);
// Data fetching
const { data: enrichmentStatus } = useMovieEnrichmentStatus(movie.id);
// Memoized callbacks
const handleEdit = useCallback(() => {
onEdit?.(movie.id);
}, [movie.id, onEdit]);
// Complex logic extracted to helpers
const displayTitle = getMovieDisplayTitle(movie);
// Render
return (
<Card
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<h3>{displayTitle}</h3>
{isHovered && <Button onClick={handleEdit}>Edit</Button>}
</Card>
);
};Standard React hooks order (required by ESLint):
- State hooks (
useState) - Effect hooks (
useEffect,useLayoutEffect) - Context hooks (
useContext) - Data hooks (
useQuery,useMutation) - Custom hooks
- Memo hooks (
useMemo,useCallback)
export const Component: React.FC<Props> = (props) => {
// 1. State
const [open, setOpen] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
// 2. Effects
useEffect(() => {
// ...
}, []);
// 3. Context
const { theme } = useContext(ThemeContext);
// 4. Data fetching
const { data: movies } = useMovies();
const selectMutation = useSelectMovie();
// 5. Custom hooks
const moviePath = useMoviePath(selectedId);
// 6. Memoization (use sparingly)
const sortedMovies = useMemo(() =>
movies?.sort() ?? [],
[movies]
);
return <div>{/* ... */}</div>;
};Props should be passed as single object:
// ✅ Good: Props object
<MovieCard movie={movie} onEdit={handleEdit} onDelete={handleDelete} />
// ✅ Good: Spreading for forwarding
<MovieCard {...cardProps} />
// ❌ Bad: Spreading props randomly
<MovieCard {...movie} onEdit={handleEdit} />Typing props:
- Create
Propsinterface - Use
React.FC<Props>for type - Never use
React.FCwithout generic
interface MovieCardProps {
movie: Movie;
onEdit?: (id: number) => void;
}
export const MovieCard: React.FC<MovieCardProps> = ({ movie, onEdit }) => {
// ...
};JSDoc for components:
/**
* Displays a single movie card with optional edit/delete actions.
*
* @example
* <MovieCard movie={movie} onEdit={handleEdit} />
*/
export const MovieCard: React.FC<MovieCardProps> = ({ movie, onEdit }) => {
// ...
};Comment complex logic only:
// ✅ Explains why, not what
const getProgressPercentage = () => {
// Avoid division by zero when no items scanned
if (!scanProgress || scanProgress.total === 0) return 0;
return Math.round((scanProgress.current / scanProgress.total) * 100);
};
// ❌ Obvious from code
const getProgressPercentage = () => {
// Calculate percentage
return Math.round((scanProgress.current / scanProgress.total) * 100);
};Document unusual patterns:
// Prevent layout shift by rendering invisible progress bar
// even when not scanning (no content below jumps during transition)
<div className={`${isScanning ? 'opacity-100' : 'opacity-0 invisible'}`}>
{/* Progress bar */}
</div>Rule: Every component has explicit Props interface, no any types
Good practices:
// ✅ Specific types, documented
interface MovieCardProps {
/** Movie entity to display */
movie: Movie;
/** Optional callback when edit is clicked */
onEdit?: (id: number) => void;
/** Show extended details (trailer, reviews) */
extended?: boolean;
/** CSS class for custom styling */
className?: string;
}
// ❌ Generic, unclear, uses any
interface CardProps {
data: any;
onClick: Function;
mode: string;
}Prefer specific types over generic:
// ✅ Specific
interface LoadingProps {
isLoading: boolean; // Boolean, not string
error?: Error; // Error object, not string
}
interface ListProps<T> {
items: T[]; // Generic array
renderItem: (item: T) => React.ReactNode; // Specific render function
}
// ❌ Generic/vague
interface LoadingProps {
state: string; // "loading" | "error" | "success"? Unclear
message?: any; // What type?
}Rule: Make props optional only when truly optional
// ✅ Good: Required when needed
interface MovieCardProps {
movie: Movie; // Required
onEdit?: () => void; // Optional, has reasonable default (none)
}
// ❌ Bad: Unnecessary optionals
interface MovieCardProps {
movie?: Movie; // When would this be undefined? Use required
onEdit?: () => void;
onClick?: () => void; // Too many optionals
}Never use any in new code (even for external props):
// ✅ Good: Explicit typing
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
interface IconButtonProps {
icon: IconDefinition; // Specific type
onClick: () => void;
}
// ❌ Bad
interface IconButtonProps {
icon: any; // What type? What's the shape?
}If type is unknown: Use generic unknown with type guard
// ✅ When data type is truly dynamic
export const DataDisplay: React.FC<{ data: unknown }> = ({ data }) => {
if (typeof data === 'string') return <div>{data}</div>;
if (Array.isArray(data)) return <ul>{/* ... */}</ul>;
return <div>Unknown data type</div>;
};Use ReactNode for flexible content:
// ✅ Flexible
interface CardProps {
children: React.ReactNode;
title?: string;
}
// When you need specific children structure
interface TabsProps {
tabs: Array<{
label: string;
content: React.ReactNode;
}>;
}
// ❌ Avoid string-only
interface AlertProps {
message: string; // Limits to text, no bold/links
}Rule:
- Simple props (1-3 fields): inline in component file
- Complex props (3+ fields) or shared:
types.tsin component directory - Domain-wide types:
types.tsin domain folder
// ✅ Simple: Inline
// components/ui/Button/index.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
}
// ✅ Complex or shared: types.ts
// components/movie/MovieCard/types.ts
export interface MovieCardProps { ... }
export interface MovieRowProps { ... }
// ✅ Domain-wide: domain types.ts
// components/movie/types.ts
export interface MovieListItem { ... }
export interface MovieDetailView { ... }Function signature patterns:
// ✅ Callback handlers
interface FormProps {
onSubmit: (data: FormData) => void; // Sync
onSubmit: (data: FormData) => Promise<void>; // Async
}
// ✅ Event handlers
interface InputProps {
onChange: (value: string) => void;
onFocus: (event: React.FocusEvent<HTMLInputElement>) => void;
}
// ❌ Generic Function type
interface Props {
onClick: Function; // Unclear what it takes/returns
}Children pattern (most flexible):
interface CardProps {
children: React.ReactNode;
className?: string;
}
// Usage
<Card>
<h3>Title</h3>
<p>Content</p>
</Card>Render props pattern (for complex logic):
interface DataListProps<T> {
data: T[];
renderItem: (item: T, index: number) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
}
export const DataList = <T,>({ data, renderItem, renderEmpty }: DataListProps<T>) => {
return data.length === 0
? renderEmpty?.() ?? null
: <ul>{data.map((item, i) => <li key={i}>{renderItem(item, i)}</li>)}</ul>;
};
// Usage
<DataList
data={movies}
renderItem={(movie) => <MovieCard movie={movie} />}
renderEmpty={() => <EmptyState />}
/>Compound components (for complex interactions):
// Context-based API
const TabsContext = createContext<TabsContextValue | null>(null);
export const Tabs: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [active, setActive] = useState(0);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
};
export const TabTrigger: React.FC<{ value: number }> = ({ value }) => {
const ctx = useContext(TabsContext)!;
return <button onClick={() => ctx.setActive(value)}>Tab</button>;
};
// Usage
<Tabs>
<TabTrigger value={0}>Tab 1</TabTrigger>
<TabTrigger value={1}>Tab 2</TabTrigger>
<TabContent value={0}>Content 1</TabContent>
</Tabs>Use composition to reduce nesting:
// ❌ Props drilling (bad)
<MovieList
movies={movies}
onEdit={onEdit}
onDelete={onDelete}
showRating={showRating}
// 10 more props...
/>
// Inside MovieList
<MovieCard
movie={movie}
onEdit={onEdit}
onDelete={onDelete}
showRating={showRating}
// 10 more props...
/>
// ✅ Composition (good)
<MovieList movies={movies}>
{(movie) => (
<MovieCard movie={movie} onEdit={onEdit} onDelete={onDelete} />
)}
</MovieList>When to extract:
- Compound component with many branches
- Sub-component >50 lines
- Sub-component used 2+ times
// ✅ Extract readable sub-components
interface MovieCardProps {
movie: Movie;
}
const MovieHeader: React.FC<{ movie: Movie }> = ({ movie }) => (
<div>{movie.title}</div>
);
const MovieStats: React.FC<{ movie: Movie }> = ({ movie }) => (
<div>{movie.rating} stars</div>
);
export const MovieCard: React.FC<MovieCardProps> = ({ movie }) => (
<Card>
<MovieHeader movie={movie} />
<MovieStats movie={movie} />
</Card>
);Rule: Test file lives in same directory as component
components/movie/MovieCard/
├── index.tsx
├── types.ts
├── MovieCard.test.tsx ← Here
└── README.md
Test these:
- User interactions (clicks, form submissions)
- Props impact on rendering
- State changes
- Callback invocations
- Conditional rendering
- Error states and edge cases
Don't test:
- Implementation details
- Library behavior (React, shadcn/ui)
- Third-party hooks
import { render, screen, fireEvent } from '@testing-library/react';
import { MovieCard } from './index.js';
describe('MovieCard', () => {
it('renders movie title', () => {
const movie = { id: 1, title: 'The Matrix' };
render(<MovieCard movie={movie} />);
expect(screen.getByText('The Matrix')).toBeInTheDocument();
});
it('calls onEdit when edit button clicked', () => {
const onEdit = vi.fn();
const movie = { id: 1, title: 'The Matrix' };
render(<MovieCard movie={movie} onEdit={onEdit} />);
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith(1);
});
it('shows enrichment status when loading', () => {
const movie = { id: 1, title: 'The Matrix' };
render(<MovieCard movie={movie} />);
// Mock useMovieEnrichmentStatus to return loading state
// ...
expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
});
});# Run all tests
npm test
# Watch mode
npm run test:watch
# Specific file
npm test MovieCard.test.tsxDocument public components:
/**
* MovieCard displays a single movie with metadata and optional edit/delete actions.
*
* Used on the dashboard and in search results. Handles hover states, enrichment
* status display, and action callbacks.
*
* @example
* const movie: Movie = { id: 1, title: 'The Matrix' };
* <MovieCard movie={movie} onEdit={handleEdit} onDelete={handleDelete} />
*
* @throws Never throws - errors are handled gracefully
*/
export const MovieCard: React.FC<MovieCardProps> = ({ movie, onEdit, onDelete }) => {
// ...
};interface MovieCardProps {
/** Movie entity to display. Required. */
movie: Movie;
/** Callback when edit button is clicked. Optional. */
onEdit?: (movieId: number) => void;
/** Callback when delete button is clicked. Optional. */
onDelete?: (movieId: number) => void;
/** Show extended metadata (directors, budget). Default: false. */
extended?: boolean;
/** Additional CSS classes for custom styling. */
className?: string;
}Create README.md for:
- Organisms with non-obvious behavior
- Components with performance considerations
- Components with many optional features
- Components that replace older versions
Template:
# MovieCard
Movie card component displaying basic metadata with optional enrichment status.
## Usage
```typescript
<MovieCard movie={movie} onEdit={handleEdit} />See types.ts for complete interface.
<MovieCard movie={movie} onEdit={handleEdit} extended />Component handles null movie gracefully - shows empty state.
Memoized with memo() to prevent re-renders when props unchanged.
Safe for large lists (1000+ items) when virtualized.
- Keyboard navigable
- ARIA labels on interactive elements
- Focus indicators visible
---
## 9. Component Decision Tree
Use this flowchart when deciding how to organize components:
┌─────────────────────────────────────┐ │ New component needed? │ └────────┬────────────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Does it have domain knowledge? │ │ (movie, library, provider specific) │ └────────┬────────────────────┬───────┘ YES │ │ NO ▼ ▼ ┌────────────┐ ┌──────────────┐ │ Domain dir │ │ components/ui│ │ (movie/) │ │ (atoms) │ └────────────┘ └──────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Needs server data? (useQuery, etc) │ └────────┬────────────────────┬───────┘ YES │ │ NO ▼ ▼ ┌────────────┐ ┌──────────────┐ │ Organism │ │ Molecule │ │ (complex) │ │ (simple) │ └────────────┘ └──────────────┘
---
## 10. Related Docs Links
**Frontend Architecture**:
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Overall frontend architecture
- [COMPONENT_REFERENCE.md](./COMPONENT_REFERENCE.md) - Component organization and patterns
- [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md) - TanStack Query usage
- [API_LAYER.md](./API_LAYER.md) - API communication patterns
- [ERROR_HANDLING.md](./ERROR_HANDLING.md) - Error boundaries and UI error handling
- [STYLING_GUIDE.md](./STYLING_GUIDE.md) - Design system and Tailwind styling
**Development**:
- [/CLAUDE.md](/CLAUDE.md) - AI assistant rules and coding standards
- [CODING_STANDARDS.md](../development/CODING_STANDARDS.md) - TypeScript conventions
- [TESTING.md](../development/TESTING.md) - Test infrastructure and patterns
- [WORKFLOW.md](../development/WORKFLOW.md) - Development workflow and processes
**Atomic Design Reference**:
- Atoms (primitives): `components/ui/` - Button, Card, Dialog
- Molecules (simple domain): `components/[domain]/` - MovieCard, LibraryCard
- Organisms (complex domain): `components/[domain]/` - VirtualizedMovieTable
- Pages (routes): `pages/` - Dashboard, Movies, Settings