27 Δεκ, 2021

React & useEffect cleanups

React 18 Automatic Batching

Πριν εξηγήσουμε τι είναι ένα useEffect cleanup και πότε πρέπει να το χρησιμοποιήσουμε, ας ρίξουμε μια ματιά στον κώδικα παρακάτω. Παρατηρείτε κάτι που είναι λάθος;

import { useEffect, useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log("Increasing count...");
      setCount((c) => c + 1);
    }, 1000);
  }, []);

  return <h4>Count: {count}</h4>;
};

export default function App() {
  const [showCounter, setShowCounter] = useState(false);

  return (
    <div>
      <button onClick={() => setShowCounter((sc) => !sc)}>
        Show/Hide Counter
      </button>
      {showCounter && <Counter />}
    </div>
  );
}

Τώρα ρίξτε μια ματιά στο παρακάτω demo και console αντίστοιχα:

Demo

Άρα, τί γίνεται εδώ;

  1. Κλικάρουμε το κουμπί για να δείξουμε τον Counter
  2. Το Counter component γίνεται mount και το interval ξεκινά
  3. Κλικάρουμε το κουμπί ξανά για να κρύψουμε τον Counter
  4. To Counter component γίνεται unmount αλλά το interval δε σταματάει ποτέ, συνεχίζει να τρέχει.

Οπότε παρά το γεγονός ότι το Counter component γίνεται unmount αφού κλικάρουμε το κουμπί τη δεύτερη φορά, το interval που δημιουργήθηκε μέσα στη useEffect του Counter δε σταματά και τρέχει για πάντα προκαλώντας το παρακάτω error στην κονσόλα:

index.js:27 Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

➡️ Το demo είναι διαθέσιμο εδώ: https://codesandbox.io/s/react-setinterval-memory-leak-4j60q

Τί είναι ένα useEffect cleanup;

Τις περισσότερες φορές όταν αναφερόμαστε σε ένα "useEffect cleanup" εννοούμε ότι χρειάζεται να γράψουμε κώδικα όπου θα μας εγγυηθεί ότι θα εξαλείψει οποιαδήποτε πιθανά memory leaks και ότι η εφαρμογή μας θα παραμείνει bug-free.

Ένα πιθανό memory leak, όπως είδαμε και στο προηγούμενο demo, μπορεί να είναι ότι δεν σταματήσαμε ένα interval (clear setInterval) όταν το component που δημιούργησε αυτο το interval έγινε unmount, και έτσι το interval συνεχίζει να τρέχει για πάντα! Ή θα μπορούσε να είναι ότι δεν κάναμε clean ένα subscription που δημιουργήσαμε και τώρα έχουμε ένα listener ο οποίος δεν απενεργοποιείται ποτέ!

Αυτές οι περιπτώσεις μπορούν πολύ εύκολα να εισάγουν τρομερά bugs στην εφαρμογή μας και παρόλο που τα cleanups σε ένα useEffect δεν είναι πάντα απαραίτητα, πρέπει πάντα όταν γράφουμε ένα useEffect να κάνουμε ερωτήσεις στον εαυτό μας όπως: "Έάν το component γίνει unmount, θα συνεχίσει να τρέχει ανεπιθύμητος κώδικας;" ή "Χρειάζεται να γράψουμε συνάρτηση cleanup για αυτό το effect;".

Πώς να γράψουμε ένα cleanup;

Το να γράψουμε ένα useEffect cleanup είναι πολύ εύκολο. Απλώς επιστρέφουμε μια συνάρτηση από το useEffect μας όπως φαίνεται παρακάτω:

useEffect(()=> {
  // our effect here...

  // we return the cleanup fn
  return () => {
    // our cleanup code here..
  }
}, []);

Κάθε πότε τρέχουν τα cleanups;

Η React εφαρμόζει το cleanup όταν το component γίνεται unmount. Όμως, εξαιτίας του γεγονότος ότι τα effects μας μπορεί να τρέξουν και άλλες φορές σε πολλά renders (και όχι μόνο κατα το mount), η React καθαρίζει (cleans up) τα effects από το previous render πριν τρέξει τα effects την επόμενη φορά. Μην ανησυχείτε εάν είναι δυσνόητο για την ώρα - θα το καταλάβουμε πολύ καλύτερα με τα παραδείγματα παρακάτω.

Παραδείγματα Cleanup

Ας διορθώσουμε το counter demo από πριν και ας εισάγουμε μια συνάρτηση cleanup η οποία θα τρέχει όταν το Counter component γίνεται unmount και θα σταματάει το interval:

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    let interval;
    interval = setInterval(() => {
      console.log("Increasing count...");
      setCount((c) => c + 1);
    }, 1000);

    // our cleanup will run when the component unmounts
    return () => {
      clearInterval(interval);
    }
  }, []);

  return <h4>Count: {count}</h4>;
};

Άρα γενικά μιλώντας ένα subscription μπορεί να δημιουργηθεί και να καταστραφεί σε ένα useEffect κάπως έτσι:

const Subscription = () => {

  useEffect(() => {
    createSubscription();

    // our cleanup
    return () => {
      destroySubscription();
    }
  }, []);

  return <div>...</div>
};

Η React καθαρίζει (cleans up) τα effects από το previous render πριν τρέξει τα effects την επόμενη φορά.

Βάσει της πρότασης παραπάνω, ας δούμε το παράδειγμα που ακολουθεί:

const FriendStatus = (props) => {
  const { friendId } = props;

  useEffect(() => {
    // ...
    subscribeToFriendStatus(friendId, handleStatusChange);
  }, [friendId]);

  return <div>...</div>
};

Το παραπάνω component FriendStatus παίρνει ένα friendId σαν prop και κάνει subscribe στην κατάσταση (status) ενός φίλου με το συγκεκριμένο friendId, το οποίο σημαίνει ότι κάθε φορά που η κατάσταση αλλάζει εμείς πρέπει να εκτελούμε μια συνάρτηση όπου για τους σκοπούς αυτού του demo ονομάσαμε ως handleStatusChange.

To useEffect θα τρέξει μια φορά στο mount και μετά κάθε φορά που το friendId αλλάζει (καθώς περάσαμε το friendId στη λίστα των dependencies του useEffect)

Οπότε φανταστείτε το σενάριο και την ροή παρακάτω:

  1. Τρέξε το πρώτο effect: Mount με friendId:1 -> subscribeToFriendStatus(1, handleStatusChange)

(το friendId αλλάζει) 2. Τρέξε το επόμενο effect: Update με friendId: 2 -> subscribeToFriendStatus(2, handleStatusChange)

(το friendId αλλάζει)

  1. Τρέξε το επόμενο effect: Update με friendId: 3 -> subscribeToFriendStatus(3, handleStatusChange)

Το πρόβλημα εδώ είναι ότι ποτέ δεν κάνουμε unsusbscribe από το προηγούμενο friendId πριν ξανα κάνουμε subscribe στο καινούργιο friendId και αυτό σημαίνει ότι καταλήγουμε να έχουμε όλα αυτά τα ανοιχτά subscriptions για όλα αυτά τα friendIds!

Προσθέτοντας μια cleanup συνάρτηση όπως φαίνεται παρακάτω, η React θα την εκτελεί πριν τρέξει τα effects στο επόμενο render (και φυσικά τελικά στο unmount).

const FriendStatus = (props) => {
  const {friendId} = props;

  useEffect(() => {
    // ...
    subscribeToFriendStatus(friendId, handleStatusChange);

    return () => {
      unSubscribeToFriendStatus(friendId, handleStatusChange);
    }
  }, [friendId]);

  return <div>...</div>
};

Άρα τώρα η ροή γίνεται:

  1. Τρέξε το πρώτο effect: Mount με friendId: 1 -> subscribeToFriendStatus(1, handleStatusChange)

(το friendId αλλάζει)

  1. Καθάρισε το προηγούμενο effect: Unsubscribe από friendId: 1 -> unSubscribeToFriendStatus(1, handleStatusChange)
  2. Τρέξε το επόμενο effect: Update με friendId: 2 -> subscribeToFriendStatus(2, handleStatusChange)

(το friendId αλλάζει)

  1. Καθάρισε το προηγούμενο effect: Unsubscribe από friendId: 2 -> unSubscribeToFriendStatus(2, handleStatusChange)
  2. Τρέξε το επόμενο effect: Update με friendId: 3 -> subscribeToFriendStatus(3, handleStatusChange)

(το component γίνεται unmount)

  1. Καθάρισε το προηγούμενο effect: Unsubscribe από friendId: 3 -> unSubscribeToFriendStatus(3, handleStatusChange)

Τέλος

Ελπίζω να σας άρεσε και να σας βοήθησε αυτό το άρθρο. Τα λέμε μετά! 😀

Loading Comments...