Moduł 2 - Zajęcia 3 - Zdarzenia i stany

Section1 Article1: Komponenty klasy

Komponenty tworzymy jako klasy, kiedy niezbędne jest dodanie do nich dynamiki. Dotychczas komponenty funkcyjne były ograniczone możliwościami tylko do otrzymywania propsów. Nie jest to już prawda, odkąd w React udostępnione zostały hooki (od wersji React 16.8), natomiast zostanie to omówione w późniejszych rozdziałach.

  • Zwykła klasa ES6, dlatego stosujemy wymaganą składnię JavaScript: konstruktor, metody, kontekst (this).
  • Obowiązkowo rozszerza klasę podstawową React.Component.
  • Działa jak funkcja, która otrzymuje props, ale dostęp do właściwości odbywa się z użyciem kontekstu (this.props).
  • Należy zadeklarować obowiązkową metodę render(), która zwraca elementy JSX. Zostanie ona wywołana automatycznie przez Reacta.
  • Użycie komponentu klasy spowoduje, że React za każdym będzie tworzył nowy egzemplarz komponentu (klasy). Dlatego dostęp do propsów przebiega przez this.props.
  • Można określić niestandardowe metody klasy i wykorzystać je w dowolnym miejscu, w tym również wewnątrz JSX.
  • Zmiana stanu komponentu lub jego propsów spowoduje ponowne renderowanie ("re-render").
  •               // Używaj importów nazwanych zamiast składni `React.Component`, zwiększa to czytelność kodu
                  import React, { Component } from "react";
    
                  class MyClassComponent extends Component {
                    static defaultProps = {};
    
                    static propTypes = {};
    
                    render() {
                      return <div>Class Component</div>;
                    }
                  }
                

Section2 Article1: Zdarzenia

Dla natywnego zdarzenia przeglądarki React posiada obiekt-opakowanie SyntheticEvent Object z identycznym interfejsem. Jest to niezbędne, aby zapewnić kompatybilność z różnymi przeglądarkami i zoptymalizować wydajność.

            <button onClick={event => console.log(event)}>Click me!</button>
          
  • Obsługa zdarzeń z wykorzystaniem EventTarget.addEventListener() praktycznie nie jest niewykorzystywana (poza kilkoma wyjątkami).
  • Propsy zdarzeń nie są wyjątkiem i nazywane są w notacji camelCase, np. onClick, onChange, onSubmit, onMouseEnter.
  • Do propsu zdarzenia przekazujemy referencję do funkcji (callback), która zostanie wywołana w przypadku wystąpienia danego zdarzenia.
  • Funkcje obsługi zdarzeń otrzymują egzemplarz SyntheticEvent Object.

W React "pod maską" realizowane jest delegowanie zdarzeń. "Listenery" nie są dodawane bezpośrednio do elementów DOM. Przekazanie callback'a to po prostu rejestracja funkcji, która będzie wywołana przez wewnętrzne mechanizmy React'a w przypadku wystąpienia zdarzenia.

Section2 Article2: Licznik

Stwórzmy komponent-licznik, który docelowo będzie miał możliwość zwiększania i zmniejszania wartości.

            import React, { Component } from "react";
            import ReactDOM from "react-dom";

            class Counter extends Component {
              static defaultProps = {
                step: 1,
              };

              render() {
                const { step } = this.props;

                return (
                  <div>
                    <span>0</span>
                    <button type="button">Increment by {step}</button>
                    <button type="button">Decrement by {step}</button>
                  </div>
                );
              }
            }

            ReactDOM.render(<Counter step={5} />, document.getElementById("root"));
          

Section2 Article3: Anonimowe callbacki

Tzw. callbacki "inline" uważane są za antywzorzec. Za każdym razem, gdy komponent renderuje się ponownie (re-render), utworzona zostanie nowa funkcja callback. W wielu przypadkach jest to normalne. Jednak jeśli callback przekazywany jest jak props do leżących niżej w drzewie komponentów, zostaną one przerenderowane (ponieważ pojawi się nowa referencja do funkcji). W dodatku większa ilość funkcji JSX zaburza czytelność układu komponentu.

            class Counter extends Component {
            /* ... */

              render() {
                const { step } = this.props;

                return (
                  <div>
                    <span>0</span>
                    <button
                      type="button"
                      onClick={evt => {
                        console.log("Increment button was clicked!", evt);// działa
                        console.log("this.props: ", this.props);// działa
                      }}
                    >
                      Increment by {step}
                    </button>
                    <button
                      type="button"
                      onClick={evt => {
                        console.log("Decrement button was clicked!", evt);// działa
                        console.log("this.props: ", this.props);// działa
                      }}
                    >
                      Decrement by {step}
                    </button>
                  </div>
                );
              }
            }
          

Section2 Article4: Funkcje obsługi zdarzeń

Najczęściej funkcje obsługi zdarzeń deklaruje się jak metody klasy, a następnie do atrybutu JSX przekazywana jest referencja do danej metody.

            class Counter extends Component {
            /* ... */

              handleIncrement(evt) {
                console.log("Increment button was clicked!", evt);// działa
                console.log("this.props: ", this.props);// Error: cannot read props of undefined
              }

              handleDecrement(evt) {
                console.log("Decrement button was clicked!", evt);// działa
                console.log("this.props: ", this.props);// Error: cannot read props of undefined
              }

              render() {
                const { step } = this.props;

                return (
                  <div>
                    <span>0</span>
                    <button type="button" onClick={this.handleIncrement}>
                      Increment by {step}
                    </button>
                    <button type="button" onClick={this.handleDecrement}>
                      Decrement by {step}
                    </button>
                  </div>
                );
              }
            }
          

Section2 Article5: Powiązanie this

Należy zawsze pamiętać o wartości this w metodach wykorzystywanych jako funkcje callback. W JavaScripcie kontekst w metodach klasy nie przywiązuje się domyślnie. Jeśli zapomni się o powiązaniu kontekstu to w czasie wywołania funkcji (w ramach obsługi zdarzenia) this pozostanie nieokreślony.

Section2 Article6: Powiązanie w trakcie przekazywania callbacku

Unikaj powiązywania kontekstu w metodzie render(). Za każdym razem, gdy komponent renderuje się ponownie, Function.prototype.bind() zwraca nową funkcję i przekazuje ją w dół drzewa komponentów. Prowadzi to do powtórnego renderowania komponentów dzieci. Może to mieć istotny wpływ na wydajność.

            // ❌ Źle
            class Counter extends Component {
            /* ... */

              handleIncrement(evt) {
            // ...
              }

              handleDecrement(evt) {
            // ...
              }

              render() {
                const { step } = this.props;

                return (
                  <div>
                    <span>0</span>
                    <button type="button" onClick={this.handleIncrement.bind(this)}>
                      Increment by {step}
                    </button>
                    <button type="button" onClick={this.handleDecrement.bind(this)}>
                      Decrement by {step}
                    </button>
                  </div>
                );
              }
            }
          

Section2 Article7: Powiązanie w konstruktorze

Kontekst można również powiązać w konstruktorze klasy. Jednak można sobie wyobrazić, o ile zwiększy się otrzymamy konstruktor, jeśli funkcji będzie wiele.

  • Konstruktor wykonuje się jeden raz, dlatego bind również zostanie wywołane tylko jeden raz.
  • Metody klasy zapisywane są we właściwości prototype funkcji-konstruktora.
            // ✅ Nieźle
            class Counter extends Component {
            /* ... */

              constructor() {
                super();
                this.handleIncrement = this.handleIncrement.bind(this);
                this.handleDecrement = this.handleDecrement.bind(this);
              }

              handleIncrement(evt) {
            // ...
              }

              handleDecrement(evt) {
            // ...
              }

              render() {
                const { step } = this.props;

                return (
                  <div>
                    <span>0</span>
                    <button type="button" onClick={this.handleIncrement}>
                      Increment by {step}
                    </button>
                    <button type="button" onClick={this.handleDecrement}>
                      Decrement by {step}
                    </button>
                  </div>
                );
              }
            }
          

Section2 Article7: Publiczne właściwości klasy

Rekomendowany sposób przywiązania kontekstu to składnia publicznych pól klasy. Po wywołaniu publicznych pól klasy, zapisują się one nie we właściwości prototype funkcji-konstruktora, a w obiekcie egzemplarza klasy.

            // ✅ Super
            class Counter extends Component {
            /* ... */

              handleIncrement = evt => {
                console.log("Increment button was clicked!", evt);// działa
                console.log("this.props: ", this.props);// działa
              };

              handleDecrement = evt => {
                console.log("Decrement button was clicked!", evt);// działa
                console.log("this.props: ", this.props);// działa
              };

              render() {
                const { step } = this.props;

                return (
                  <div>
                    <span>0</span>
                    <button type="button" onClick={this.handleIncrement}>
                      Increment by {step}
                    </button>
                    <button type="button" onClick={this.handleDecrement}>
                      Decrement by {step}
                    </button>
                  </div>
                );
              }
            }
          

Section3 Article1: Wewnętrzny stan komponentu

Stan komponentu (state) pozwala nam dynamicznie aktualizować interfejs użytkownika w odpowiedzi na jego działania. Za każdym razem, gdy zmienia się stan komponentu (lub propsy), wywoływana jest metoda render(). W stanie powinniśmy przechowywać jedynie minimalny, niezbędny zestaw danych, potrzebny do prawidłowego zaktualizowania interfejsu użytkownika.

Stan należy do komponentu klasowego i można go zmienić tylko za pomocą metod zdefiniowanych w obrębie klasy. Zmiana stanu komponentu nigdy nie aktualizuje jego rodzica i sąsiadów. Aktualizacji podlegają natomiast wszystkie elementy dzieci danego komponentu. W takim modelu dane w aplikacji przekazują się tylko w jeden konkretny sposób nazywany jednokierunkowym przepływem danych (one way data flow).

Stan deklaruje się w konstruktorze, ze względu na to, że jest on wywoływany jako pierwszy podczas tworzenia egzemplarza klasy.

            class Counter extends Component {
              constructor() {
                super();

                this.state = {
                  value: 0,
                };
              }

            /* ... */

              render() {
                return (
                  <div>
                    <span>{this.state.value}</span>
                    {/* ... */}
                  </div>
                );
              }
            }
          

Section3 Article2: Stan początkowy w zależności od props

Czasami konieczne może być aby początkowy stan zależał od przekazanych propsów, np. początkowa wartość naszego licznika. W takim przypadku należy jawnie zadeklarować parametr props w konstruktorze i przekazać go do wywołania super(props). Dopiero wtedy w konstruktorze będzie dostępne this.props.

            class Counter extends Component {
              static defaultProps = {
                step: 1,
                initialValue: 0,
              };

              constructor(props) {
                super(props);

                this.state = {
                  value: this.props.initialValue,
                };
              }

            /* ... */
            }

            ReactDOM.render(<Counter initialValue={10} />, document.getElementById("root"));
          

Możemy jednak pominąć męczące deklarowanie konstruktora i zdefiniować stan jako publiczną właściwość klasy. Traspilator (Babel) zajmie się wszystkim za nas.

            class Counter extends Component {
              static defaultProps = {
                step: 1,
                initialValue: 0,
              };

              state = {
                value: this.props.initialValue,
              };

            /* ... */
            }
          

Section3 Article3: Zmiana stanu komponentu

Aktualizacja stanu komponentu odbywa się z wykorzystaniem odziedziczonej metody setState().

            setState(updater, callback);
          
  • Jako pierwszy, obowiązkowy argument przekazujemy obiekt z polami wskazującymi, jaką część stanu chcemy zmienić.
  • Jako drugi, nieobowiązkowy argument można przekazać funkcję callback, która wykona się po zmianie stanu.

Obiekt stanu state to właściwość klasy, jednak nigdy nie wolno jej zmieniać bezpośrednio.

            state = { fullName: "Poly" };

            // ❌ Źle - mutacja stanu
            this.state.fullName = "Mango";

            // ✅ Dobrze
            this.setState({
              fullName: "Mango",
            });
          

Stwórzmy komponent z przełącznikiem, którego metody będą nadpisywać wartość isOpen w stanie.

            class Toggle extends Component {
              state = { isOpen: false };

              show = () => this.setState({ isOpen: true });

              hide = () => this.setState({ isOpen: false });

              render() {
                const { isOpen } = this.state;
                const { children } = this.props;

                return (
                  <>
                    <button onClick={this.show}>Show</button>
                    <button onClick={this.hide}>Hide</button>
                    {isOpen && children}
                  </>
                );
              }
            }
          

Section3 Article4: Jak aktualizuje się stan

            // stan przed połączeniem
            const currentState = { a: 2, b: 3, c: 7, d: 9 };

            // obiekt przekazany w setState
            const updateSlice = { b: 5, d: 4 };

            // nowa wartość this.state po połączeniu
            const nextState = { ...currentState, ...updateSlice };// {a: 2, b: 5, c: 7, d: 4}
          

Section3 Article5: Asynchroniczność aktualizacji stanu

Tak naprawdę metoda setState() nie zmienia stanu natychmiast. Rejestruje ona asynchronicznną operację aktualizacji stanu, która staje w kolejce aktualizacji. Może się również zdarzyć tak, że kilka aktualizacji zostanie połączonych w jedną, w celu polepszenia wydajności. Ze względu na asynchroniczność aktualizacji, dostęp do this.state w synchronicznym kodzie może zwrócić wartość stanu sprzed aktualizacji.

Wyobraź sobie, że w trakcie zmiany stanu polegasz na jego obecnej wartości. Wykorzystamy pętlę for do stworzenia (rejestracji) kilku operacji zmiany stanu.

            // Zaczynamy z następującym stanem:
              state = { value: 0 };

              // Rozpocznynamy pętlę i wywołujemy 3 operacje zmiany stanu
              for (let i = 0; i < 3; i += 1) {
              // Ponieważ to synchroniczny kod i aktualizacja stanu jeszcze nie zaszła
                console.log(this.state.value);

                this.setState({ value: this.state.value + 1 });
              }
          

Powyższy fragment kodu zwróci w konsoli wartość 0 dla każdej iteracji pętli.

Wyjaśnienie:

Wartość właściwości this.state.value jest zapamiętywana w czasie tworzenia obiektu przekazywanego do setState(), a nie w czasie aktualizacji stanu. Oznacza to, że jeśli w czasie utworzenia obiektu, this.state.value miało wartość 0, to do funkcji setState() przekazany zostanie obiekt {value: 0 + 1}.

W wyniku wykonania pętli otrzymamy kolejkę aktualizacji z 3 obiektów { value: 0 + 1 }, { value: 0 + 1 }, { value: 0 + 1 } i oryginalny stan w momencie aktualizacji { value: 0 }. Po wszystkich aktualizacjach otrzymamy stan { value: 1 }.

Z tego względu nie można polegać na obecnym stanie podczas obliczania następnego (zależnego od poprzedniego w momencie aktualizacji). Rozwiązaniem tego problemu jest drugi sposób na aktualizację stanu.

Section3 Article6: setState z funkcją

Metoda setState() jako pierwszy argument może przyjmować nie tylko obiekt, ale również funkcję. Niemniej, funkcja taka powinna w dalszym ciągu zwrócić obiekt, którym chcemy zaktualizować stan.

            setState((state, props) => {
              return {};
            }, callback);
          

Aktualny stan i propsy zostaną przekazane do funkcji na czas jej wykonywania. W ten sposób można być pewnym poprawnej wartości poprzedniego stanu podczas tworzenia następnego.

            state = { value: 0 };

            for (let i = 0; i < 3; i += 1) {
              console.log(this.state.value);// 0

              this.setState(prevState => {
                console.log(prevState.value);// zwróci poprawne wartości stanu podczas każdej iteracji

                return { value: prevState.value + 1 };
              });
            }
          

Poprawmy zatem komponent przełącznika <Toggle></Toggle>

            class Toggle extends Component {
              state = { isOpen: false };

              toggle = () => {
                this.setState(state => ({ isOpen: !state.isOpen }));
              };

              render() {
                const { isOpen } = this.state;
                const { children } = this.props;

                return (
                  <div>
                    <button onClick={this.toggle}>{isOpen ? "Hide" : "Show"}</button>
                    {isOpen && children}
                  </div>
                );
              }
            }
          

Natomiast licznik będzie wyglądał następująco:

            class Counter extends Component {
            /* ... */

              handleIncrement = () => {
                this.setState((state, props) => ({
                  value: state.value + props.step,
                }));
              };

              handleDecrement = () => {
                this.setState((state, props) => ({
                  value: state.value - props.step,
                }));
              };

            /* ... */
            }
          

Section3 Article7: Zmiana stanu rodzica

React wykorzystuje jednokierunkowy przepływ danych, dlatego aby zmienić stan rodzica podczas zdarzenia w komponencie dziecku wykorzystuje się wzorzec z funkcją callback.

  • W rodzicu zdefiniowany jest stan i metoda, która go zmienia.
  • Do dziecka przerzuca się, za pomocą props, metodę rodzica zmieniającą stan rodzica.
  • W dziecku zachodzi wywołanie przekazanej do niego metody.
  • Po wywołaniu tej metody zmienia stan rodzica.
  • Zachodzi ponowne renderowanie poddrzewa komponentów rodzica.

Spójrzmy na prosty, ale obrazowy przykład:

            // Przycisk otrzyma funkcję changeMessage (nazwa właściwości props),
            // która zostanie wywołana podczas zdarzenia onClick
            const Button = ({ changeMessage, label }) => (
              <button type="button" onClick={changeMessage}>
                {label}
              </button>
            );

            class App extends Component {
              state = {
                message: new Date().toLocaleTimeString(),
              };

            // Metoda, którą będziemy przekazywać do przycisku
              updateMessage = evt => {
                console.log(evt);// Dostępny obiekt zdarzenia w odwołaniu onClick

                this.setState({
                  message: new Date().toLocaleTimeString(),
                });
              };

              render() {
                return (
                  <>
                    <span>{this.state.message}</span>
                    <Button label="Change message" changeMessage={this.updateMessage} />
                  </>
                );
              }
            }
          

Stan komponentu App jest aktualizowany przy pomocy funkcji updateMessage, której wywołanie następuje po kliknięciu w przycisk. Wzorzec ten ustanawia wyraźną granicę pomiędzy "mądrymi" (smart) i "głupimi" (dumb) komponentami.

Metodę do aktualizacji stanu rodzica możemy przekazywać poprzez props dowolną ilość razy w głąb drzewa komponentów.

Section3 Article8: Typy wewnętrznych danych komponentu-klasy

  • static data - statyczne właściwości i metody klasy
  • this.state.data - dane dynamiczne, poddawane aktualizacji z pomocą metod komponentu-klasy
  • this.data - dane, które będą inne dla każdej instancji klasy.
  • const DATA - stałe, które nie zmieniają się i są jednakowe dla wszystkich instancji.