Mężczyzna w koszuli, trzymający dwa pędzle w, ubrudzonych farbami, dłoniach

Komponenty klasowe i state – Pierwszy projekt w ReactJS #4

W poprzednim wpisie poznaliśmy cechujące się prostotą komponenty funkcyjne. Niestety, nie poradzimy sobie z budową trackera kryptowalut, mając do dyspozycji samą warstwę prezentacyjną. Nadszedł czas na analizę komponentów klasowych, które będą służyły jako centra logiki biznesowej naszej aplikacji. Tym razem dowiemy się, czym jest stan komponentu i jak skutecznie nim zarządzać.

Tak się składa, że mieliśmy już styczność z komponentem klasowym. Jest nim nadrzędny komponent naszej aplikacji czyli App.

Jeżeli zajrzysz do App.js to zauważysz, że został on zadeklarowany z użyciem następującej składni: class App extends Component. To właśnie dzięki dziedziczeniu po React.Component, ma on dostęp do rozszerzonych możliwości komponentów klasowych.

Kolejną cechą, która różni App od komponentu funkcyjnego Coin, jest metoda render. Służy ona do zwracania elementów React stanowiących dane wyjściowe każdego komponentu.

Jakby nie patrzeć, jest to kosmetyczna zmiana, która wynika z różnic pomiędzy funkcjami a klasami w JS. Tym, co naprawdę wyróżnia komponenty klasowe, jest stan.

Czym jest stan?

Stan to dane o dynamicznym charakterze. Upraszczając, ulegają one zmianom w czasie. Przykłady wykorzystania stanu można zaobserwować w każdym urządzeniu elektronicznym.

Weźmy smartfon, który zapewne właśnie toczy ze mną zaciętą walkę o Twoje gałki oczne. Powiedzmy, że wpadłeś na genialny pomysł i chcesz wyciszyć swój przenośny komputer.

Telefon musi przechowywać aktualny stan głośności, żeby inkrementować/dekrementować tę wartość po wciśnięciu przycisku regulacji.

Dokładnie w ten sam sposób wykorzystujemy stan w React. Za jego pomocą w dynamiczny sposób opisujemy, co dzieje się we wnętrzu komponentu.

Obiekt state

Do zarządzania stanem wykorzystuje się obiekt state. W przeciwieństwie do props, jest on własnością komponentu.

To właśnie z state rodzica pochodzi lwia część propsów wykorzystywanych w jego dzieciach. Oprócz fragmentów state, komponenty klasowe moga przekazywać referencje do swoich metod. Są one podpinane pod obsługę zdarzeń w dzieciach i na ogół służą do aktualizowania stanu rodzica. Więcej na ten temat w kolejnym wpisie z serii.

Warto wiedzieć: Zadeklarowanie komponentu za pomocą klasy, nie wiąże się z obowiązkiem korzystania ze state. Jeżeli istnieje taka możliwość, warto zrezygnować z tego obiektu. Jego obecność wiąże się z koniecznością dogłębnego przeanalizowania tego, co dzieje się we wnętrzu komponentu. Musimy liczyć się z tym, że niezależnie od przekazywanych danych wejściowych, dane wyjściowe mogą ulegać zmianom, ze względu na dynamiczny charakter state.

Jeżeli uznamy, że state jest potrzebny w naszym komponencie, musimy zacząć od jego inicjalizacji. Są na to dwa sposoby.

constructor

Pierwszy sposób polega na inicjalizacji state we wnętrzu metody constructor. Jest ona wywoływana podczas tworzenia instancji komponentu.

Przypuścmy, że na początek umieścimy w state informacje o Bitcoinie.

constructor(props) {
  super(props);
  this.state = {
    name: "Bitcoin",
    acronym: "BTC",
    value: 8714,
    cap: 147379083734
  };
}

Jak widzisz, constructor posiada parametr w postaci props, który obowiązkowo przekazujemy do metody super().

W ten sposób props trafią do React.Component. Tam dojdzie do wiązania z instancją komponentu.

Dostęp do props w komponentach klasowych uzyskujemy właśnie za pomocą tego wiązania, stąd zamiast props.prop korzystamy z referencji this.props.prop.

Za to z wiązaniem state musimy poradzić sobie sami. Właśnie dlatego zainicjalizowałem obiekt, korzystając z this.state zamiast state.

Pole klasy

Drugim sposobem na zainicjalizowanie state jest eksperymentalna składnia pola klasy. Nie jest jeszcze częścią ECMAScript (obecnie w stage 3) ale wspiera ją konfiguracja Babel w create-react-app

class App extends Component {
/*
// constructor nie będzie nam więcej potrzebny
constructor(props) {
  super(props);
  this.state = {
    name: "Bitcoin",
    acronym: "BTC",
    value: 8714,
    cap: 147379083734
  };
}
*/

state = {
  name: "Bitcoin",
  acronym: "BTC",
  value: 8714,
  cap: 147379083734
};

Jeżeli nie utworzymy jawnej deklaracji constructora, jego domyślna forma zostanie automatycznie wywołana przez silnik.

Składnia pola klasy pozwala na pominęcie constructora w większości komponentów. To atrakcyjne rozwiązanie, z którego korzystam na co dzień.

Aktualizacja stanu

Jedynym miejscem w komponencie, gdzie możemy przypisywać wartości do this.state, jest constructor/pole klasy. Podczas aktualizowania stanu nie powinniśmy bezpośrednio modyfikować tego obiektu. Wyraźnie zaznacza to dokumentacja Reacta.

state powinien być traktowany jako obiekt niemutowalny (więcej na ten temat za chwilę). Ignorowanie tego zalecenia wiąże się z nieoczekiwanymi efektami ubocznymi i znacznym spadkiem wydajności.

React udostępnia specjalną metodę this.setState(). Stanowi ona interfejs do aktualizowania stanu. Jako parametr przyjmuje obiekt lub funkcję. Na ich podstawie samodzielnie aktualizuje stan komponentu.

this.setState({ name: "EOS" });

Po wywołaniu setState dochodzi do mergowania przekazanego obiektu z state. Proces mergowania przebiega na tych samych zasadach co w przypadku Object.assign(). Jeżeli przekażemy właściwość, która do tej pory nie istniała w state, zostanie ona do niego dodana. Jeżeli właściwość istnieje, jej wartość zostanie nadpisana.

W obydwóch przypadkach, reszta state pozostanie nienaruszona.

// po wywołaniu setState({ name: "EOS" });
state = {
  name: "EOS",
  acronym: "BTC",
  value: 8714,
  cap: 147379083734
};

Wywołanie setState rozpoczyna proces reconcillation. Jego efektem jest ponowne renderowanie i wywołanie niektórych metod cyklu życia komponentu. Lekkomyślny adept Reacta z łatwością może wpaść w otchłań nieskończonej pętli. Uważaj, gdzie wywołujesz setState().

Warto wiedzieć: Aktualizacje state mogą mieć asynchroniczny charakter. setState() nie jest wywoływane natychmiastowo, najpierw trafia do kolejki.

Niemutowalność stanu

Już kilka razy przewinęło się to pokraczne słowo. Jaki jest bezpośredni wpływ tej całej niemutowalności state na pisany przez nas kod? Korzystanie z setState(), zamiast bezpośredniego przypisywania wartości do state, chroni nas jedynie przed jawnym mutowaniem.

Niestety, musimy również uważać na znacznie łatwiejsze do przeoczenia niejawne mutacje. Natura referowania obiektów w JS nie ułatwia tego zadania.

Przyjrzyjmy się najczęstszej pomyłce oraz technikom, które zaoszczędzą nam kłopotów.

Referencja zamiast kopii

const updatedCrypto = this.state;
updatedCrypto.name = "Bitcoin"; // state:
this.setState({ updatedCrypto })

Na pierwszy rzut oka wszystko powinno być w porzadku. Niestety, linijka const updatedCrypto = this.state; zapewniła nam jedynie referencję do this.state, zamiast oczekiwanej kopii tego obiektu. Nieświadomie dokonaliśmy zmiany name w state, a setState zostało wywołane nadaremno.

Aby ustrzec się przed taką niespodzianką, mamy do dyspozycji trzy techniki.

Object.assign()

Pierwszym rozwiązaniem jest metoda ze specyfikacji ES6. Jak już wspominałem wcześniej, wykonuje ona merge pomiędzy obiektami przekazanymi w parametrach. Świetnie sprawdza się przy tworzeniu kopii.

const updatedCrypto = Object.assign({}}, this.state);
updatedCryptos.name = "Bitcoin";
this.setState({ updatedCryptos })

Niestety, i w tym wypadku nie obejdzie się bez pułapek.

Przebudujmy state do formy, która będzie odpowiadała naszym realnym potrzebom.

state = {
  cryptos: [
	  { name: "Bitcoin", acronym: "BTC",value: 8714, cap: 147379083734 }, 
	  { name: "Etherum", acronym: "ETH",value: 688, cap: 67585640793 },
	  { name: "NEO", acronym: "NEO",value: 84, cap: 5515789500	},
    { name: "EOS", acronym: "EOS",value: 5, cap: 4141934598	}
  ]
};

Przypuścmy, że chcemy zmienić nazwę pierwszej kryptowaluty w tablicy cryptos.

const updatedCryptos = Object.assign([], this.state.cryptos);
updatedCryptos[0].name = "Ripple"; // updatedCryptos = [{ name: "Ripple", ... }]; state.cryptos = [{ name: "Ripple", ... }];

Damn. Znowu dokonaliśmy niejawnej modyfikacji state.

Object.assign() zapewnia jedynie płytką kopię obiektu przekazanego jako drugi parametr.

Płytka kopia zawiera dokładne kopie wartości oryginalnego obietu. Tak się składa, że w przypadku zagnieżdżonych obiektów, wartościami są referencje adresów w pamięci, a nie same obiekty.

Aby z tego wybrnąć, musimy skorzystać z zagnieżdżonych wywołań Object.assign(). Wykorzystamy trzeci parametr metody, który pozwala dodać lub nadpisać właściwości tworzonego obiektu.

const updatedCryptos = Object.assign([], this.state.cryptos, {
  0: Object.assign({}, this.state.cryptos[0], {
    name: "Ripple"
    }
  )
});

Niestety. Ani to czytelne, ani przyjazne w użyciu. Na szczęście to nie koniec naszych możliwości.

{ ...Object spread }

Z modyfikowaniem zagnieżdżonych właściwości znacznie lepiej poradzimy sobie z pomocą składni Object spread.

Object spread pozwala na przekopiowanie wszystkich właściwości obiektu, tak jak array spread robi to z elementami tablicy.

Jeżeli oprócz przekopiowania właściwości, chcemy zmodyfikować jedną z nich, robimy to po wykonaniu spreada.

const updatedCryptos = {
  ...this.state.cryptos,
  this.state.cryptos[0]: {
    ...this.state.cryptos[0],
    name: "Bitcoin"
  }
};

Object.spread to zdecydowanie najpopularniejszy sposób na wykonywanie zagnieżdżonych aktualizacji stanu. Podobnie jak pola klasy, jest to eksperymentalna funkcjonalność, która nie stanowi części oficjalnego standardu. Mimo to, w świecie Reata będziesz spotykał się z object spread na każdym kroku.

Funkcyjne setState()

Jeżeli podczas aktualizacji stanu chcemy polegać na jego dotychczasowej wartości, nie możemy zadowolić się prostym odwołaniem do this.state. Droga na skróty zaprowadzi nas do kolejnej pułapki.

>Skorzystam z akademickiego przykładu, ponieważ aktualny stan trackera nie nadaje się do zobrazowania tego problemu.

// state: { counter: 0 };
this.setState({ counter: this.state.counter + 1 });
this.setState({ counter: this.state.counter + 1 });
this.setState({ counter: this.state.counter + 1 });
// state: { counter: 1 } :O?!

Panie, co to za oszustwo. Już tłumaczę. React, aby zaoszczędzić sobie pracy, batchuje wywołania setState występujące blisko siebie. Ostatecznie inkrementujemy state.counter raz, a nie trzy razy, tak jak tego oczekiwaliśmy.

Jeżeli podczas aktualizacji chcemy odwołać się do poprzedniej wartości stanu, musimy użyć funkcji zamiast obiektu.

Funkcja przekazywana do setState jako pierwszy parametr otrzymuje obiekt prevState. Udostępnia on zawartość state sprzed wywołania setState. Dzięki prevState mamy gwarancję, że odwołujemy się do poprawnych wartości i unikniemy batchowania.

// state: { counter: 0 };
this.setState((prevState, props) => ({
  counter: prevState.counter + 1
}));
this.setState((prevState, props) => ({
  counter: prevState.counter + 1
}));
this.setState((prevState, props) => ({
  counter: prevState.counter + 1
}));
// state: { counter: 3 };

Zadanie domowe

Skorzystaj z danych przechowywanych w state komponentu klasowego App. Przekaż je do instancji Coin zamiast literałów. Dodaj do inicjalizacji state wartość całego kapitał na giełdzie. Przekaż ją do Header.

Pamiętaj, że to świetna okazja do sprawdzenia, ile informacji przyswoiłeś. Postaraj się wykonać zadanie samodzielnie. Rozwiązanie znajdziesz tutaj.

Podsumowanie

To dopiero początek zabawy z komponentami klasowymi. W kolejnych wpisach opiszę dynamiczne generowanie listy komponentów, wyświetlanie warunkowe oraz obsługę zdarzeń za pomocą callbacków.

Podobał Ci się dzisiejszy artykuł? Udostępnij go w Twoich ulubionych mediach społecznościowych. Może znajdzie się ktoś, dla kogo również będzie wartościowy.

Bądź na bieżąco z serią Pierwszy projekt w ReactJS. Wystarczy polubić fanpage AlgoSmart na fejsie, obserwować mój profil na Twitterze i regularnie odwiedzać portal Polski Front-end.

Zdjęcie tytułowe autorstwa: unsplash-logoAlice Achterhof

Do zobaczenia za tydzień :).