17 Δεκ, 2021

React 18: Automatic Batching

React 18 Automatic Batching

Είμαι σίγουρος ότι έχετε ακούσει τον όρο "React Batching" στο παρελθόν, και ίσως να είχατε μπερδευτεί λιγάκι (όπως είχα εγώ) για το τι ακριβώς είναι και τι κάνει.

Γενικά μιλώντας η λέξη Batching μπορεί να χρησιμοποιηθεί με διάφορους τρόπους αλλά ένας γενικός ορισμός που έχει νόημα στην περίπτωση μας μπορεί να εκφραστεί ως:

Μια παρτίδα από δουλειές, δεδομένα ή προγράμματα τα οποία θεωρούνται ως μια μονάδα για υπολογιστική επεξεργασία.

(Batch Processing) Μια μορφή επεξεργασίας δεδομένων στην οποία είσοδοι ομαδοποιούνται για επεξεργασία στον ίδιο κύκλο εκτέλεσης.

Τι είναι το React Batching

Όταν, η React εφαρμόζει "Batching" σημαίνει ότι "ομαδοποιεί πολλαπλά state updates και τα εκτελεί με ένα re-render" κυρίως για καλύτερη απόδοση.

Στην React 17 και προηγούμενες εκδόσεις της 17, η React θα κάνει batch αυτόματα οποιαδήποτε state updates συμβούν μόνο μέσα σε React event handlers (όπως ένα click ή change).

Ρίξτε μια ματιά στον κώδικα παρακάτω και μαντέψτε πόσα re-renders θα προκαλέσει η handleClick συνάρτηση;

Επίσης μην ανησυχείτε για την useRenderCount, είναι απλά ένα hook για να μετράμε τα re-renders :-).

const useRenderCount = () => {
  const renderCounter = useRef(0); 
  useLayoutEffect(() => {
    renderCounter.current++;
  });
  return renderCounter.current;
};

Το actual demo:

const Demo1 = () => {
    const renderCount = useRenderCount(); // we use this to count re-renders
    const [countUp, setCountUp] = useState(0);
    const [countDown, setCountDown] = useState(0);

    const handleClick = () => {
        setCountUp((cUp) => cUp + 1);
        setCountDown((cDown) => cDown - 1);
    };

    return (
        <div style={{ textAlign: "center" }}>
            <h4>Demo 1 (Batching)</h4>
            <button onClick={handleClick}>Click me</button>
            <h2>Count up: {countUp}</h2>
            <h2>Count down: {countDown}</h2>
            <div>Number of rerenders: {renderCount}</div>
        </div>
    );
}

Demo 1 (Batching)

Ακριβώς, θα προκαλέσει μόνο ένα re-render επειδή η React θα κάνει batch τα δυο state updates setCountUp και setCountDown σε ένα!

Αρκετά κουλ έτσι; Η React θα το τακτοποιήσει αυτό για εμάς αυτόματα και θα αποφύγει περιττά re-renders το οποίο είναι τέλειο για την απόδοση! Επίσης θα αποτρέψει τα components μας από το να κάνουν render "μισο-τελειωμένα" states όπου μόνο μια state μεταβλητή έχει ανανεωθεί, το οποίο μπορεί να οδηγήσει σε bugs.

✋ Όμως προσοχή...

Η React (πριν την έκδοση 18) θα εφαρμόσει batch μόνο μέσα σε React event handlers. Δεν θα εφαρμόσει batch σε updates μέσα σε promises, setTimeout, native event handlers ή άλλα events.

Στον πραγματικό κόσμο υπάρχουν αρκετές περιπτώσεις όπου χρειάζεται να κάνουμε πολλαπλά state updates μεσα σε ένα handler όπου η React δεν μπορεί να κάνει batch.

Ας δούμε μερικά παραδείγματα όπου η React δεν μπορεί να κάνει batch τα updates και θα προκαλέσει παραπάνω από ένα re-render.

1) Updates μέσα σε setTimeout

Στο demo παρακάτω, τα updates setCountUp και setCountDown θα προκαλέσουν δύο re-renders καθώς η React δε μπορεί να κάνει batch updates που βρίσκονται μέσα σε setTimeout callback.

const Demo2 = () => {
  const renderCount = useRenderCount();
  const [countUp, setCountUp] = useState(0);
  const [countDown, setCountDown] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      setCountUp((cUp) => cUp + 1);
      setCountDown((cDown) => cDown - 1);
    }, 500);
  };

  return (
    <div className="Demo">
      <h4>Demo 2 (setTimeout)</h4>
      <button onClick={handleClick}>Click me</button>
      <h2>Count up: {countUp}</h2>
      <h2>Count down: {countDown}</h2>
      <div>Number of rerenders: {renderCount}</div>
    </div>
  );
};

Demo 2 (setTimeout)

2) Updates inside a promise

Στο παρακάτω παράδειγμα κάνουμε fetch μια τυχαία εικόνα γάτας όταν κλικάρουμε το κουμπί. Η React δε μπορεί να κάνει batch τα πολλαπλά state updates που βρίσκονται μετά το await fetch call, συγκεκριμένα το setCat(json.url) και setLoading(false).

Ωστόσο, μπορεί επιτυχώς να κάνει batch τις πρώτες δυο κλήσεις πριν το await: setLoading(true) and setCat(null).

Οπότε συνολικά η συνάρτηση handleClick θα προκαλέσει 3 re-renders.

const Demo3 = () => {
  const renderCount = useRenderCount();
  const [loading, setLoading] = useState(false);
  const [cat, setCat] = useState(null);

  const handleClick = async () => {
    // these two below will be batched
    setLoading(true);
    setCat(null);
    const response = await fetch("https://thatcopy.pw/catapi/rest/");
    if (response.ok) {
      const json = await response.json();
      setCat(json.url); // cannot batch
    }
    setLoading(false); // cannot batch
  };

  return (
    <div className="Demo">
      <h4>Demo 3 (Promise)</h4>
      <button onClick={handleClick}>Click me</button>
      <h2>Loading: {String(loading)}</h2>
      <h2
        style={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center"
        }}
      >
        Cat:{" "}
        {cat ? (
          <img
            alt="cat"
            width="35"
            height="35"
            src={cat}
            style={{ marginLeft: "8px" }}
          />
        ) : (
          ""
        )}
      </h2>
      <div>Number of rerenders: {renderCount}</div>
    </div>
  );
};

Demo 3 (Promise)

3) Updates inside a native event handler

Στο παράδειγμα παρακάτω χρησιμοποιούμε ένα native event handler κάνοντας attach ένα click listener στο κουμπί μας. Η React δεν μπορεί να κάνει batch τα updates αυτά επίσης.

const Demo4 = () => {
  const renderCount = useRenderCount();
  const [countUp, setCountUp] = useState(0);
  const [countDown, setCountDown] = useState(0);

  useEffect(() => {
    const handleClick = () => {
      setCountUp((cUp) => cUp + 1);
      setCountDown((cDown) => cDown - 1);
    };

    const element = document.querySelector("#my-button");
    element.addEventListener("click", handleClick);

    return () => {
      element.removeEventListener('click', handleClick);
    };
  }, []);

  return (
    <div className="Demo">
      <h4>Demo 4 (Native event handler)</h4>
      <button id="my-button">Click me</button>
      <h2>Count up: {countUp}</h2>
      <h2>Count down: {countDown}</h2>
      <div>Number of rerenders: {renderCount}</div>
    </div>
  );
};

Demo 4 (Native event handler)

💡 Γρήγορο fix για τα παραπάνω σενάρια

Προφανώς, μια γρήγορη λύση θα ήταν να αποθηκεύσουμε όλα τα "επιμέρους" states σε ένα μεγάλο state (ή να χρησιμοποιήσουμε useReducer).

Για παράδειγμα, το Demo με το fetch promise θα μπορούσε να γραφτεί όπως παρακάτω ώστε να αποφύγουμε ανεπιθύμητα re-renders:

const Demo3Fix = () => {
  const renderCount = useRenderCount();
  const [state, setState] = useState({
    loading: false,
    cat: null
  }); // a single state object

  const handleClick = async () => {
    setState({
      loading: true,
      cat: null
    }); // 1st re-render
    const response = await fetch("https://thatcopy.pw/catapi/rest/");
    if (response.ok) {
      const json = await response.json();
      setState((s) => ({ cat: json.url, loading: false })); // 2nd re-render
    } else {
      setState((s) => ({ ...s, loading: false })); // or this 2nd re-render
    }
  };

  const { cat, loading } = state;

  return (
    ...
  );
};

➡️ Όλα τα προηγούμενα React 17 demos είναι διαθέσιμα σε αυτό το CodeSandbox: https://codesandbox.io/s/cranky-mccarthy-g5ntm

React 18 είναι εδώ 🎉

Στην version 18 της React (όπου είναι ακόμη σε beta), γίνεται αυτόματο batching για όλα τα use cases ώστε να βελτιωθεί η απόδοση ακόμη περισσότερο. Μπορείτε να διαβάσετε περισσότερα εδώ.

Αυτό σημαίνει ότι σε όλα μας τα προηγούμενα μας παραδείγματα, η React (18) θα κάνει αυτόματα batch όλα τα πολλαπλά state updates σε ένα update.

Για του λόγου το αληθές, ας δούμε το setTimeout demo όπου το μόνο πράγμα αλλάξαμε ήταν η version της React σε 18 και ο κώδικας μέσα στο αρχείο index.js όπου λέμε στην React πως να φορτώσει την εφαρμογή μας. Συγκεκριμένα, για να χρησιμοποιήσει η React 18 αυτά τα νέα features, πρέπει να χρησιμοποιήσουμε το createRoot api.

index.js στην React 17:

import { render } from "react-dom";
import App from "./App";

render(<App />, document.getElementById("root"));

index.js στην React 18:

import { createRoot } from "react-dom";
import App from "./App";

createRoot(document.getElementById("root")).render(<App />);

Demo React 18 (setTimeout)

➡️ Εδώ είναι ο κώδικας με όλα τα demos σε React 18: https://codesandbox.io/s/objective-elbakyan-6u76m

Εάν δεν θέλουμε να κάνουμε batch;

Ίσως υπάρχει κάποιο σενάριο όπου δε θέλουμε η React να κάνει batch updates (αν και το batching γενικά θεωρείται ασφαλές). Μπορούμε να το πετύχουμε αυτό χρησιμοποιώντας τη συνάρτηση flushSync ως εξής:

import { flushSync } from 'react-dom'; 

const handleClick = () => {
  flushSync(() => {
    setCountUp((cUp) => cUp + 1);  // 1st re-render 
  })
  flushSync(() => { 
    setCountDown((cDown) => cDown - 1); // 2nd re-render
  })  
};

Επίσης, αξίζει να αναφέρουμε ότι σε εκδόσεις της React πριν την 18, μπορούσαμε να αναγκάσουμε (force) state updates έξω απο React event handlers να γίνουν batch χρησιμοποιώντας το undocumented API unstable_batchedUpdates έτσι:

unstable_batchedUpdates(() => {
  // these 2 below will be batched
  setCountUp((cUp) => cUp + 1);
  setCountDown((cDown) => cDown - 1);
});

Σύμφωνα με τα docs αυτό το API ακόμη υπάρχει στην React 18, αλλά δεν είναι απαραίτητο πια επειδή το batching εφαρμόζεται αυτόματα. Επίσης ίσως αφαιρεθεί σε μελλοντικές εκδόσεις.

Τέλος

Ελπίζω να σας άρεσε αυτό το άρθρο, τα λέμε μετά! 😃

Loading Comments...