Skip to main content

Vue d’ensemble

Kit’Asso utilise des patterns cohérents pour structurer le code de manière prévisible et maintenable. Ce guide documente les 4 patterns principaux du projet.

Pattern 1 : Architecture des Modals

Structure Container/Form/Preview/Hook

Chaque modal complexe suit cette organisation :
features/[feature]/modals/[Name]Modal/
├── index.tsx              # Container principal
├── [Name]Form.tsx         # Formulaire d'édition
├── [Name]Preview.tsx      # Vue en lecture seule
├── types.ts               # Types du modal
└── use[Name]Modal.ts      # Logique métier

Exemple : ToolModal

1. Container (index.tsx)

import { lazy, Suspense } from 'react';
import { Modal, Spinner } from '@/shared/components/ui';
import { useToolModal } from './useToolModal';
import type { ToolModalProps } from './types';

const ToolForm = lazy(() => import('./ToolForm'));
const ToolPreview = lazy(() => import('./ToolPreview'));

export function ToolModal({ toolId, mode, onClose }: ToolModalProps) {
  const { tool, loading, handleSubmit } = useToolModal(toolId);
  
  if (loading) {
    return (
      <Modal onClose={onClose}>
        <Spinner />
      </Modal>
    );
  }
  
  return (
    <Modal onClose={onClose} size="lg">
      <Suspense fallback={<Spinner />}>
        {mode === 'edit' ? (
          <ToolForm 
            tool={tool} 
            onSubmit={handleSubmit}
            onCancel={onClose}
          />
        ) : (
          <ToolPreview tool={tool} />
        )}
      </Suspense>
    </Modal>
  );
}
Responsabilités :
  • Orchestration (form vs preview)
  • Lazy loading des composants lourds
  • Gestion des états de chargement
  • Routing du modal (ouverture/fermeture)

2. Form Component (ToolForm.tsx)

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Input, Select, Button } from '@/shared/components/ui';

const toolSchema = z.object({
  name: z.string().min(1, 'Nom requis'),
  description: z.string().min(10, 'Description trop courte'),
  pricing_tier: z.enum(['Gratuit', 'Freemium', 'Payant', 'Entreprise']),
  category_id: z.string().uuid('Catégorie invalide'),
  website_url: z.string().url('URL invalide').optional(),
});

type ToolFormData = z.infer<typeof toolSchema>;

interface ToolFormProps {
  tool?: Tool;
  onSubmit: (data: ToolFormData) => Promise<void>;
  onCancel: () => void;
}

export function ToolForm({ tool, onSubmit, onCancel }: ToolFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ToolFormData>({
    resolver: zodResolver(toolSchema),
    defaultValues: tool,
  });
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <Input
        label="Nom de l'outil"
        {...register('name')}
        error={errors.name?.message}
      />
      
      <Input
        label="Description"
        as="textarea"
        rows={4}
        {...register('description')}
        error={errors.description?.message}
      />
      
      <Select
        label="Tarification"
        {...register('pricing_tier')}
        error={errors.pricing_tier?.message}
      >
        <option value="Gratuit">Gratuit</option>
        <option value="Freemium">Freemium</option>
        <option value="Payant">Payant</option>
        <option value="Entreprise">Entreprise</option>
      </Select>
      
      <Input
        label="Site web"
        type="url"
        {...register('website_url')}
        error={errors.website_url?.message}
      />
      
      <div className="flex gap-2 justify-end">
        <Button variant="ghost" onClick={onCancel}>
          Annuler
        </Button>
        <Button type="submit" loading={isSubmitting}>
          {tool ? 'Mettre à jour' : 'Créer'}
        </Button>
      </div>
    </form>
  );
}

export default ToolForm;
Responsabilités :
  • Validation avec Zod
  • Gestion des inputs
  • Affichage des erreurs
  • UI du formulaire uniquement

3. Hook métier (useToolModal.ts)

import { useState, useEffect } from 'react';
import { toolsApi } from '@/api';
import type { Tool, ToolUpdate } from '@/api/types';

export function useToolModal(toolId?: string) {
  const [tool, setTool] = useState<Tool | null>(null);
  const [loading, setLoading] = useState(!!toolId);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    if (toolId) {
      loadTool(toolId);
    }
  }, [toolId]);
  
  const loadTool = async (id: string) => {
    setLoading(true);
    setError(null);
    
    try {
      const data = await toolsApi.getById(id);
      setTool(data);
    } catch (err) {
      setError('Impossible de charger l'outil');
      console.error(err);
    } finally {
      setLoading(false);
    }
  };
  
  const handleSubmit = async (data: ToolUpdate) => {
    try {
      if (toolId) {
        await toolsApi.update(toolId, data);
      } else {
        await toolsApi.create(data);
      }
      // Success: modal fermé par le parent
    } catch (err) {
      setError('Erreur lors de la sauvegarde');
      throw err; // Re-throw pour que le form affiche l'erreur
    }
  };
  
  return {
    tool,
    loading,
    error,
    handleSubmit,
  };
}
Responsabilités :
  • Chargement des données
  • Appels API
  • Gestion des erreurs
  • Business logic

4. Types (types.ts)

export interface ToolModalProps {
  toolId?: string;
  mode: 'edit' | 'view';
  onClose: () => void;
}

Avantages du pattern

Separation of Concerns : UI, logique, data séparés
Testabilité : Chaque partie testable indépendamment
Réutilisabilité : Form peut être utilisé ailleurs
Performance : Lazy loading des composants lourds
Maintenabilité : Facile à comprendre et modifier

Pattern 2 : Forms avec React Hook Form + Zod

Setup standard

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 1. Définir le schema Zod
const schema = z.object({
  name: z.string().min(1, 'Champ requis'),
  email: z.string().email('Email invalide'),
  age: z.number().min(18, 'Doit avoir 18 ans minimum'),
});

// 2. Inférer le type TypeScript
type FormData = z.infer<typeof schema>;

// 3. Utiliser dans le composant
export function MyForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setValue,
    watch,
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      name: '',
      email: '',
      age: 18,
    },
  });
  
  const onSubmit = async (data: FormData) => {
    // Données déjà validées par Zod
    await api.create(data);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Input
        {...register('name')}
        error={errors.name?.message}
      />
      {/* ... */}
    </form>
  );
}

Validation complexe

const workflowSchema = z.object({
  title: z.string().min(1, 'Titre requis'),
  difficulty: z.enum(['débutant', 'intermédiaire', 'expert']),
  duration: z.string().regex(/^\d+min$/, 'Format: 30min'),
  steps: z.array(
    z.object({
      tool_name: z.string(),
      action: z.string(),
    })
  ).min(1, 'Au moins une étape requise'),
});

Validation asynchrone

const schema = z.object({
  slug: z.string()
    .min(3)
    .refine(
      async (slug) => {
        const exists = await api.checkSlugExists(slug);
        return !exists;
      },
      { message: 'Ce slug existe déjà' }
    ),
});

Pattern 3 : Custom Hooks

Hook de données (useAppData)

import { useState, useEffect } from 'react';
import { toolsApi, categoriesApi } from '@/api';
import type { EnhancedTool, Category } from '@/api/types';

export function useAppData() {
  const [tools, setTools] = useState<EnhancedTool[]>([]);
  const [categories, setCategories] = useState<Category[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    loadData();
  }, []);
  
  const loadData = async () => {
    setLoading(true);
    setError(null);
    
    try {
      const [toolsData, categoriesData] = await Promise.all([
        toolsApi.list(),
        categoriesApi.list(),
      ]);
      
      setTools(toolsData);
      setCategories(categoriesData);
    } catch (err) {
      setError('Erreur de chargement');
      // Fallback vers données de démo
      setTools(DEMO_TOOLS);
      setCategories(DEMO_CATEGORIES);
    } finally {
      setLoading(false);
    }
  };
  
  return {
    tools,
    categories,
    loading,
    error,
    refetch: loadData,
  };
}

Hook de logique métier (useFavorites)

import { useState, useEffect } from 'react';

const STORAGE_KEY = 'kitasso_favorites';

export function useFavorites() {
  const [favorites, setFavorites] = useState<Set<string>>(() => {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      return stored ? new Set(JSON.parse(stored)) : new Set();
    } catch {
      return new Set();
    }
  });
  
  useEffect(() => {
    try {
      localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify([...favorites])
      );
    } catch (err) {
      console.error('Erreur sauvegarde favoris', err);
    }
  }, [favorites]);
  
  const addFavorite = (id: string) => {
    setFavorites((prev) => new Set([...prev, id]));
  };
  
  const removeFavorite = (id: string) => {
    setFavorites((prev) => {
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  };
  
  const toggleFavorite = (id: string) => {
    if (favorites.has(id)) {
      removeFavorite(id);
    } else {
      addFavorite(id);
    }
  };
  
  const isFavorite = (id: string) => favorites.has(id);
  
  return {
    favorites,
    count: favorites.size,
    addFavorite,
    removeFavorite,
    toggleFavorite,
    isFavorite,
  };
}

Hook avec debounce (useToolFilters)

import { useState, useMemo, useEffect } from 'react';
import type { EnhancedTool } from '@/api/types';

export function useToolFilters(tools: EnhancedTool[]) {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedSearch, setDebouncedSearch] = useState('');
  const [selectedPricing, setSelectedPricing] = useState<string[]>([]);
  const [selectedCategory, setSelectedCategory] = useState<string>('');
  
  // Debounce search
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedSearch(searchTerm);
    }, 300);
    
    return () => clearTimeout(timer);
  }, [searchTerm]);
  
  const filteredTools = useMemo(() => {
    return tools.filter((tool) => {
      // Search filter
      if (debouncedSearch) {
        const search = debouncedSearch.toLowerCase();
        const matchName = tool.name.toLowerCase().includes(search);
        const matchDesc = tool.description.toLowerCase().includes(search);
        if (!matchName && !matchDesc) return false;
      }
      
      // Pricing filter
      if (selectedPricing.length > 0) {
        if (!selectedPricing.includes(tool.pricing_tier)) {
          return false;
        }
      }
      
      // Category filter
      if (selectedCategory && tool.category_id !== selectedCategory) {
        return false;
      }
      
      return true;
    });
  }, [tools, debouncedSearch, selectedPricing, selectedCategory]);
  
  return {
    filteredTools,
    searchTerm,
    setSearchTerm,
    selectedPricing,
    setSelectedPricing,
    selectedCategory,
    setSelectedCategory,
    resultCount: filteredTools.length,
  };
}

Pattern 4 : Routes & Navigation

React Router v6 setup

// router.tsx
import { createBrowserRouter } from 'react-router-dom';
import { lazy } from 'react';

// Lazy load pages
const Admin = lazy(() => import('./pages/Admin'));
const Compare = lazy(() => import('./pages/Compare'));
const Login = lazy(() => import('./pages/Login'));

export const router = createBrowserRouter([
  {
    path: '/',
    element: <App />,
    errorElement: <ErrorPage />,
  },
  {
    path: '/admin',
    element: <ProtectedRoute><Admin /></ProtectedRoute>,
  },
  {
    path: '/compare',
    element: <Compare />,
  },
  {
    path: '/login',
    element: <Login />,
  },
  {
    path: '/quiz/:slug',
    element: <QuizPage />,
  },
]);

Protected Routes

import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/lib/auth';

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { user, loading } = useAuth();
  const location = useLocation();
  
  if (loading) {
    return <Spinner />;
  }
  
  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  
  return <>{children}</>;
}
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';

export function QuizPage() {
  const { slug } = useParams<{ slug: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const navigate = useNavigate();
  
  const step = searchParams.get('step') || '0';
  
  const nextStep = () => {
    setSearchParams({ step: String(Number(step) + 1) });
  };
  
  const goToResults = () => {
    navigate(`/quiz/${slug}/results`);
  };
  
  return (
    <div>
      <h1>Quiz: {slug}</h1>
      <p>Étape: {step}</p>
      <button onClick={nextStep}>Suivant</button>
    </div>
  );
}

Checklist patterns

Avant de créer un nouveau composant complexe :
  • Container avec lazy loading
  • Form séparé avec React Hook Form + Zod
  • Hook métier avec logique isolée
  • Types définis dans types.ts
  • Preview component si besoin

Hook custom

  • Nom commence par use
  • Return objet avec noms explicites
  • Cleanup dans useEffect si nécessaire
  • Types de retour explicites
  • Documenté avec JSDoc

Form

  • Schema Zod défini
  • Validation côté client
  • Gestion erreurs affichée
  • Loading state sur submit
  • Accessible (labels, ARIA)

Route

  • Lazy loading si pages lourdes
  • Protected si nécessite auth
  • Error boundary
  • Loading state
  • Meta tags SEO

Ressources