Introduction
Dans ce tutoriel, nous aborderons les bonnes pratiques en utilisant un projet GitHub disponible dans la barre latérale. Clonez le projet et essayez-le. L'application est un piano simulé qui produit des sons lorsque l'on appuie sur les touches :

Saviez-vous ?
React utilise seulement deux packages : React (fonctionnalités de base) et react-dom (fonctionnalités liées au navigateur). Sans react-dom, JSX ne peut pas être utilisé car les navigateurs ne le prennent pas en charge. Depuis React 17, l'objet React n'est plus nécessaire pour afficher le code JSX.
FunctionComponent
Les FunctionComponents permettent de typer les fonctions TypeScript et de retourner un objet React avec des props. Un élément React est un objet JavaScript représentant l'état des éléments DOM à un moment donné. Pensez à utiliser FunctionComponent pour typer avec TypeScript.
import React, { FunctionComponent } from "react";
import "./style.css";
export const Footer: FunctionComponent = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<a href="https://switch-case.io/">Switch-case</a>
<br />
{currentYear}
</footer>
);
};
FC est utilisé pour définir un composant fonctionnel React :
const Issues: FC<Props> = () => {...}
C'est une bonne option pour migrer ce type de code vers des hooks React :
class Issues extends Component<Props, State> {
Pourquoi utiliser les FunctionComponents ?
- Facilitent la lecture et compréhension du code
- Faciles à tester
- Potentiellement meilleures performances (gain de 6%)
- Faciles à déboguer
- Réutilisables
- Réduisent le couplage
Quand ne pas les utiliser ? Commencez toujours par un composant fonctionnel. S'il est nécessaire d'ajouter des méthodes de cycle de vie ou un état au niveau du composant, refactorisez vers un composant de classe. N'utilisez pas de composants fonctionnels si vous n'avez pas d'autre choix.
Union type
En développement d'application, le domaine fait référence au sujet principal du programme et permet de structurer les données. Pour cette application de piano, notre domaine concerne les sons, les notes générées, la notation des notes et les touches du piano réel.
Dans ce fichier TypeScript, nous utilisons l'Union Type pour regrouper les différents types de notes possibles : NoteType, NotePitch et OctaveIndex. L'Union Type nous permet de créer un ensemble d'entités que nous pouvons sélectionner à tout moment. Dans notre cas, les types de notes possibles sont "natural", "sharp" et "flat", qui sont séparés par le symbole "|".
Voici les types de notes possibles pour notre application :
export type NoteType = "natural" | "flat" | "sharp"
export type NotePitch = "A" | "B" | "C" | "D" | "E" | "F" | "G"
export type OctaveIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
Interface
En programmation, une interface est une description abstraite d'une entité qui permet de partager des informations similaires entre les composants. En TypeScript, les interfaces sont une manière puissante de définir des contrats à travers notre code, ce qui rend les composants moins dépendants les uns des autres. Cela facilite la réutilisation de code et réduit le risque d'erreurs inattendues.
Dans l'exemple ci-dessous, nous définissons une interface pour une note de musique qui est directement liée à notre Union Type. Cette interface permet de spécifier la structure et les données possibles que nous attendons pour un objet "note".
export interface Note {
midi: MidiValue;
type: NoteType;
pitch: NotePitch;
index: PitchIndex;
octave: OctaveIndex;
}
En utilisant cette interface, nous créons un contrat qui garantit que tout objet "note" aura les propriétés "midi", "type", "pitch", "index" et "octave". Utiliser des interfaces de cette manière amène une meilleure flexibilité et cohérence à nos composants, ce qui est une bonne pratique de développement.
Les types génériques
En programmation, les types génériques permettent de créer des composants qui peuvent fonctionner avec une grande variété de types. Cela ajoute de la flexibilité en évitant d'assigner une classe à un composant de manière fixe.
Dans l'exemple ci-dessous, nous définissons différentes constantes liées à la musique, telles que les notes de musique et leurs valeurs MIDI. Nous utilisons également le type Record<K, T>, qui demande un ensemble de propriétés de type K pour les valeurs de type T. Dans notre cas, nous construisons un objet qui a un ensemble de PitchIndex de type NotePitch.
const C1_MIDI_NUMBER = 24;
const C4_MIDI_NUMBER = 60;
const B5_MIDI_NUMBER = 83;
export const LOWER_NOTE = C4_MIDI_NUMBER;
export const HIGHER_NOTE = B5_MIDI_NUMBER;
export const SEMITONES_IN_OCTAVE = 12;
export const NATURAL_PITCH_INDICES: PitchIndex[] = [0, 2, 4, 5, 7, 9, 11];
export const PITCHES_REGISTRY: Record<PitchIndex, NotePitch> = {
0: "C",
1: "C",
2: "D",
3: "D",
4: "E",
5: "F",
6: "F",
7: "G",
8: "G",
9: "A",
10: "A",
11: "B",
};
Enfin, nous avons un exemple de type générique en TypeScript, le type Optional
export type Optional<TEntity> = TEntity | null
Les alias Webpack
Lorsque l'on travaille sur des applications JavaScript, l'un des problèmes les plus courants est la gestion des chemins relatifs. En effet, si vous déplacez un fichier ou renommez un dossier, vous devrez modifier les chemins relatifs pour chaque import, ce qui peut rapidement devenir fastidieux. La solution à ce problème est l'utilisation des "aliases".
Les "aliases" permettent de définir des noms plus courts pour des chemins d'importation plus longs. Dans TypeScript, on peut les définir dans le fichier de configuration "tsconfig.json" :
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"paths": {
"Components/*": [ "components/*"],
"Domains/*": [ "domains/*"],
"Layouts/*": [ "layout/*"],
"Pages/*": [ "pages/*"],
},
},
"include": [
"src"
]
}
Dans cet exemple, nous avons défini quatre alias : "Components", "Domains", "Layouts" et "Pages", qui pointent respectivement vers les dossiers "components", "domains", "layout" et "pages".
Une fois les alias définis, nous pouvons les utiliser dans nos fichiers comme suit :
import React, { FunctionComponent } from "react"
import { notes } from "Domains/note"
import "./style.css"
export const Keyboard: FunctionComponent = () => {
return (
<div className="keyboard">
</div>
)
}
Pour que cela fonctionne, nous avons également besoin de modifier notre configuration Webpack en installant la dépendance "tsconfig-paths-webpack-plugin" et en ajoutant les lignes suivantes dans notre fichier de configuration Webpack :
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
// ...
resolve: {
plugins: [new TsconfigPathsPlugin()],
}
// ...
}
En ajoutant cette dépendance et ces lignes de configuration, nous pouvons utiliser nos alias sans avoir à modifier les chemins relatifs dans chaque fichier.
Les composants réutilisable
Les composants réutilisables sont des composants que l'on peut partager entre plusieurs domaines d'une application afin d'éviter la duplication de code. En utilisant des composants réutilisables, on peut créer des applications complexes tout en gardant une interface propre et maintenable.
Dans React, il est facile de composer des composants ensemble. Par exemple, on peut créer un composant "Profile" qui utilise deux autres composants : "Picture" et "UserName" pour afficher l'image de profil et le nom de l'utilisateur :
const Profile = ({ user }) => (
<>
<Picture profileImageUrl ={user. profileImageUrl } />
<UserName name ={user. name } screenName ={user. screenName } />
</>
)
En créant ces petits composants avec une interface propre, on peut composer rapidement de nouvelles parties de l'interface utilisateur en écrivant seulement quelques lignes de code. Chaque fois que l'on compose des composants, on partage des données entre eux à l'aide de "props". Cela permet de créer des composants réutilisables qui peuvent être utilisés dans différents endroits de l'application sans avoir à réécrire le code.
La props children
La propriété "children" est une prop spéciale en React qui permet de passer n'importe quel élément en tant qu'enfant d'un composant. Cela permet d'avoir une grande flexibilité dans la composition de nos interfaces utilisateur.
Par exemple, si nous voulons créer un bouton réutilisable qui peut afficher plus qu'une simple chaîne de texte, nous pouvons utiliser la prop "children" pour cela :
const Button = ({ children }) => (
<button className="btn">{children}</button>
)
En passant la prop "children", nous ne sommes pas limités à une simple propriété de texte, mais nous pouvons passer n'importe quel élément à Button, et il est rendu à la place de la propriété. Par exemple, nous pouvons passer une image et un texte à notre bouton :
<Button>
<img src="..." alt="..." />
<span>Click me!</span>
</Button>
Dans cet exemple, nous avons défini la propriété "children" comme un tableau, ce qui signifie que nous pouvons passer n'importe quel nombre d'éléments comme enfants du composant.
En utilisant la prop "children", nous pouvons créer des composants plus flexibles et réutilisables, qui peuvent accepter n'importe quel élément et les envelopper dans un parent prédéfini.
Pattern conteneur et présentation
Dans React, il existe deux modèles simples et puissants appelés conteneur et présentation. Ces modèles peuvent être appliqués lors de la création de composants pour séparer la logique de la présentation. En créant des frontières bien définies entre la logique et la présentation, non seulement les composants deviennent plus réutilisables, mais cela offre également de nombreux autres avantages que nous allons découvrir dans cette section. Pour mieux comprendre ces concepts, il est recommandé de voir des exemples pratiques, donc nous allons approfondir un peu le code.
Prenons l'exemple d'un composant qui utilise des API de géolocalisation pour obtenir la position de l'utilisateur et afficher la latitude et la longitude sur la page dans le navigateur.
import { useState, useEffect } from 'react'
const Geolocation = () => {}
export default Geolocation
Définissons nos états :
const [latitude, setLatitude] = useState<number | null>(null)
const [longitude, setLongitude] = useState<number | null>(null)
Maintenant, nous pouvons utiliser le Hook useEffect pour envoyer la requête aux API :
useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handleSuccess)
}
}, [])
Lorsque le navigateur renvoie les données, nous stockons le résultat dans l'état à l'aide de la fonction suivante :
const handleSuccess = ({
coords: {
latitude,
longitude
}
}: { coords: { latitude: number; longitude: number }}) => {
setLatitude(latitude)
setLongitude(longitude)
}
Enfin, nous montrons les latitude et longitude :
return (
<div>
<h1>Geolocation:</h1>
<div>Latitude: {latitude}</div>
<div>Longitude: {longitude}</div>
</div>
)
Il est important de noter que lors du premier rendu, les valeurs de latitude et de longitude sont vides car le composant a demandé les coordonnées lors de son montage. Pour améliorer la performance, il serait souhaitable de séparer cette partie de la partie où la position est demandée et chargée.
Pour ce faire, nous allons utiliser les modèles de conteneur et de présentation pour isoler la partie présentation. Chaque composant est divisé en deux parties plus petites, chacune ayant des responsabilités claires.
Le conteneur gère la logique du composant et appelle les API nécessaires. Il gère également la manipulation des données et la gestion des événements. Le composant de présentation est l'endroit où l'interface utilisateur est définie. Il reçoit des données sous la forme de props du conteneur. Étant donné que le composant de présentation est généralement sans logique, nous pouvons le créer en tant que composant fonctionnel et sans état. Cependant, cela ne signifie pas qu'il ne peut pas avoir d'état.
Voici un exemple de code pour un conteneur utilisant la géolocalisation :
import { useState, useEffect } from 'react'
import Geolocation from './Geolocation'
const GeolocationContainer = () => {
const [latitude, setLatitude] = useState<number | null>(null)
const [longitude, setLongitude] = useState<number | null>(null)
const handleSuccess = ({ coords: { latitude, longitude } }: { coords: { latitude: number; longitude: number }}) => {
setLatitude(latitude)
setLongitude(longitude)
}
useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handleSuccess)
}
}, [])
return (
<Geolocation latitude={latitude} longitude={longitude} />
)
}
export default GeolocationContainer
Comme vous pouvez le voir dans l'extrait précédent, au lieu de créer les éléments HTML à l'intérieur du conteneur, nous retournons simplement le composant de présentation (que nous allons créer ensuite), et nous lui transmettons l'état. Les états sont latitude et longitude, qui sont initialement vides, et qui contiendront la position réelle de l'utilisateur lorsque le navigateur déclenche le rappel.
Voici un exemple de code pour un composant de présentation appelé Geolocation.tsx utilisant les données de position transmises par le conteneur :
import { FC } from 'react'
type Props = {
latitude: number
longitude: number
}
const Geolocation: FC<Props> = ({ latitude, longitude }) => (
<div>
<h1>Geolocation:</h1>
<div>Latitude: {latitude}</div>
<div>Longitude: {longitude}</div>
</div>
)
export default Geolocation
Les composants fonctionnels sont une façon élégante de définir les interfaces utilisateur. Ils sont des fonctions pures qui renvoient des éléments HTML ou d'autres composants. En suivant le modèle de conteneur et de présentation, nous avons créé un composant réutilisable simple que nous pouvons utiliser dans notre guide de style en lui transmettant des fausses coordonnées. Si nous devons afficher la même structure de données dans d'autres parties de l'application, nous pouvons simplement envelopper ce composant dans un nouveau conteneur qui, par exemple, charge la latitude et la longitude à partir d'un point de terminaison différent.
Pendant ce temps, d'autres développeurs de notre équipe peuvent améliorer le conteneur qui utilise la géolocalisation en ajoutant une logique de gestion des erreurs, sans affecter la présentation. Ils peuvent même créer un composant de présentation temporaire juste pour afficher et déboguer les données, puis le remplacer par le véritable composant de présentation lorsqu'il est prêt.
Ce modèle est simple mais très puissant. Lorsqu'il est appliqué à de grandes applications, il peut faire une grande différence en termes de vitesse de développement et de maintenabilité du projet. Cependant, l'application de ce modèle sans raison peut augmenter le nombre de fichiers et de composants sans réelle utilité, ce qui peut rendre la base de code moins utile. Nous devons donc bien réfléchir avant de décider de refactoriser un composant en suivant les modèles de conteneur et de présentation. En général, il est préférable de commencer par un seul composant et de le diviser uniquement lorsque la logique et la présentation sont trop étroitement liées.
| Conteneur | Présentation |
|---|---|
| Ils sont plus préoccupés par le comportement. | Ils sont plus préoccupés par la représentation visuelle. |
| Ils affichent leurs composants de présentation. | Ils affichent le balisage HTML (ou d'autres composants). |
| Ils effectuent des appels d'API et manipulent des données. | Ils reçoivent des données des parents sous forme de props. |
| Ils définissent les gestionnaires d'événements. | Ils sont souvent écrits comme des composants fonctionnels sans état. |
Pattern Adapter
Ce modèle permet à une interface d'une entité (telle qu'une classe ou un service) d'être utilisée comme une autre interface, en fournissant une API tierce qui peut être utilisée dans notre application. En d'autres termes, il fournit une API tierce pour nous, permettant d'intégrer facilement l'interface dans notre application. Voici un diagramme illustrant ce modèle :

Le modèle de conception de l'Adapter permet de proposer une interface tierce pour accéder à des fonctionnalités telles que la Soundfont Api et l'AudioContext. Grâce à cette API, il est possible d'intégrer facilement ces fonctionnalités dans notre application, même si elles ont été conçues avec une interface différente.
import { useState, useRef } from "react"
import Soundfont, { InstrumentName, Player } from "soundfont-player"
import { MidiValue } from "Domains/note"
import { Optional } from "Domains/types"
import {
AudioNodesRegistry,
DEFAULT_INSTRUMENT
} from "Domains/sound"
interface Settings {
AudioContext: AudioContextType
}
interface Adapted {
loading: boolean
current: Optional<InstrumentName>
load(instrument?: InstrumentName): Promise<void>
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
export function useSoundfont({ AudioContext }: Settings): Adapted {
let activeNodes: AudioNodesRegistry = {}
const [current, setCurrent] = useState<Optional<InstrumentName>>(
null
)
const [loading, setLoading] = useState<boolean>(false)
const [player, setPlayer] = useState<Optional<Player>>(null)
const audio = useRef(new AudioContext())
async function resume() {
return audio.current.state === "suspended"
? await audio.current.resume()
: Promise.resolve()
}
async function load(
instrument: InstrumentName = DEFAULT_INSTRUMENT
) {
setLoading(true)
const player = await Soundfont.instrument(
audio.current,
instrument
)
setLoading(false)
setCurrent(instrument)
setPlayer(player)
}
async function play(note: MidiValue) {
await resume()
if (!player) return
const node = player.play(note.toString())
activeNodes = { ...activeNodes, [note]: node }
}
async function stop(note: MidiValue) {
await resume()
if (!activeNodes[note]) return
activeNodes[note]!.stop()
activeNodes = { ...activeNodes, [note]: null }
}
return {
loading,
current,
load,
play,
stop
}
}
L'interface Adapted représente l'interface des fonctions fournies par notre adapter, y compris le type de retour attendu. Elle définit les méthodes qui peuvent être utilisées pour accéder aux fonctionnalités de la Soundfont Api et de l'AudioContext.
Les paramètres requis pour utiliser la fonction useSoundfont sont définis dans l'objet Settings. Cette configuration permet à notre adapter de connaître les informations nécessaires pour charger la Soundfont et l'utiliser correctement.
La fonction useSoundfont permet d'implémenter les méthodes fournies par l'interface Adapted. Elle utilise les paramètres fournis dans l'objet Settings pour charger la Soundfont et renvoie une instance de l'interface Adapted qui peut être utilisée pour accéder aux fonctionnalités de la Soundfont Api et de l'AudioContext.
import React, { FunctionComponent } from "react"
import { useAudioContext } from "../AudioContextProvider"
import { useSoundfont } from "Adapters/SoundFont"
import { useMount } from "../../utils/useMount"
import { Keyboard } from "../Keyboard"
import "./style.css"
export const KeyboardWithInstrument: FunctionComponent = () => {
const AudioContext = useAudioContext()!
const { loading, play, stop, load } = useSoundfont({ AudioContext })
useMount(load)
return <Keyboard loading={loading} play={play} stop={stop} />
}
Dans notre exemple, nous importons l'interface Adapted pour accéder aux méthodes fournies par l'API. En utilisant la fonction useSoundfont, nous pouvons récupérer les méthodes loading, play, stop et load, qui sont essentielles pour notre composant. En utilisant cette API, nous pouvons réutiliser ces fonctionnalités pour d'autres cas d'utilisation et pas seulement dans le composant lui-même.
Ce modèle de conception met l'accent sur la fourniture d'une API intermédiaire qui permet d'implémenter et de relier des objets ou des classes qui n'ont normalement pas de lien direct. Il permet ainsi de simplifier l'intégration de fonctionnalités tierces dans notre application et de favoriser la réutilisation du code.
Pattern Observer
La principale idée est de nous permettre de nous abonner à des évènements et de les gérer comme nous le voulons. Prenons le cas du keyboard : nous voulons nous abonner à l'évènement keyPress pour cela commençons par créer l'observer :
import { useEffect, useState } from "react"
import { Key as KeyLabel } from "Domains/keyboard"
type IsPressed = boolean
type EventCode = string
interface Settings {
watchKey: KeyLabel
onStartPress: Function
onFinishPress: Function
}
function fromEventCode(code: EventCode): KeyLabel {
const prefixRegex = /Key|Digit/gi
return code.replace(prefixRegex, "")
}
function equal(watchedKey: KeyLabel, eventCode: EventCode): boolean {
return (
fromEventCode(eventCode).toUpperCase() ===
watchedKey.toUpperCase()
)
}
export function usePressObserver({
watchKey,
onStartPress,
onFinishPress
}: Settings): IsPressed {
const [pressed, setPressed] = useState<IsPressed>(false)
useEffect(() => {
function handlePressStart({ code }: KeyboardEvent): void {
if (pressed || !equal(watchKey, code)) return
setPressed(true)
onStartPress()
}
function handlePressFinish({ code }: KeyboardEvent): void {
if (!pressed || !equal(watchKey, code)) return
setPressed(false)
onFinishPress()
}
document.addEventListener("keydown", handlePressStart)
document.addEventListener("keyup", handlePressFinish)
return () => {
document.removeEventListener("keydown", handlePressStart)
document.removeEventListener("keyup", handlePressFinish)
}
}, [watchKey, pressed, setPressed, onStartPress, onFinishPress])
return pressed
}
Lorsqu'un utilisateur appuie sur une touche, nous appelons la fonction handlePressStart pour gérer l'événement. Nous vérifions que cette touche n'a pas encore été pressée. Si tel est le cas, nous assignons la variable pressed à true et appelons la fonction onStartPress.
Lorsque l'utilisateur relâche la touche, nous appelons la fonction onFinishPress qui réinitialise la variable pressed à son état initial et appelle la fonction onFinishPress.
Il est important d'utiliser la méthode removeEventListener pour retirer les listeners des touches une fois qu'ils ne sont plus nécessaires. Si nous ne le faisons pas, cela peut rapidement entraîner des problèmes de mémoire car chaque touche a sa propre instance et crée donc un listener pour chacune d'entre elles.
Notre observer nous permet ici d'écouter des événements du clavier. Nous pourrions implémenter cette fonctionnalité directement dans notre composant, mais cela pourrait limiter notre flexibilité. En utilisant un observer, nous pouvons écouter des événements du clavier de manière générique et les réutiliser facilement dans d'autres parties de notre application.
Maintenant que nous avons compris comment cela fonctionne, voyons comment l'utiliser dans notre application.
import React, { FunctionComponent, ReactEventHandler } from "react"
import clsx from "clsx"
import { NoteType } from "Domains/note"
import { usePressObserver } from "Observers/PressObserver"
import "./style.css"
interface KeyProps {
type: NoteType
label: string
disabled?: boolean
onUp: ReactEventHandler<HTMLButtonElement>
onDown: ReactEventHandler<HTMLButtonElement>
}
export const Key: FunctionComponent<KeyProps> = ({
type,
label,
onDown,
onUp,
...rest
}) => {
const pressed = usePressObserver({
watchKey: label,
onStartPress: onDown,
onFinishPress: onUp
})
return (
<button
className={clsx(`key key--${type}`, pressed && "is-pressed")}
onMouseDown={onDown}
onMouseUp={onUp}
type="button"
{...rest}
>
{label}
</button>
)
}
Pattern Render props
Le concept de "render prop" se réfère à une technique de partage de code entre les composants React en utilisant une prop qui est une fonction. En termes simples, une prop de rendu est une prop d'un composant où vous pouvez passer une fonction qui doit renvoyer des éléments qui seront utilisés pour le rendu des composants. Cette technique permet de partager la logique interne d'un composant avec un autre, ce qui facilite le développement de composants réutilisables.
Pour illustrer cela, imaginons un exemple de composant avec une fonction de rendu :
<ExampleRenderPropsComponent
render={(name: string) => <div>Hello, {name}!</div>}
/>
En examinant de près, nous remarquons qu'il faut une fonction qui renvoie un autre composant React. Cependant, cette fonction ne rend pas seulement un composant, elle rend un composant avec un texte qui contient un nom. Ce nom est une valeur calculée à l'intérieur de ExampleRenderProps. Ainsi, cette fonction de rendu connecte les valeurs internes de ExampleRenderProps avec le monde extérieur en exposant cette valeur interne au monde extérieur. La meilleure partie est que nous pouvons décider de ce que nous voulons partager avec le monde extérieur et de ce que nous ne voulons pas partager. Nous pourrions avoir 100 valeurs internes à l'intérieur de ExampleRenderProps, mais n'en exposer qu'une.
De cette manière, nous pouvons encapsuler la logique en un seul endroit - ExampleRenderProps - mais partager certaines fonctionnalités avec différents composants. Cela permet de réduire la duplication de code et d'améliorer la réutilisabilité des composants. En utilisant des props de rendu, nous pouvons facilement transmettre des fonctions entre les composants et partager des fonctionnalités communes.
<ExampleRenderPropsComponent
render={(name: string) => <Greetings name={name} />}
/>
<ExampleRenderPropsComponent
render={(name: string) => <Farewell name={name} />}
/>
En utilisant un composant fonctionnel, nous pouvons créer le pattern de "render prop" en définissant une fonction de rendu à l'intérieur du composant. Cette fonction de rendu peut prendre des props et renvoyer des éléments qui seront utilisés pour le rendu. Nous pouvons ensuite transmettre cette fonction en tant que prop à d'autres composants pour leur permettre de réutiliser cette fonctionnalité.
Voici un exemple de code utilisant un composant fonctionnel pour implémenter le pattern de "render prop" :
import {
ReactElement,
FunctionComponent,
useState,
useEffect,
useRef,
useCallback
} from "react"
import Soundfont, { InstrumentName, Player } from "soundfont-player"
import { MidiValue } from "Domains/note"
import { Optional } from "Domains/types"
import {
AudioNodesRegistry,
DEFAULT_INSTRUMENT
} from "Domains/sound"
interface ProvidedProps {
loading: boolean
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
interface ProviderProps {
instrument?: InstrumentName
AudioContext: AudioContextType
render(props: ProvidedProps): ReactElement
}
export const SoundfontProvider: FunctionComponent<ProviderProps> = ({
AudioContext,
instrument,
render
}) => {
let activeNodes: AudioNodesRegistry = {}
const [current, setCurrent] = useState<Optional<InstrumentName>>(
null
)
const [loading, setLoading] = useState<boolean>(false)
const [player, setPlayer] = useState<Optional<Player>>(null)
const audio = useRef(new AudioContext())
const loadInstrument = useCallback(() => load(instrument), [
instrument
])
useEffect(() => {
if (!loading && instrument !== current) loadInstrument()
}, [loadInstrument, loading, instrument, current])
async function resume() {
return audio.current.state === "suspended"
? await audio.current.resume()
: Promise.resolve()
}
async function load(
instrument: InstrumentName = DEFAULT_INSTRUMENT
) {
setLoading(true)
const player = await Soundfont.instrument(
audio.current,
instrument
)
setLoading(false)
setCurrent(instrument)
setPlayer(player)
}
async function play(note: MidiValue) {
await resume()
if (!player) return
const node = player.play(note.toString())
activeNodes = { ...activeNodes, [note]: node }
}
async function stop(note: MidiValue) {
await resume()
if (!activeNodes[note]) return
activeNodes[note]!.stop()
activeNodes = { ...activeNodes, [note]: null }
}
return render({
loading,
play,
stop
})
}
Par une classe
import { Component, ReactElement } from "react"
import Soundfont, { InstrumentName, Player } from "soundfont-player"
import { MidiValue } from "Domains/note"
import { Optional } from "Domains/types"
import {
AudioNodesRegistry,
DEFAULT_INSTRUMENT
} from "Domains/sound"
interface ProvidedProps {
loading: boolean
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
interface ProviderProps {
instrument: InstrumentName
AudioContext: AudioContextType
render(props: ProvidedProps): ReactElement
}
interface ProviderState {
loading: boolean
current: Optional<InstrumentName>
}
export class SoundfontProvider extends Component<
ProviderProps,
ProviderState
> {
public static defaultProps = {
instrument: DEFAULT_INSTRUMENT
}
private audio: AudioContext
private player: Optional<Player> = null
private activeNodes: AudioNodesRegistry = {}
public state: ProviderState = {
loading: false,
current: null
}
private resume = async () => {
return this.audio.state === "suspended"
? await this.audio.resume()
: Promise.resolve()
}
private load = async (instrument: InstrumentName) => {
this.setState({ loading: true })
this.player = await Soundfont.instrument(this.audio, instrument)
this.setState({ loading: false, current: instrument })
}
constructor(props: ProviderProps) {
super(props)
const { AudioContext } = this.props
this.audio = new AudioContext()
}
public componentDidMount() {
const { instrument } = this.props
this.load(instrument)
}
public shouldComponentUpdate({ instrument }: ProviderProps) {
return this.state.current !== instrument
}
public componentDidUpdate({
instrument: prevInstrument
}: ProviderProps) {
const { instrument } = this.props
if (instrument && instrument !== prevInstrument)
this.load(instrument)
}
public play = async (note: MidiValue) => {
await this.resume()
if (!this.player) return
const node = this.player.play(note.toString())
this.activeNodes = { ...this.activeNodes, [note]: node }
}
public stop = async (note: MidiValue) => {
await this.resume()
if (!this.activeNodes[note]) return
this.activeNodes[note]!.stop()
this.activeNodes = { ...this.activeNodes, [note]: null }
}
public render() {
const { render } = this.props
const { loading } = this.state
return render({
loading,
play: this.play,
stop: this.stop
})
}
}
En utilisant le pattern de "render prop", nous avons créé une fonctionnalité que nous pouvons fournir à d'autres composants sous forme de prop. Cela permet aux autres composants d'utiliser cette fonctionnalité comme une sorte de bibliothèque de fonctions pour leur propre logique.
<SoundfontProvider AudioContext={AudioContext} instrument={instrument}>
{(props) => <Keyboard {...props} />}>
</SoundfontProvider>
Il existe certaines limites à l'utilisation du pattern de "render prop".
Tout d'abord, l'utilisation de props de rendu peut ajouter un ou deux niveaux d'imbrication supplémentaires dans un composant qui l'utilise, ce qui peut rendre le code plus complexe à lire et à comprendre.
De plus, le pattern de "render prop" nécessite un rendu pour être appelé, ce qui signifie que le code ne peut pas être utilisé en dehors de l'environnement de rendu de React. Cela peut limiter sa capacité à être réutilisé dans des contextes non-React.
Enfin, l'utilisation excessive de props de rendu peut rendre le code plus difficile à maintenir et à déboguer, car il peut être difficile de suivre le flux de données à travers les différentes fonctions de rendu.
Malgré ces limitations, le pattern de "render prop" peut être une solution utile et flexible pour la réutilisation de code dans les composants React, en permettant de fournir des fonctionnalités communes à différents composants sans duplication de code.
Higher Order Components
Commençons par examiner à quoi ressemble un HoC (Higher-order Component).
const HoC = Component => EnhancedComponent
Les HOC (Higher-order Components) sont des fonctions qui prennent un composant en entrée et renvoient un composant amélioré en sortie.
Voici un exemple simple pour illustrer à quoi ressemble un composant amélioré créé avec un HOC.
Supposons que nous devions ajouter une propriété commune à tous nos composants pour une raison quelconque. Nous pourrions ajouter cette propriété à chaque méthode manuellement, mais cela pourrait être fastidieux. Au lieu de cela, nous pouvons créer un HOC qui ajoute automatiquement cette propriété à chaque composant qu'il enveloppe, comme suit :
const withClassName = Component => props => (
<Component {...props} className="my-class" />
)
Dans la communauté React, il est courant d'utiliser le préfixe "with" pour les HOC. Le code précédent peut sembler complexe au premier abord, examinons-le ensemble.
Nous définissons une fonction qui prend une fonction en entrée et renvoie une autre fonction en sortie. La fonction renvoyée est un composant fonctionnel qui reçoit des props et renvoie le composant d'origine. Les props collectées sont ensuite réparties et une propriété avec une certaine valeur est transmise au composant fonctionnel.
Ce code est assez simple et pas très utile en soi, mais il nous permet de mieux comprendre ce que sont les HOC et à quoi ils ressemblent. Voyons maintenant comment nous pouvons utiliser le HOC dans nos composants.
const MyComponent = ({ className }) => (
<div className={className} />
)
Comme vous pouvez le constater, en utilisant les HOC, nous pouvons prendre un composant en entrée, puis renvoyer un nouveau composant avec des fonctionnalités supplémentaires.
Au lieu d'utiliser le composant directement, nous le transmettons à un HOC, comme ceci :
const MyComponentWithClassName = withClassName(MyComponent)
En enveloppant nos composants dans la fonction HOC, nous nous assurons qu'ils reçoivent les props supplémentaires dont ils ont besoin pour fonctionner.
Pour illustrer l'utilisation des HOC, prenons l'exemple de la connexion du composant Keyboard à notre SoundFont.
Nous pouvons créer un HOC qui fournit les fonctionnalités de SoundFont à Keyboard. Ce HOC peut prendre en entrée le composant Keyboard, ajouter les props nécessaires pour utiliser SoundFont, et renvoyer un nouveau composant Keyboard amélioré avec ces fonctionnalités supplémentaires.
Voici à quoi pourrait ressembler notre HOC :

Pour notre SoundFont, l'API reste la même que précédemment. Cependant, nous allons maintenant utiliser le terme "InjectedProps" au lieu de "ProvidedProps", car nous allons injecter ces props dans un composant qui sera amélioré par notre HOC.
interface InjectedProps {
loading: boolean
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
interface ProviderProps {
AudioContext: AudioContextType
instrument: InstrumentName
}
interface ProviderState {
loading: boolean
current: Optional<InstrumentName>
}
Ensuite, nous créons une fonction "withInstrument()" qui prend un composant à améliorer en entrée. Nous rendons cette fonction générique en utilisant les generics de TypeScript, afin de spécifier quels props vont être injectés. Dans la communauté React, il est courant de préfixer les HOC avec "with".
Voici à quoi ressemblerait notre HOC withInstrument() :
export function withInstrument<
TProps extends InjectedProps = InjectedProps
>(WrappedComponent: ComponentType<TProps>) {
Le type générique "TProps" doit avoir des propriétés qui correspondent à celles définies dans le type "InjectedProps", sinon TypeScript nous donnera une erreur.
Notez également que, par défaut, nous définissons "TProps" comme étant de type "InjectedProps" en utilisant le signe "=" lors de la définition du type générique. Cela fonctionne exactement comme les valeurs par défaut pour les arguments dans les fonctions.
Ensuite, nous créons une classe "WithInstrument" que nous renvoyons. Cette classe sera le composant conteneur qui améliorera notre "WrappedComponent".
public render() {
const injected = {
loading: this.state.loading,
play: this.play,
stop: this.stop
} as InjectedProps
return (
<WrappedComponent {...this.props} {...(injected as TProps)} />
)
}
Ici, au lieu d'appeler "this.props.render()" et de passer un objet avec des valeurs et des méthodes, nous retournons directement notre "WrappedComponent" en y injectant ces valeurs et méthodes. Notez que nous avons d'abord propagé les props du composant d'origine en utilisant "this.props", puis injecté les fonctionnalités supplémentaires. Cela garantit que les props injectés ne seront pas écrasés par d'autres props.
Maintenant que notre HOC est créé, nous pouvons l'utiliser avec notre composant Keyboard :
import React, { FunctionComponent } from "react"
import { useInstrument } from "States/Instrument"
import { withInstrument } from "Adapters/SoundFont/withInstrument"
import { useAudioContext } from "../AudioContextProvider"
import { Keyboard } from "../Keyboard"
import "./style.css"
const WrappedKeyboard = withInstrument(Keyboard)
export const KeyboardWithInstrument: FunctionComponent = () => {
const AudioContext = useAudioContext()!
const { instrument } = useInstrument()
return (
<WrappedKeyboard
AudioContext={AudioContext}
instrument={instrument}
/>
)
}
Ici, nous pouvons voir comment utiliser "withInstrument()". Nous prenons un composant "Keyboard" qui a besoin des props suivants :
- "loading"
- Les méthodes "play()" et "stop()"
Et nous renvoyons "WrappedKeyboard", qui a besoin de :
- "AudioContext"
- Un props "instrument" (facultatif)
Ceci est possible car un "Keyboard" devient un "WrappedComponent" lorsqu'on appelle "withInstrument()". "WrappedKeyboard" est essentiellement une instance de la classe "WithInstrument" qui rend un "Keyboard" avec des props injectés "mémorisés". À ce stade, lorsque nous affichons "WrappedComponent", il a déjà les props "loading" et les méthodes "play()" et "stop()", car ils ont été injectés auparavant en tant que "InjectedProps".
Les HOC ont des avantages, tels que la possibilité de composer statiquement des arguments pour le futur (cependant, cela peut également être fait avec d'autres modèles tels que le modèle factory), et sont une implémentation littérale d'un modèle décorateur.
Cependant, les HOC ont également des inconvénients, tels qu'une stratégie de typage non évidente avec la présence de génériques, un typage "à la volée" et un niveau de difficulté global plus élevé. De plus, les HOC peuvent devenir trop verbeux.





