Aller au contenu principal
    IT INNOVE - Logo de l'agence de développement web et mobile
    Retour au blog
    React

    Composants React réutilisables: guide des bonnes pratiques

    Équipe IT INNOVE
    8 min de lecture

    Props flexibles, variants intelligents et API composables pour des composants qui durent.

    Composants React réutilisables: guide des bonnes pratiques - React | IT INNOVE
    Composants React réutilisables: guide des bonnes pratiques - React

    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.

    Questions fréquentes