React + TypeScript en 2026 : Guide Complet du Développeur Modern
Meta description : Maîtrisez React avec TypeScript en 2026 : hooks typés, patterns avancés, performance, tests. Guide complet pour développeurs qui veulent écrire du code React robuste et maintenable. Mots-clés cibles : react typescript, react ts guide, typescript react 2026, hooks typescript, développement react moderne, frontend typescript Slug : react-typescript-2026-guide-completReact + TypeScript : Le Duo Incontournable en 2026
En 2026, 87% des nouveaux projets React utilisent TypeScript. Et ce n'est pas un hasard : le typage statique élimine des classes entières de bugs, améliore l'autocomplétion, et rend le code plus lisible pour toute l'équipe.
Ce guide vous montre comment exploiter React + TypeScript efficacement — pas juste les bases, mais les patterns qui font la différence en production.
1. Setup Moderne en 2 Minutes
Vite (Recommandé en 2026)
npm create vite@latest mon-app -- --template react-ts
cd mon-app
npm install
npm run dev
Next.js (Si vous avez besoin de SSR)
npx create-next-app@latest mon-app --typescript --app --tailwind
Structure de Projet
src/
├── components/
│ ├── ui/ # Composants réutilisables
│ ├── features/ # Composants métier
│ └── layouts/ # Layouts de page
├── hooks/ # Custom hooks
├── lib/ # Utilitaires
├── types/ # Types partagés
├── services/ # Appels API
└── stores/ # State management
2. Typer vos Composants
Props avec Interface
interface UserCardProps {
user: {
id: string
name: string
email: string
avatar?: string // optionnel
}
onEdit: (id: string) => void
variant?: 'compact' | 'full' // union type
}
function UserCard({ user, onEdit, variant = 'full' }: UserCardProps) {
return (
<div className={card card-${variant}}>
<h3>{user.name}</h3>
{variant === 'full' && <p>{user.email}</p>}
<button onClick={() => onEdit(user.id)}>Éditer</button>
</div>
)
}
Children Typés
// Children simple (JSX)
interface LayoutProps {
children: React.ReactNode
sidebar?: React.ReactNode
}
// Render props
interface DataListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
keyExtractor: (item: T) => string
}
function DataList<T>({ items, renderItem, keyExtractor }: DataListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
)
}
// Utilisation
<DataList
items={users}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>
Composants Polymorphiques
type ButtonProps<T extends React.ElementType = 'button'> = {
as?: T
variant: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
} & React.ComponentPropsWithoutRef<T>
function Button<T extends React.ElementType = 'button'>({
as,
variant,
size = 'md',
className,
...props
}: ButtonProps<T>) {
const Component = as || 'button'
return (
<Component
className={btn btn-${variant} btn-${size} ${className ?? ''}}
{...props}
/>
)
}
// Utilisation
<Button variant="primary">Cliquer</Button>
<Button as="a" variant="secondary" href="/about">À propos</Button>
3. Hooks Typés
useState
// Type inféré
const [count, setCount] = useState(0) // number
// Type explicite (nécessaire pour les objets)
interface User {
id: string
name: string
email: string
}
const [user, setUser] = useState<User | null>(null)
// Avec un état initial
const [filters, setFilters] = useState<{
search: string
category: string
page: number
}>({
search: '',
category: 'all',
page: 1
})
useRef
// Ref DOM
const inputRef = useRef<HTMLInputElement>(null)
// Ref mutable (valeur qui persiste sans re-render)
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
function handleFocus() {
inputRef.current?.focus() // optional chaining
}
useReducer
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset'; payload: number }
| { type: 'set'; payload: number }
interface State {
count: number
history: number[]
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return {
count: state.count + 1,
history: [...state.history, state.count + 1]
}
case 'decrement':
return {
count: state.count - 1,
history: [...state.history, state.count - 1]
}
case 'reset':
return { count: action.payload, history: [] }
case 'set':
return {
count: action.payload,
history: [...state.history, action.payload]
}
}
}
const [state, dispatch] = useReducer(reducer, { count: 0, history: [] })
dispatch({ type: 'set', payload: 42 })
Custom Hooks
// Hook de fetch générique
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const controller = new AbortController()
async function fetchData() {
try {
setLoading(true)
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(HTTP ${res.status})
const json = await res.json() as T
setData(json)
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err)
}
} finally {
setLoading(false)
}
}
fetchData()
return () => controller.abort()
}, [url])
return { data, error, loading }
}
// Utilisation
const { data: users, loading } = useFetch<User[]>('/api/users')
4. Context API Typé
interface AuthContext {
user: User | null
login: (email: string, password: string) => Promise<void>
logout: () => void
isLoading: boolean
}
const AuthContext = createContext<AuthContext | null>(null)
function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const login = async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json() as User
setUser(data)
}
const logout = () => setUser(null)
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
)
}
5. Formulaires Typés
React Hook Form + Zod (La Meilleure Combo)
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(2, 'Le nom doit faire au moins 2 caractères'),
email: z.string().email('Email invalide'),
message: z.string().min(10, 'Le message doit faire au moins 10 caractères'),
category: z.enum(['support', 'sales', 'partnership']),
newsletter: z.boolean().default(false)
})
type ContactForm = z.infer<typeof contactSchema>
function ContactPage() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<ContactForm>({
resolver: zodResolver(contactSchema)
})
const onSubmit = async (data: ContactForm) => {
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Nom" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} placeholder="Email" type="email" />
{errors.email && <span>{errors.email.message}</span>}
<select {...register('category')}>
<option value="support">Support</option>
<option value="sales">Commercial</option>
<option value="partnership">Partenariat</option>
</select>
<textarea {...register('message')} placeholder="Votre message" />
{errors.message && <span>{errors.message.message}</span>}
<label>
<input {...register('newsletter')} type="checkbox" />
Recevoir la newsletter
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Envoi...' : 'Envoyer'}
</button>
</form>
)
}
6. Appels API Typés
Avec TanStack Query (React Query)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
interface Product {
id: string
name: string
price: number
category: string
}
// Service API typé
const productApi = {
getAll: async (): Promise<Product[]> => {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('Failed to fetch products')
return res.json()
},
getById: async (id: string): Promise<Product> => {
const res = await fetch(/api/products/${id})
if (!res.ok) throw new Error('Product not found')
return res.json()
},
create: async (data: Omit<Product, 'id'>): Promise<Product> => {
const res = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
})
return res.json()
}
}
// Dans le composant
function ProductList() {
const { data: products, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: productApi.getAll
})
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: productApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
}
})
if (isLoading) return <p>Chargement...</p>
if (error) return <p>Erreur: {error.message}</p>
return (
<ul>
{products?.map(product => (
<li key={product.id}>
{product.name} — {product.price}€
</li>
))}
</ul>
)
}
7. Performance avec TypeScript
React.memo Typé
interface ExpensiveListProps {
items: Product[]
onSelect: (id: string) => void
}
const ExpensiveList = React.memo<ExpensiveListProps>(
function ExpensiveList({ items, onSelect }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
)
}
)
useCallback et useMemo Typés
const filteredProducts = useMemo<Product[]>(
() => products.filter(p => p.category === selectedCategory),
[products, selectedCategory]
)
const handleSelect = useCallback(
(id: string) => {
setSelectedId(id)
onProductSelect?.(id)
},
[onProductSelect]
)
8. Tests avec TypeScript
Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
describe('UserCard', () => {
const mockUser: User = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
}
it('renders user info', () => {
const onEdit = vi.fn()
render(<UserCard user={mockUser} onEdit={onEdit} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('calls onEdit with user id', async () => {
const onEdit = vi.fn()
const user = userEvent.setup()
render(<UserCard user={mockUser} onEdit={onEdit} />)
await user.click(screen.getByText('Éditer'))
expect(onEdit).toHaveBeenCalledWith('1')
})
it('hides email in compact variant', () => {
const onEdit = vi.fn()
render(<UserCard user={mockUser} onEdit={onEdit} variant="compact" />)
expect(screen.queryByText('john@example.com')).not.toBeInTheDocument()
})
})
9. Patterns Avancés
Discriminated Unions pour les États
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function useAsyncData<T>(fetcher: () => Promise<T>) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })
const execute = async () => {
setState({ status: 'loading' })
try {
const data = await fetcher()
setState({ status: 'success', data })
} catch (err) {
setState({ status: 'error', error: err as Error })
}
}
return { ...state, execute }
}
// Utilisation — TypeScript sait quel champ existe selon le status
function ProductPage() {
const state = useAsyncData(() => productApi.getAll())
switch (state.status) {
case 'idle':
return <button onClick={state.execute}>Charger</button>
case 'loading':
return <Spinner />
case 'error':
return <Alert>{state.error.message}</Alert>
case 'success':
return <ProductList items={state.data} />
}
}
Generic Components
interface TableProps<T> {
data: T[]
columns: {
key: keyof T
header: string
render?: (value: T[keyof T], row: T) => React.ReactNode
}[]
onRowClick?: (row: T) => void
}
function Table<T extends { id: string }>({
data,
columns,
onRowClick
}: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id} onClick={() => onRowClick?.(row)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
10. Configuration TypeScript Optimale
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"paths": {
"@/": ["./src/"]
}
},
"include": ["src"]
}
Conclusion
React + TypeScript n'est plus optionnel en 2026 — c'est la norme. Le typage statique vous fait gagner du temps sur le long terme : moins de bugs en production, meilleure documentation vivante, et refactoring sans peur.
Pour démarrer rapidement :- Setup Vite + React + TypeScript en 2 minutes
- Typez vos props et hooks
- Utilisez Zod pour la validation de formulaires
- TanStack Query pour les appels API
> Besoin d'un projet React + TypeScript prêt pour la production ? Notre SaaS Boilerplate inclut authentification, paiements Stripe, dashboard admin — le tout entièrement typé et testé.
FAQ
TypeScript ralentit-il le développement ?
Au début oui (courbe d'apprentissage). Après 2-3 semaines, il accélère le développement grâce à l'autocomplétion et la détection d'erreurs instantanée.
Faut-il typer tout ?
Non. Laissez TypeScript inférer quand c'est clair (variables locales, retours de fonctions simples). Typez explicitement les interfaces publiques (props, API responses, hooks exports).
React ou Next.js avec TypeScript ?
React (Vite) pour les SPA et les dashboards. Next.js pour les sites qui ont besoin de SEO (SSR/SSG) ou de server-side logic.
Quel est le meilleur state manager typé ?
Zustand pour la simplicité, Jotai pour les atomes, TanStack Query pour le server state. Redux Toolkit si vous avez déjà Redux.