26 Απρ, 2022

Νεο React API: startTransition

React new startTransition API

Τι είναι ένα Transition?

To transition είναι ένα νέο concept της React για να ξεχωρίζει τα urgent (επείγοντα) από τα non-urgent (μη-επείγοντα) updates.

  • Urgent updates αντικαντοπτρίζουν άμεσα interactions όπως key presses, clicks, κλπ.
  • Transition updates "μεταβαίνουν" από το ένα UI view στο άλλο.

Τα urgent updates όπως key presses, click κλπ χρειάζονται άμεση ανταπόκριση για να "ταιριάξουν" το πώς έχουμε συνηθίσει τα φυσικά αντικείμενα να συμπεριφέρονται. Ειδάλλως μοιάζουν "λάθος". Ωστόσο, τα transitions είναι διαφορετικό σενάριο διότι ο χρήστης δεν περιμένει να δει άμεσα μια τιμή στην οθόνη.

Για παράδειγμα, όταν πληκτρολογούμε σε ένα πεδίο κειμένου για να ψάξουμε κάτι, προσδοκούμε ότι το καθ'εαυτό input field θα δείξει τον χαρακτήρα που πληκτρολογήσαμε αμέσως. Ωστόσο, τα πραγματικά αποτελέσματα του search μπορούν να εμφανιστούν ξεχωριστά και μια μικρή καθυστέρηση είναι συχνά αναμενόμενη. Εάν αλλάξουμε το πεδίο κειμένου ξανά πριν τα αποτελέσματα ολοκληρώσουν την εμφάνιση τους, μας νοιάζει να δούμε μόνο τα τελευταία αποτελέσματα.

Στην React 18 μπορούμε να χρησιμοποιήσουμε το startTransition API για να χαρακτηρίσουμε κάποια state updates ως non-urgent.

Στον παρακάτω κώδικα, υπάρχουν 2 state updates - ένα που είναι urgent και ένα που το έχουμε χαρακτηρίσει ως non-urgent καθώς το έχουμε περικλείσει μέσα σε ένα startTransition callback:

import {startTransition} from 'react';

// Urgent: Show what was typed
setInputValue(input);

// Mark any state updates inside as transitions (non-urgent)
startTransition(() => {
  // Transition: Show the results
  setResults(input);
});

Τα non-urgent updates θα διακοπούν (interrupt) εάν urgent updates όπως clicks ή key presses εμφανιστούν. Εάν ένα transition γίνει interrupted από ένα χρήστη (για παράδειγμα πληκτρολογώντας πολλούς χαρακτήρες στη σειρά), η React θα "πετάξει" το μη-ολοκληρωμένο (stale) rendering work και θα κάνει render μόνο το τελευταίο update.

Demo Application: Απόδοση με και χωρίς startTransition

Φτιάξαμε μια απλή εφαρμογή για να δείξουμε την δύναμη του νέου startTransition API.

Η εφαρμογή πρακτικά παρέχει στο χρήστη την εύρεση χαρακτήρων μέσα σε ένα μεγάλο κείμενο - στην περίπτωση μας περίπου 60000 χαρακτήρες.

Η εφαρμογή μας πρέπει να προσφέρει τα δυο παρακάτω features:

  • Να δείχνει ένα input που θα επιτρέπει στο χρήστη να πληκτρολογήσει ένα όρο αναζήτησης.
  • Να υπογραμμίζει τους χαρακτήρες που ταιριάζουν (ανεξαρτήτως μικρά ή κεφαλαία) με τον όρο αναζήτησης.

Ο απλός αλγόριθμος που χρησιμοποιήσαμε είναι:

  1. Δημιουργία ενός regex με βάση τον όρο αναζήτησης που θα ταιριάξει χαρακτήρες (case insensitive)
  2. Προσθήκη της κλάσης highlight σε όλα τα spans των χαρακτήρων που ταιριάζουν.

Ο κώδικας για τη συνάρτηση applyFilter φαίνεται παρακάτω:

const applyFilter = (query, setFilteredNode) => {
  setFilteredNode(() => {
    if (!query || query.trim().length === 0) {
      return INITIAL_VALUE;
    }

    const regex = new RegExp(
      query.trim().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"),
      "ig"
    );

    const node = TEXT.replaceAll(regex, (s) => `##${s}##`)
      .split("##")
      .map((s, i) => (
        <span key={i} className={i % 2 === 1 ? "highlight" : ""}>
          {s}
        </span>
      ));

    return node;
  });
};

Προφανώς θα μπορούσαμε να σκεφτούμε πιο αποδοτικούς αλγορίθμους, και αυτό το regex σίγουρα επηρρεάζει πολύ την απόδοση. Ωστόσο ο στόχος μας εδώ είναι να "δημιουργήσουμε" μια κοστοβόρα συνάρτηση που προκαλεί ένα heavy React state update ώστε να δούμε ένα το startTransition API θα βελτιώσει την κατάσταση.

Μπορείτε να βρείτε όλο τον κώδικα εδώ.

1η Υλοποίηση: Unoptimized χωρίς startTransition

Η πρώτη μας υλοποίηση είναι ένα component με δυο απλά state updates, ένα για το input και ένα που κάνει apply τα φιλτραρισμένα αποτελέσματα. Όποτε αλλάζει το input value, η συνάρτηση applyFilter καλείται. Και τα δυο state updates χαρακτηρίζονται ως urgent by default:

import {useState} from 'react';

const Unoptimized = () => {
  const [query, setQuery] = useState("");
  const [filteredNode, setFilteredNode] = useState(INITIAL_VALUE);

  const onInputChange = (e) => setQuery(e.target.value);

  useEffect(() => {
    applyFilter(query, setFilteredNode);
  }, [query]);

  return (
    <>
      <div className="input">
        <input onChange={onInputChange} />
      </div>
      <div className="text">{filteredNode}</div>
    </>
  );
};

Unoptimized

Μπορείτε να δείτε ότι οι χαρακτήρες δεν εμφανίζονται αμέσως μέσα στο input και το UI φαίνεται κάπως "αργό":

Unoptimized

Unoptimized με CPU 4x slowdown

Εφαρμόσαμε 4x CPU slowdown (από το Chrome dev tools) για να προσωμοιώσουμε καλύτερα ένα average CPU ή κινητή συσκευή. Μπορούμε να δούμε ότι το UI σίγουρα φαίνεται αργό και καθόλου smooth:

Chrome Dev Tools

Unoptimized CPU 4x Slowdown

2η Υλοποίηση: Optimized με startTransition

Στην 2η υλοποίηση μας χρησιμοποιούμε το startTransition API. Συγκεκριμένα χρησιμοποιούμε το useTransition hook που επιστρέφει και μια pending μεταβλητή την οποία εκμεταλλευόμαστε για να δείξουμε στο χρήστη ότι ένα transition είναι ακόμη σε επεξεργασία θολώνοντας την οθόνη (θα μπορούσαμε να δείξουμε και κάποιο loader).

Χαρακτηρίζουμε το state update των αποτελεσμάτων ως non-urgent περικλείοντάς το μέσα σε startTransition callback:

import {useState, useTransition} from 'react';

const Optimized = () => {
  const [query, setQuery] = useState("");
  const [filteredNode, setFilteredNode] = useState(INITIAL_VALUE);
  const [pending, startTransition] = useTransition();

  const onInputChange = (e) => setQuery(e.target.value);

  useEffect(() => {
    startTransition(() => {
      applyFilter(query, setFilteredNode);
    });
  }, [query, startTransition]);

  return (
    <>
      {pending && <div className="fade" />}
      <div className="input">
        <input onChange={onInputChange} />
      </div>
      <div className="text">{filteredNode}</div>
    </>
  );
};

Optimized

Δείτε πόσο γρήγορα οι χαρακτήρες εμφανίζονται στο input - φαίνεται φυσικό, smooth και αυτό που θα περίμενε ο χρήστης. Θολώνουμε την οθόνη όσο η μεταβλητή pending είναι true ώστε να δείξουμε στο χρήστη ότι το transition είναι ακόμη σε επεξεργασία.

Optimized

Optimized με CPU 4x slowdown

Εφαρμόσαμε ακόμη και CPU 4x slowdown για να εξετάσουμε πώς αποκρίνεται το UI. Φαίνεται καλό, ακόμη και σε αυτό το σενάριο!

Optimized CPU 4x Slowdown

Εναλλακτικές λύσεις πριν την React 18?

Πριν την React 18 δεν είχαμε το startTransition API, όποτε πώς διορθώναμε τέτοιες περιστάσεις σε προηγούμενες React versions?

Η πιο συχνή λύση για να αντιμετωπίσουμε τέτοια προβλήματα και να αποφύγουμε βαριά απανωτά state updates ήταν να χρησιμοποιήσουμε debounce ή throttle τεχνικές.

Για παράδειγμα θα μπορούσαμε να χρησιμοποιήσουμε αυτή την εκπληκτική use-debounce βιβλιοθήκη και να περικλείσουμε τα βαριά state updates των αποτελεσμάτων μέσα σε ένα debounced callback κάπως έτσι:

import {useState} from 'react';
import {useDebouncedCallback} from 'use-debounce';

const Debounced = () => {
  const [query, setQuery] = useState("");
  const [filteredNode, setFilteredNode] = useState(INITIAL_VALUE);
   
  const onInputChange = (e) => setQuery(e.target.value);

  const debounced = useDebouncedCallback(
    (query, setFilteredNode) => {
       applyFilter(value, setFilteredNode);
    },
    100 // debounce 100 ms
  );  

  useEffect(() => {
    debounced(query, setFilteredNode);
  }, [query, startTransition]);

  return (
    <>
      {pending && <div className="loader" />}
      <div className="input">
        <input onChange={onInputChange} />
      </div>
      <div className="text">{filteredNode}</div>
    </>
  );
};

Αλλά υπάρχουν ακόμη κάποια προβλήματα με αυτή τη προσέγγιση.

Πρώτον, η συνάρτηση applyFilter δεν μπορεί να καλεσθεί σε κάτω από 100ms (ή οσοδήποτε χρόνο επιλέξουμε) από τη στιγμή που ο χρήστης σταματήσει να πληκτρολογεί. Από την άλλη μεριά, χρησιμοποιώντας το startTransition API, η βαριά επεξεργασία των αποτελέσματων ξεκινάει το συντομότερο δυνατό, χωρίς να χρειάζεται να περιμένει κάποιο χρόνο.

Δεύτερον, ακόμη και αν χρησιμοποιούσαμε throttle αντί για debounce για να προσπαθήσουμε να λύσουμε το παραπάνω, η επεξεργασία των αποτελεσμάτων είναι un-interruptable. Αυτό σημαίνει ότι εάν έρθουν urgent updates (όπως key pressing) όσο τα αποτελέσματα είναι ακόμη σε επεξεργασία, τo UI αναπόφευκτα θα είναι unresponsive. Ενώ, με το startTransition API, η επεξεργασία των αποτελεσμάτων θα γίνει interrupted όταν έρθουν τα urgent updates, κρατώντας έτσι το input field responsive.

Τέλος

Ελπίζω να βρήκατε το άρθρο χρήσιμο.

Τα λέμε σύντομα! 🙂

Loading Comments...