TypeScript en pratique - 07 - Types avec React

Typer les composants, les hooks, les events, les refs, les composants génériques et polymorphiques en React avec TypeScript.

07 - Types avec React

Ce que tu vas apprendre

  • FC vs typage explicite des props
  • Typer useState, useRef, useContext
  • Creer des composants génériques
  • Typer les event handlers
  • Le pattern du composant polymorphique (prop as)
  • Typer children, forwardRef et les HOCs

Prerequisites

Avoir lu l'article sur l'error handling type et connaître les bases de React.


FC ou pas FC

React exporte un type FC (FunctionComponent) :

typescriptimport { FC } from "react"

const UserCard: FC<UserCardProps> = ({ name, email }) => {
  return <div>{name} - {email}</div>
}

Pendant longtemps, FC incluait children implicitement. Depuis React 18, ce n'est plus le cas. Mais FC a toujours un défaut : il masque le type de retour.

Le pattern que je préféré — et que la plupart des équipes adoptent :

typescriptinterface UserCardProps {
  name: string
  email: string
  onEdit?: (id: string) => void
}

function UserCard({ name, email, onEdit }: UserCardProps) {
  return <div>{name} - {email}</div>
}

Pas de FC. Le type des props est explicite. Le type de retour est infere (JSX.Element). C'est plus simple et plus lisible.

Typer children

Trois approches selon ce que tu acceptes :

typescript// 1. ReactNode — accepte tout (string, number, elements, null, undefined)
interface LayoutProps {
  children: React.ReactNode
}

// 2. PropsWithChildren — raccourci pour ajouter children optionnel
type LayoutProps = React.PropsWithChildren<{
  sidebar: React.ReactNode
}>

// 3. Render prop — children est une fonction
interface DataListProps<T> {
  items: T[]
  children: (item: T) => React.ReactNode
}

ReactNode est le type le plus large. Il accepte les strings, les numbers, les éléments JSX, les fragments, null, undefined. Pour la plupart des composants de layout, c'est le bon choix.

Typer useState

useState infere le type depuis la valeur initiale :

typescriptconst [count, setCount] = useState(0)          // type: number
const [name, setName] = useState("")            // type: string
const [user, setUser] = useState<User | null>(null) // doit specifier le generic

Quand la valeur initiale est null ou undefined, TypeScript ne peut pas inferer le type final. Il faut passer le generic explicitement.

typescript// ❌ Infere useState<undefined> — setUser("john") est une erreur
const [user, setUser] = useState(undefined)

// ✅ Specifie le type complet
const [user, setUser] = useState<User | null>(null)

Un pattern courant pour les donnees chargees depuis une API :

typescriptinterface UserPageState {
  user: User | null
  loading: boolean
  error: string | null
}

const [state, setState] = useState<UserPageState>({
  user: null,
  loading: true,
  error: null,
})

Typer useRef

useRef a deux modes selon que la ref est mutable ou non :

typescript// Ref DOM — le type est l'element HTML, initialise a null
const inputRef = useRef<HTMLInputElement>(null)
// type: RefObject<HTMLInputElement> — .current est readonly

// Ref mutable — stocke une valeur quelconque
const intervalRef = useRef<number | null>(null)
// type: MutableRefObject<number | null> — .current est mutable

La distinction depend du fait que tu passes null comme argument avec ou sans le type null dans le generic :

typescript// RefObject (readonly) — null dans l'arg mais pas dans le generic
useRef<HTMLDivElement>(null)

// MutableRefObject — null dans le generic aussi
useRef<HTMLDivElement | null>(null)

Pour les refs DOM que tu attaches a un élément JSX, utilise la première forme (readonly). Pour les refs qui stockent des valeurs (timers, instances), utilise la deuxieme.

Typer useContext

Le context a besoin d'un type et d'une valeur par défaut :

typescriptinterface AuthContext {
  user: User | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = React.createContext<AuthContext | null>(null)

// Hook custom qui garantit que le context est disponible
function useAuth(): AuthContext {
  const context = React.useContext(AuthContext)
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider")
  }
  return context
}

Le createContext<AuthContext | null>(null) initialise a null. Le hook useAuth fait un narrowing : si le context est null, c'est un bug (le composant n'est pas dans le provider). Apres le check, le type est AuthContext sans null.

Sur paltemps.fr, chaque context a son hook custom. Ca évité de répéter le null check partout.

Composants génériques

Un composant générique accepte un type paramètre :

typescriptinterface ListProps<T> {
  items: T[]
  renderItem: (item: T) => React.ReactNode
  keyExtractor: (item: T) => string
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  )
}

// Utilisation — T est infere comme User
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

TypeScript infere T depuis le type de items. Les callbacks renderItem et keyExtractor recoivent le bon type sans annotation.

Un pattern plus avance avec contrainte :

typescriptinterface SelectProps<T extends { id: string; label: string }> {
  options: T[]
  value: T | null
  onChange: (option: T) => void
}

function Select<T extends { id: string; label: string }>({
  options,
  value,
  onChange,
}: SelectProps<T>) {
  return (
    <select
      value={value?.id ?? ""}
      onChange={(e) => {
        const option = options.find(o => o.id === e.target.value)
        if (option) onChange(option)
      }}
    >
      {options.map(option => (
        <option key={option.id} value={option.id}>
          {option.label}
        </option>
      ))}
    </select>
  )
}

T extends { id: string; label: string } garantit que chaque option a un id et un label. Le composant recoit le type complet en retour — pas seulement { id: string; label: string }.

Typer les event handlers

React fournit des types pour tous les événements :

typescriptfunction Form() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value) // type: string
  }

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log(e.clientX, e.clientY)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
      <button onClick={handleClick}>Submit</button>
    </form>
  )
}

Le generic (ex: HTMLInputElement) donne le type de e.currentTarget et e.target. Les types courants :

Event Type React
onClick MouseEvent<HTMLElement>
onChange ChangeEvent<HTMLInputElement>
onSubmit FormEvent<HTMLFormElement>
onKeyDown KeyboardEvent<HTMLElement>
onFocus FocusEvent<HTMLElement>
onDrag DragEvent<HTMLElement>

Le composant polymorphique (prop as)

Un composant qui change son élément HTML selon une prop :

typescripttype PolymorphicProps<E extends React.ElementType> = {
  as?: E
  children: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<E>, "as" | "children">

function Box<E extends React.ElementType = "div">({
  as,
  children,
  ...props
}: PolymorphicProps<E>) {
  const Component = as || "div"
  return <Component {...props}>{children}</Component>
}

// Utilisation
<Box>default div</Box>
<Box as="section" id="main">section element</Box>
<Box as="a" href="/about">link element</Box>
// href est type-checke parce que as="a" donne les props de <a>

React.ComponentPropsWithoutRef<E> donne les props natives de l'élément E. Si as="a", les props incluent href, target, etc. Si as="button", elles incluent type, disabled, etc.

C'est un pattern avance mais tres utile pour les composants de design system.

Typer forwardRef

forwardRef a besoin du type de la ref et des props :

typescriptinterface InputProps {
  label: string
  error?: string
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...props} />
        {error && <span>{error}</span>}
      </div>
    )
  }
)

Input.displayName = "Input"

// Utilisation
const inputRef = useRef<HTMLInputElement>(null)
<Input ref={inputRef} label="Email" />

Le premier generic est le type de la ref (HTMLInputElement). Le deuxieme est le type des props. Les props natives de <input> sont propagees via ...props.


Résumé

  • Prefere le typage explicite des props a FC — c'est plus simple et plus clair
  • useState avec null initial a besoin du generic : useState<User | null>(null)
  • useRef a deux modes : RefObject (readonly, refs DOM) et MutableRefObject (valeurs)
  • Les composants génériques inferent le type depuis les props — pas besoin d'annotation
  • Les composants polymorphiques utilisent React.ComponentPropsWithoutRef<E> pour les props natives
  • forwardRef prend deux generics : le type de la ref et le type des props

Article précédent : 06 - Error handling type

Article suivant : 08 - Types avec les ORMs

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.