Modal de confirmation avec React

Voir la vidéo
Description Sommaire

Dans cette vidéo je vous propos de découvrir comment gérer l'affichage de message de confirmation dans une application React. On va chercher à développer une approche qui soit réutilisable et pratique à utiliser.

00:00 Présentation
00:20 Approche React (pas réutilisable)
01:35 Objectif
02:22 Solution 1: Composant Global
09:50 Solution 2: Contexte

Approche de base

Pour l'exemple on imagine un composant qui permet d'effectuer une action lorsque l'on clique sur un bouton.

function MyComponent() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount((n) => n + 1);
  };

  return (
    <>
      <p>Compteur : {count}</p>
      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        <button onClick={increment}>Incrémenter</button>
      </div>
    </>
  );
}

Mais on souhaite obtenir la confirmation de l'utilisateur pour effectuer l'action.
L'approche naturelle consisterait à utiliser un état pour savoir si on doit afficher ou non le message de confirmation.

function MyComponent() {
  const [count, setCount] = useState(0);
  const [confirm, setConfirm] = useState(false);

  const startConfirm = () => {
    setConfirm(true);
  };

  const increment = () => {
    setCount((n) => n + 1);
    setConfirm(false);
  };

  const cancelConfirm = () => {
    setConfirm(false);
  };

  return (
    <>
      <p>Compteur : {count}</p>
      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        <button onClick={startConfirm}>Incrémenter</button>
      </div>
      <ConfirmDialog
        onConfirm={increment}
        onCancel={cancelConfirm}
        open={confirm}
      />
    </>
  );
}

Cette approche fonctionne pour un cas simple mais n'est pas assez réutilisable et nécessite pas mal de code. Une approche plus impérative semblerait plus naturel et ne nécessiterait que peu de code à l'utilisation.

const increment = () => {
    if (await confirm({ title: "Voulez vous vraiment incrémenter ?" })) {
        setCount((n) => n + 1)
    }
};

On pourrait même créer une fonction d'ordre supérieur withConfirm() pour encore plus simplifier l'écriture.

Implémentation

Via une variable globale

Cette première approche permet de se passer d'un contexte en utilisant une variable globale qui sera altérée par un composant global.

// Cette variable servira de "ref"
const confirmAction = {
  current: (p) => Promise.resolve(true),
};

export function confirm(props) {
  return confirmAction.current(props);
}

Notre fonction utilisera la valeur de la "ref" ce qui permettra de modifier sans modifier la fonction (on modifiera confirmAction.current). On va donc créer un composant global qui viendra justement changer le comportement de confirmAction.

export function ConfirmGlobal() {
  const [open, setOpen] = useState(false);
  const [props, setProps] = useState({});
  // On sauvegarde la fonction de résolution de la promesse
  const resolveRef = useRef((v) => {});
  // On modifie confirmAction pour le connecter à notre composant
  confirmAction.current = (props) =>
    new Promise((resolve) => {
      setProps(props);
      setOpen(true);
      resolveRef.current = resolve;
    });

  const onConfirm = () => {
    resolveRef.current(true);
    setOpen(false);
  };

  const onCancel = () => {
    resolveRef.current(false);
    setOpen(false);
  };

  return (
    <ConfirmDialog
      onConfirm={onConfirm}
      onCancel={onCancel}
      open={open}
      {...props}
    />
  );
}

Pour que cette approche fonctionne il faudra avoir ce composant <ConfirmGlobal> monté dans notre application pour que la fonction de confirmation déclenche l'affichage de la boîte modale.

<>
  <ConfirmGlobal>
  <App/>
</>

Avec un contexte

Si on souhaite éviter l'utilisation d'une variable globale (et pouvoir changer le comportement) il est possible d'utiliser un contexte pour sauvegarder l'action de confirmation

type Params = Partial<
  Omit<ComponentProps<typeof ConfirmDialog>, "open" | "onConfirm" | "onCancel">
>;

const defaultFunction = (p?: Params) => Promise.resolve(true); // En l'absence de contexte, on renvoie true directement

const defaultValue = {
  confirmRef: {
    current: defaultFunction,
  },
};

const ConfirmContext = createContext(defaultValue);

// On devra entourer notre application avec ce context provider
export function ConfirmContextProvider({ children }: PropsWithChildren) {
  const confirmRef = useRef(defaultFunction);
  return (
    <ConfirmContext.Provider value={{ confirmRef }}>
      {children}
      <ConfirmDialogWithContext />
    </ConfirmContext.Provider>
  );
}

function ConfirmDialogWithContext() {
  const [open, setOpen] = useState(false);
  const [props, setProps] = useState<undefined | Params>();
  const resolveRef = useRef((v: boolean) => {});
  const { confirmRef } = useContext(ConfirmContext);
  confirmRef.current = (props) =>
    new Promise((resolve) => {
      setProps(props);
      setOpen(true);
      resolveRef.current = resolve;
    });

  const onConfirm = () => {
    resolveRef.current(true);
    setOpen(false);
  };

  const onCancel = () => {
    resolveRef.current(false);
    setOpen(false);
  };
  return (
    <ConfirmDialog
      onConfirm={onConfirm}
      onCancel={onCancel}
      open={open}
      {...props}
    />
  );
}

// Ce hook nous permettra d'accéder à la fonction confirm
export function useConfirm() {
  const { confirmRef } = useContext(ConfirmContext);
  return {
    confirm: useCallback(
      (p: Params) => {
        return confirmRef.current(p);
      },
      [confirmRef]
    ),
  };
}

On peut ensuite entourer notre application via le provider.

<ConfirmContextProvider>
  <App />
</ConfirmContextProvider>

Ensuite, dans notre composant on peut utiliser la méthode confirm récupérée depuis notre hook.

function MyComponent() {
  const [count, setCount] = useState(0);
  const { confirm } = useConfirm();

  const increment = async () => {
    if (await confirm({ title: "Voulez vous vraiment incrémenter ?" })) {
      setCount((n) => n + 1);
    }
  };

  return (
    <>
      <p>Compteur : {count}</p>
      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        <button onClick={increment}>Incrémenter</button>
      </div>
    </>
  );
}

Et voila !

Pourquoi ne pas créer un contexte classique ?

On peut se demander pourquoi on n'a pas utiliser un contexte classique où on aurait sauvegardé l'état de la boîte de confirmation. Le problème d'une telle approche est que le changement d'état de la confirmation entrainerait un changement de valeur du contexte. Ce qui engendrerait un rerendu pour tous les consommateurs de ce contexte (ce qui est contre-intuitif).

Publié
Technologies utilisées
Auteur :
Grafikart
Partager