Composants React réutilisables: guide des bonnes pratiques
Un composant vraiment réutilisable économise des semaines de développement et évite la duplication de code. Voici comment les concevoir pour qu'ils résistent au temps et aux évolutions.
Anatomie d'un composant robuste
1. API claire et prévisible
tsx
// ❌ Props floues et limitées
interface ButtonProps {
type: 'primary' | 'secondary';
size: 'small' | 'large';
text: string;
clickHandler: () => void;
}
// ✅ API flexible et extensible
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
asChild?: boolean; // Polymorphisme avec Radix Slot
loading?: boolean;
children: React.ReactNode;
}2. Variants avec class-variance-authority
tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Classes de base
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{children}
</>
) : (
children
)}
</Comp>
);
}
);Composition over configuration
Pattern Compound Components
tsx
// Usage composable et lisible
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Gérez vos préférences de notification</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<NotificationSetting
title="Emails marketing"
description="Recevez nos dernières actualités"
/>
<NotificationSetting
title="Notifications push"
description="Alertes en temps réel"
/>
</div>
</CardContent>
<CardFooter>
<Button>Sauvegarder</Button>
</CardFooter>
</Card>Implémentation des sous-composants
tsx
// Card.tsx
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
)
);
// Export en bundle
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };Hooks personnalisés réutilisables
useLocalStorage générique
tsx
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
// Usage typé
const [user, setUser] = useLocalStorage<User | null>('user', null);
const [preferences, setPreferences] = useLocalStorage<UserPreferences>('prefs', defaultPrefs);useAsync pour les états de chargement
tsx
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useAsync<T>(asyncFunction: () => Promise<T>, dependencies: any[] = []) {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
let cancelled = false;
setState({ data: null, loading: true, error: null });
asyncFunction()
.then(data => {
if (!cancelled) {
setState({ data, loading: false, error: null });
}
})
.catch(error => {
if (!cancelled) {
setState({ data: null, loading: false, error });
}
});
return () => {
cancelled = true;
};
}, dependencies);
return state;
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useAsync(
() => fetchUser(userId),
[userId]
);
if (loading) return <UserSkeleton />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <UserNotFound />;
return <UserCard user={user} />;
}Patterns avancés de réutilisabilité
Render Props avec TypeScript
tsx
interface DataFetcherProps<T> {
url: string;
children: (props: {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return children({ data, loading, error, refetch: fetchData });
}
// Usage type-safe
<DataFetcher<User[]> url="/api/users">
{({ data, loading, error, refetch }) => (
<>
{loading && <UsersListSkeleton />}
{error && <ErrorBanner error={error} onRetry={refetch} />}
{data && <UsersList users={data} />}
</>
)}
</DataFetcher>Higher-Order Component (HOC) moderne
tsx
function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: React.ComponentType<{ error: Error; resetErrorBoundary: () => void }>
) {
const WrappedComponent = (props: P) => (
<ErrorBoundary
FallbackComponent={fallback || DefaultErrorFallback}
onReset={() => window.location.reload()}
>
<Component {...props} />
</ErrorBoundary>
);
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
return WrappedComponent;
}
// Usage
const SafeUserProfile = withErrorBoundary(UserProfile, UserProfileErrorFallback);Testing des composants réutilisables
Tests complets avec React Testing Library
tsx
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders with default variant and size', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toHaveClass('bg-primary', 'h-10');
});
it('applies custom variants correctly', () => {
render(<Button variant="outline" size="lg">Large Outline</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('border', 'h-11');
expect(button).not.toHaveClass('bg-primary');
});
it('shows loading state', () => {
render(<Button loading>Loading...</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
it('forwards refs correctly', () => {
const ref = createRef<HTMLButtonElement>();
render(<Button ref={ref}>Ref test</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
it('works with asChild prop', () => {
render(
<Button asChild>
<a href="/test">Link Button</a>
</Button>
);
const link = screen.getByRole('link', { name: /link button/i });
expect(link).toHaveAttribute('href', '/test');
expect(link).toHaveClass('bg-primary'); // Button styles applied to <a>
});
});Storybook pour la documentation
tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']
},
size: {
control: 'select',
options: ['default', 'sm', 'lg', 'icon']
}
}
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Button'
}
};
export const AllVariants: Story = {
render: () => (
<div className="flex gap-2 flex-wrap">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
)
};
export const WithIcon: Story = {
args: {
children: (
<>
<Mail className="mr-2 h-4 w-4" />
Login with Email
</>
)
}
};Checklist composant réutilisable
API Design
- [ ] Props étendues depuis les éléments HTML natifs
- [ ] Variants définis avec CVA
- [ ] Forward ref implémenté
- [ ] TypeScript strict activé
Flexibilité
- [ ] Composition over configuration
- [ ] Support du polymorphisme (asChild)
- [ ] Classes CSS customisables
- [ ] Accessible par défaut
Quality Assurance
- [ ] Tests unitaires complets
- [ ] Stories Storybook documentées
- [ ] Performance optimisée (React.memo si nécessaire)
- [ ] Error boundaries appropriées
Documentation
- [ ] JSDoc pour toutes les props
- [ ] Exemples d'usage multiples
- [ ] Guide de migration (breaking changes)
- [ ] Métriques d'adoption trackées
Un composant réutilisable bien conçu devient un actif technique qui économise des mois de développement sur la durée de vie d'un projet.

