Skip to content

Evènements personnalisés

Les composants permettent de découper une application en blocs distincts. Ils facilitent ainsi la séparation des préoccupations, chaque composant étant responsable d'une partie précise de l'interface.

Dans ce découpage, il arrive parfois que l'on souhaite permettre à un composant enfant de modifier des données gérées par un composant parent. Dans ce chapitre, nous allons voir comment créer des événements personnalisés pour répondre à cette problématique et ainsi aller plus loin dans les relations entre nos composants.

Flux de données unidirectionel

Le principe de flux de données unidirectionel est un principe fondamental des frameworks JavaScript modernes. Il stipule que les données réactives d'une application ne doivent se propager que dans une seule direction : des composants parents vers les composants enfants.

C'est ce que nous mettons en œuvre lorsque nous définissons une props, qui permet la récupération d'une donnée d'un parent vers un enfant. Le principe sous-entend également que si la valeur d'une donnée est modifiée, cela affectera d'abord le composant qui a défini la donnée, avant de se propager au niveau de ses composants enfants, s'ils reçoivent la donnée en props.

Cette règle peut paraître anodine, mais elle est importante pour comprendre l'ordre dans lequel s'exécutent les actions lorsqu'un changement d'état a lieu sur une donnée :

jsx
import { useState, useEffect } from "react";

function Child({ count }) {
  useEffect(() => {
    console.log("Update, from child");
  }, [count]);

  return <p>{count}</p>;
}

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Update, from parent");
  }, [count]);

  const increment = () => setCount((oldCount) => oldCount + 1);

  return (
    <div>
      <Child count={count} />
      <button onClick={increment}>Add</button>
    </div>
  );
}

Dans cet exemple, la donnée count est transmise d'un composant parent à un composant enfant. Si l'état de la donnée change, cela affectera d'abord le composant parent avant d'affecter l'enfant, comme le montre l'ordre d'exécution des observateurs.

INFO

👉 Cet ordre de résolution est directement lié à la façon dont le framework gère la mise à jour des données dans le cycle de vie des composants, comme nous l'avons vu au chapitre précédent.

Admettons maintenant que l'on ait besoin d'inverser la structure et que ce soit le composant enfant qui soit chargé d'éditer la donnée, et le parent de l'afficher :

jsx
import { useState, useEffect } from "react";

function Child({ count }) {
  const increment = () => {/* ??? */}; 

  useEffect(() => {
    console.log("Update from child");
  }, [count]);

  return <button onClick={increment}>Add</button>;
}

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Update from parent");
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <Child count={count} />
    </div>
  );
}

Le composant enfant ne peut pas directement modifier la props qu'il reçoit de son parent. Cela sous-entendrait qu'il est affecté par le changement d'état avant le composant parent. Nous entrerions alors en conflit avec le principe de flux de données unidirectionel.

INFO

Il est souvent difficile de comprendre l'intérêt du flux de données unidirectionel lorsque l'on débute avec un framework JavaScript. Le principe devient avant tout utile lorsque l'application se complexifie et que les changements d'état se multiplient dans l'application. Il permet alors de maintenir une lecture claire de l'ordre dans lequel s'effectuent les actions dans l'application en réponse à un changement d'état. Le principe est également nécessaire au bon fonctionnement de la réactivité telle qu'elle est implémentée par le framework.

Evènement personnalisés

Les événements personnalisés permettent à un composant parent de réagir à une action effectuée par un composant enfant. Ce mécanisme peut donc être utilisé pour qu'un composant enfant indique à son parent quand il est nécessaire de modifier certains états à sa place :

jsx
import { useState, useEffect } from "react";

function Child({ count, onIncrement }) {
  useEffect(() => {
    console.log("Update from child");
  }, [count]);

  return <button onClick={onIncrement}>Add</button>;
}

export default function App() {
  const [count, setCount] = useState(0);

  const increment = () => setCount((oldCount) => oldCount + 1);

  useEffect(() => {
    console.log("Update from parent");
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <Child count={count} onIncrement={increment} />
    </div>
  );
}

Le composant enfant définit une nouvelle props chargée de transmettre une fonction à exécuter si un incrément est effectué. C'est notre évènement personnalisé. Le composant parent n'a plus qu'à fournir la fonction d'incrément à son composant enfant pour qu'elle s'exécute lorsque l'événement se produit.

À aucun moment la props count n'est directement modifiée par le composant enfant. La responsabilité du changement d'état est toujours reportée au composant parent qui centralise les actions permettant de modifier la donnée. Le changement d'état se propage ensuite au composant enfant dont la props change de valeur.

TIP

💡 On retrouve ici finalement une syntaxe similaire à celle du traitement des événements JavaScript natifs.

Les parents sont toujours responsables

Lorsque plusieurs composants exploitent une même donnée, l'instanciation de la donnée doit toujours être confiée au plus proche parent commun de tous ces composants.

C'est le cas lorsque la donnée est utilisée par un parent et un enfant. Mais c'est également le cas pour des composants frères :

jsx
import { useState } from "react";

function Child1({ count }) {
  return <p>{count}</p>;
}

function Child2({ onIncrement }) {
  return <button onClick={onIncrement}>Add</button>;
}

export default function App() {
  const [count, setCount] = useState(0);
  const increment = () => setCount((oldCount) => oldCount + 1);

  return (
    <div>
      <Child1 count={count} />
      <Child2 onIncrement={increment} />
    </div>
  );
}

Dans cet exemple, la définition et la gestion du changement d'état sont confiées au composant parent, même s'il n'utilise pas directement la donnée count. Il joue le rôle de parent le plus proche entre les composants utilisant la donnée.

Cette méthode a l'avantage de regrouper les fonctions responsables de la modification de la donnée. Il est toujours préférable de regrouper la définition des actions permettant d'éditer une donnée. Cela permet d'ajouter plus facilement des logiques complémentaires lors de la modification, comme une étape de vérification de la nouvelle valeur, ou l'actualisation du cache comme vu précédemment.

NOTE

📌 L'implémentation d'événements personnalisés peut devenir complexe si les composants qui utilisent la donnée deviennent très distants les uns des autres dans le découpage du projet. Dans ce cas, il peut être préférable de passer par l'implémentation d'un store, qui gérera l'état de la donnée à la place des composants.
Lorsque les composants concernés sont des parents proches, il faut toutefois privilégier l'usage des événements personnalisés, afin de maintenir si possible la gestion de la donnée à une échelle locale.

🐶 Dog Center

Avec les différents ajouts dans l'application, le code de notre composant principal est devenu assez volumineux :

jsx
import "./App.css";
import { useState, useMemo, useEffect } from "react";
import { getDogsData } from "./services/api";
import NewsSlider from "./components/NewsSlider";
import DogCard from "./components/DogCard";

export default function App() {
  // Dogs data
  const [dogsData, setDogData] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const data = await getDogsData();
      setDogData(data);
    }
    fetchData();
  }, [])

  // Options
  const [search, setSearch] = useState(
    localStorage.getItem("search") || ""
  );
  const [dogsSortBy, setDogsSortBy] = useState(
    localStorage.getItem("dogsSortBy") || "age"
  );

  useEffect(() => {
    localStorage.setItem("search", search)
  }, [search])

  useEffect(() => {
    localStorage.setItem("dogsSortBy", dogsSortBy)
  }, [dogsSortBy])

  // data filtering
  const filteredDogsData = useMemo(() => {
    let result = dogsData.filter((dog) =>
      dog.name.toLowerCase().includes(search.toLowerCase())
    );
    result = result.toSorted((a, b) => {
      if (dogsSortBy === "age") {
        // age can be null
        return (a.age || 0) - (b.age || 0);
      } else {
        // sort in alphabetical order
        return a.name.localeCompare(b.name);
      }
    });
    return result;
  }, [dogsData, search, dogsSortBy]);
  
  return (
    <div>
      <h1>Dog Center</h1>
      <NewsSlider />
      <div id="gallery-options">
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="Search dog"
        />
        <label htmlFor="dog-sort">Sort by : </label>
        <select
          id="dog-sort"
          value={dogsSortBy}
          onChange={(e) => setDogsSortBy(e.target.value)}
        >
          <option value="age">Age</option>
          <option value="name">Name</option>
        </select>
      </div>
      <div id="dog-gallery">
        {filteredDogsData.map((dog) => (
          <DogCard
            key={dog.id}
            name={dog.name}
            age={dog.age}
            breed={dog.breed}
            pictureUrl={dog.pictureUrl}
            soundUrl={dog.soundUrl}
          />
        ))}
      </div>
    </div>
  );
};

Notre composant gère désormais plusieurs préoccupations en même temps qui rendent plus complexe l'appréhension du code, et qui mériteraient d'être séparées :

  • La définition et la récupération des données des chiens (dogsData)
  • La gestion des options de recherche dans la galerie
  • Le filtrage des chiens en fonction des options
  • L'affichage du contenu de la galerie

En utilisant les événements personnalisés, nous pouvons définir un nouveau composant enfant GalleryOptions auquel nous pourrons déléguer la gestion des options. Nous en profitons pour définir un autre composant GalleryContent qui se chargera du filtrage des chiens et de l'affichage de la galerie.

jsx
export default function GalleryOptions({
  search,
  dogsSortBy,
  onSearchChange,
  onDogsSortByChange,
}) {
  return (
    <div id="gallery-options">
      <input
        type="text"
        value={search}
        onChange={(e) => onSearchChange(e.target.value)}
        placeholder="Search dog"
      />
      <label htmlFor="dog-sort">Sort by : </label>
      <select
        id="dog-sort"
        value={dogsSortBy}
        onChange={(e) => onDogsSortByChange(e.target.value)}
      >
        <option value="age">Age</option>
        <option value="name">Name</option>
      </select>
    </div>
  );
};
jsx
import { useMemo } from "react";
import DogCard from "./DogCard";

export default function GalleryContent({ dogsData, search, dogsSortBy }) {
  const filteredDogsData = useMemo(() => {
    let result = dogsData.filter((dog) =>
      dog.name.toLowerCase().includes(search.toLowerCase())
    );
    result = result.toSorted((a, b) => {
      if (dogsSortBy === "age") {
        // age can be null
        return (a.age || 0) - (b.age || 0);
      } else {
        // sort in alphabetical order
        return a.name.toLocaleCompare(b.name);
      }
    });
    return result;
  }, [dogsData, search, dogsSortBy]);

  return (
    <div id="dog-gallery">
      {filteredDogsData.map((dog) => (
        <DogCard
          key={dog.id}
          name={dog.name}
          age={dog.age}
          breed={dog.breed}
          pictureUrl={dog.pictureUrl}
          soundUrl={dog.soundUrl}
        />
      ))}
    </div>
  );
};
jsx
import { useState, useEffect } from "react";
import { getDogsData } from "./services/api";
import NewsSlider from "./components/NewsSlider";
import GalleryOptions from "./components/GalleryOptions";
import GalleryContent from "./components/GalleryContent";

export default function App() {
  // Dogs data
  const [dogsData, setDogData] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const data = await getDogsData();
      setDogData(data);
    }
    fetchData();
  }, [])

  // Options
  const [search, setSearch] = useState(
    localStorage.getItem("search") || ""
  );
  const [dogsSortBy, setDogsSortBy] = useState(
    localStorage.getItem("dogsSortBy") || "age"
  );

  useEffect(() => {
    localStorage.setItem("search", search)
  }, [search])

  useEffect(() => {
    localStorage.setItem("dogsSortBy", dogsSortBy)
  }, [dogsSortBy])
  
  return (
    <div>
      <h1>Dog Center</h1>
      <NewsSlider />
      <GalleryOptions
        search={search}
        dogsSortBy={dogsSortBy}
        onSearchChange={setSearch}
        onDogsSortByChange={setDogsSortBy}
      />
      <GalleryContent
        dogsData={dogsData}
        search={search}
        dogsSortBy={dogsSortBy}
      />
    </div>
  );
};

Le découpage en différents composants simplifie l'appréhension de chaque partie de l'application et rend chacune plus facilement maintenable.

Conclusion

Les événements personnalisés offrent une plus grande flexibilité dans la façon dont les composants enfants peuvent interagir avec leur parent. Ils permettent, dans certains cas, d'aller plus loin dans le découpage en composants et ainsi avoir une meilleure séparation des préoccupations.

Dans le prochain chapitre, nous aborderons un sujet tout à fait différent en voyant comment créer plusieurs pages au sein d'une application frontend. Nous verrons pour cela la notion de routage frontend.