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>
);
}
}
Section2 Article9: Materiały dodatkowe
Common Components
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.