Quando evitare gli effetti collaterali in React | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Quando evitare gli effetti collaterali in React

Gli effetti collaterali sono il concetto più mal interpretato e impropriamente usato di React. Vediamo una lista di casi in cui potresti essere tentato ad usare un effetto collaterale, e come evitare di farlo. In generale, ci sono due casi in cui gli effetti collaterali non servono mai: per trasformare i dati in JSX per…

Lezione 32 / 41
Enza Neri
Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

Gli effetti collaterali sono il concetto più mal interpretato e impropriamente usato di React. Vediamo una lista di casi in cui potresti essere tentato ad usare un effetto collaterale, e come evitare di farlo.

In generale, ci sono due casi in cui gli effetti collaterali non servono mai:

  • per trasformare i dati in JSX
  • per gestire eventi

Vediamoli!

Trasformare i dati in JSX

Immaginiamo di voler filtrare una lista prima di mostrarla. La lista arriva tramite prop da un componente controller e noi vogliamo mostrare solo alcuni degli elementi. Potremmo essere tentati di creare uno stato, che rappresenta la “lista filtrata”, e utilizzare un effetto collaterale per modificarla ogni volta che cambia la lista originale (che sarebbe la prop):

export function MyList({ items }) {
  const [filteredItems, setFilteredItems] = useState(items);
  const [chosenCategory, setChosenCategory] = useState("all");

  useEffect(() => {
    if (chosenCategory === "all") {
      setFilteredItems(items);
    } else {
      const filteredItems = items.filter((item) => {
        return item.category === chosenCategory;
      });

      setFilteredItems(filteredItems);
    }
  }, [items, chosenCategory]);

  // ...
}

In questo esempio, abbiamo due stati interni, che rappresentano una categoria scelta dall’utente e la lista filtrata per categoria. Quando la categoria scelta dall’utente (che possiamo immaginare essere scelta tramite un menù a tendina) cambia, scateniamo un effetto collaterale che modifica la lista filtrata. Allo stesso modo, quando la lista di partenza (items, che arriva tramite props) cambia, scateniamo lo stesso effetto collaterale.

Vediamo come React gestirebbe i cicli di rendering in questo caso:

  1. Ciclo di rendering iniziale.
  2. Ciclo di rendering quando viene chiamata la funzione setter setChosenCategory, oppure quando si aggiorna la prop items.
  3. Ciclo di rendering subito dopo il secondo, perché i cambiamenti di chosenCategory e items scatenano useEffect, che a sua volta chiama setFilteredItems.

Il terzo ciclo di rendering non è necessario! filteredItems dipende direttamente da items e il nostro componente MyList viene già chiamato al rendering ogni volta che items si aggiorna:

export function MyList({ items }) {
  const [chosenCategory, setChosenCategory] = useState("all");

  const filteredItems = (() => {
    if (chosenCategory === "all") {
      return items;
    } else {
      return filteredItems.filter((item) => {
        return item.category === chosenCategory;
      });
    }
  })();

  // ...
}

Gestire eventi

Il secondo caso generico è la gestione di operazioni che dipendono direttamente da eventi scatenati dall’utente. Per esempio, immaginiamo di avere un hook useNotification che ci permette di mostrare una notifica, e un bottone “Compra”: quando il bottone “Compra” viene attivato, mandiamo una richiesta di rete di tipo command. Se tutto va bene, inviamo una notifica di successo per far sapere all’utente che tutto è andato a buon fine. Utilizziamo il nostro hook useCommand per l’esempio:

export default function BuyButton({ product }) {
  const [buyRequestNetworkState, sendBuyRequest] = useCommand({
    path: "/products/buy",
    method: "POST",
  });

  const { showNotification } = useNotification();

  useEffect(() => {
    if (buyRequestNetworkState.isSuccessful()) {
      showNotification({
        type: "success",
        message: "You just bought the product!",
      });
    }
  }, [buyRequestNetworkState]);

  return (
    <button
      disabled={buyRequestNetworkState.isLoading()}
      onClick={() => sendBuyRequest(product)}
    >
      Buy
    </button>
  );
}

In questo esempio, al click del bottone che dice “Buy”, inviamo una richiesta di tipo POST a una API al percorso /products/buy. Utilizziamo un effetto collaterale per mostrare la notifica di successo ogni volta che la richiesta di rete buyRequestNetworkState viene aggiornata. Se la richiesta è andata a buon fine, mostriamo la notifica.

Anche in questo caso, stiamo scatenando un ciclo di rendering in più rispetto a quello che ci serve. Possiamo rimuovere l’effetto collaterale e chiamare showNotification direttamente dentro una funzione per gestire l’evento, sfruttando il fatto che sendBuyRequest restituisce una Promise:

export default function BuyButton({ product }) {
  const [buyRequestNetworkState, sendBuyRequest] = useCommand({
    path: "/products/buy",
    method: "POST",
  });

  const { showNotification } = useNotification();

  const onButtonClick = () => {
    sendBuyRequest(product).then(() => {
      showNotification({
        type: "success",
        message: "You just bought the product!",
      });
    });
  };

  return (
    <button
      disabled={buyRequestNetworkState.isLoading()}
      onClick={onButtonClick}
    >
      Buy
    </button>
  );
}

Questi erano due casi generici in cui si può evitare di utilizzare effetti collaterali, ma non temere, abbiamo anche un sacco di esempi specifici! Vediamone un paio!

Aggiornare stati in base a props o altri stati

Un esempio classico: abbiamo nome e cognome, vogliamo metterli insieme per ottenere il nome completo. Potresti essere tentato di fare una cosa del genere:

export default function Form() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(firstName + " " + lastName);
  }, [firstName, lastName]);

  // ...
}

Aggiungendo fullName come stato, dobbiamo, poi, preoccuparci di tenerlo sincronizzato con i due stati da cui deriva (firstName e lastName), ma non ci serve farlo.

Anche in questo caso, possiamo risparmiare uno stato e un ciclo di rendering utilizzando una banale costante. In più, firstName e lastName descrivono due proprietà della stessa cosa (tendenzialmente una persona) e abbiamo detto che gli stati dovrebbero rappresentare tutte e sole le informazioni che servono per descrivere la realtà:

export default function Form() {
  const [person, setPerson] = useState({
    firstName: "",
    lastName: "",
  });

  const fullName = person.firstName + " " + person.lastName;

  // ...
}

fullName dipende direttamente dallo stato di person, ci penserà React a chiamare la nostra funzione/componente Form quando verrà chiamata la funzione setPerson, così che fullName venga aggiornata.

Notificare il componente controller

Quando si parla di input (nel senso di elementi <input />), non sempre è possibile operare con un solo stato. Idealmente, lo stato e la sua funzione setter esistono una sola volta in un solo componente e vengono passati insieme ai componenti view.

Ci sono, però, dei casi in cui lo stato e l’input non coincidono, per esempio perché lo stato deve passare una validazione ma vogliamo che l’utente veda quello che sta scrivendo anche se è sbagliato, oppure perché l’utente sta scrivendo in un campo di ricerca e non vogliamo lanciare una chiamata di rete ogni volta che l’input cambia, ma solo quando l’utente ha finito di scrivere.

In questi casi, abbiamo bisogno di una prop e un evento per notificare il componente controller, ma anche di uno stato interno per tenere traccia dei cambiamenti. In questo caso, la tentazione di tenere sincronizzati lo stato con le prop è forte:

export default function MyInput({ value, validate, onChange }) {
  const [state, setState] = useState(value);

  useEffect(() => {
    const isValid = validate(state);

    if (isValid) {
      onChange(state);
    }
  }, [state, validate, onChange]);

  return (
    <input
      value={state}
      onInput={(event) => {
        setState(event.currentTarget.value);
      }}
    />
  );
}

Anche in questo caso, possiamo spostare entrambi gli aggiornamenti in una funzione per gestire l’evento:

export default function MyInput({ value, validate, onChange }) {
  const [state, setState] = useState(value);

  const onInput = (inputValue) => {
    setState(inputValue);

    const isValid = validate(state);

    if (isValid) {
      onChange(state);
    }
  };

  return (
    <input
      value={state}
      onInput={(event) => {
        onInput(event.currentTarget.value);
      }}
    />
  );
}

Se, invece, possiamo “sollevare lo stato” e far gestire tutto al componente controller, quella è un’opzione migliore, perché ci permette di isolare la logica di funzionamento in un unico punto. 

Sei indeciso sul percorso? 💭

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626