Wynoszenie stanu w górę
Bardzo często kilka komponentów jednocześnie musi odzwierciedlać te same zmiany w danych. W takim przypadku proponujemy przeniesienie wspólnego stanu do najbliższego wspólnego przodka. Zobaczmy, jak wygląda to w praktyce.
W tej części poradnika stworzymy kalkulator, który obliczy nam czy woda będzie się gotować w podanej temperaturze.
Rozpocznijmy od komponentu, który nazwiemy BoilingVerdict
. Komponent ten przyjmie atrybut z temperaturą o nazwie celsius
, a następnie zwróci informację, czy temperatura jest wystarczająco wysoka.
function BoilingVerdict(props) {
if (props.celsius >= 100) {
return <p>Woda będzie się gotować</p>; }
return <p>Woda nie będzie się gotować.</p>;}
Następnie stwórzmy komponent o nazwie Calculator
. Wyrenderuje on element <input>
, który pozwoli wpisać temperaturę oraz zachowa jego wartość w this.state.temperature
.
Dodatkowo, będzie on renderował komponent BoilingVerdict
dla obecnej wartości inputa.
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''}; }
handleChange(e) {
this.setState({temperature: e.target.value}); }
render() {
const temperature = this.state.temperature; return (
<fieldset>
<legend>Podaj temperaturę w Celsjuszach:</legend>
<input value={temperature} onChange={this.handleChange} /> <BoilingVerdict celsius={parseFloat(temperature)} /> </fieldset>
);
}
}
Dodawanie drugiego inputa
Naszym kolejnym wymogiem jest, aby oprócz inputa do wpisywania temperatury w Celsjuszach, dostarczyć także drugi input, który przyjmie temperaturę w Fahrenheitach. Oba inputy powinny być ze sobą zsynchronizowane.
Zacznijmy od wyizolowania komponentu TemperatureInput
z komponentu Calculator
. Dodamy do niego nowy atrybut scale
, który będzie mógł przyjmować wartość "c"
lub "f"
:
const scaleNames = { c: 'Celsjuszach', f: 'Fahrenheitach'};
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''};
}
handleChange(e) {
this.setState({temperature: e.target.value});
}
render() {
const temperature = this.state.temperature;
const scale = this.props.scale; return (
<fieldset>
<legend>Podaj temperaturę w {scaleNames[scale]}:</legend> <input value={temperature}
onChange={this.handleChange} />
</fieldset>
);
}
}
Zmieńmy komponent Calculator
tak, by renderował dwa osobne inputy z temperaturą:
class Calculator extends React.Component {
render() {
return (
<div>
<TemperatureInput scale="c" /> <TemperatureInput scale="f" /> </div>
);
}
}
Mamy teraz dwa inputy, jednak jeśli podamy temperaturę w jednym z nich, drugi nie zostanie zaktualizowany. Jest to sprzeczne z naszymi wymogami: chcemy, by oba inputy były ze sobą zsynchronizowane.
Nie możemy też wyświetlić BoilingVerdict
z poziomu komponentu Calculator
. Spowodowane jest to faktem, iż Calculator
nie ma dostępu do informacji o obecnej temperaturze, która schowana jest w TemperatureInput
.
Pisanie funkcji konwertujących
Na początek napiszmy dwie funkcje do konwertowania temperatury ze stopni Celsjusza na Fahrenheita i odwrotnie:
function toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
Obie te funkcje konwertują liczby. Napiszmy jeszcze jedną funkcję, która jako argumenty przyjmie ciąg znaków temperature
oraz funkcję konwertującą,, a zwróci inny ciąg znaków. Użyjemy jej do wyliczenia wartości jednego inputa w oparciu o drugi.
Funkcja zwróci zaokrąglony do trzeciego miejsca po przecinku wynik lub pusty ciąg znaków, jeśli temperature
jest nieprawidłowe.
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return '';
}
const output = convert(input);
const rounded = Math.round(output * 1000) / 1000;
return rounded.toString();
}
Na przykład, tryConvert('abc', toCelsius)
zwróci pusty ciąg znaków, natomiast tryConvert('10.22', toFahrenheit)
zwróci '50.396'
.
Wynoszenie stanu w górę
Obecnie obydwa komponenty TemperatureInput
trzymają swoje wartości niezależnie, w lokalnym stanie:
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''}; }
handleChange(e) {
this.setState({temperature: e.target.value}); }
render() {
const temperature = this.state.temperature; // ...
Nam zależy jednak, aby oba te inputy były ze sobą zsynchronizowane. Podczas aktualizacji inputa z Celsjuszami, input z Fahrenheitami powinien odzwierciedlać przekonwertowaną temperaturę i odwrotnie.
W Reakcie współdzielenie stanu komponentu można osiągnąć poprzez utworzenie stanu w najbliższym wspólnym przodku. Nazywa się to “wynoszeniem stanu w górę” (ang. lifting state up).
Jeśli wyniesiemy stan do komponentu Calculator
, zostanie on “źródłem prawdy” dla obecnej temperatury w obu inputach. Komponent ten może poinformować je o trzymaniu zgodnych wartości. Oba inputy będą ze sobą zawsze zsynchronizowane, ponieważ atrybuty dla komponentów TemperatureInput
przychodzą ze wspólnego przodka - komponentu Calculator
.
Przeanalizujmy to krok po kroku.
Najpierw zastąpimy this.state.temperature
używając this.props.temperature
w komponencie TemperatureInput
. Na tę chwilę przyjmijmy, że atrybut this.props.temperature
istnieje, jednakże w przyszłości będziemy musieli go przekazać z komponentu Calculator
:
render() {
// Wcześniej było: const temperature = this.state.temperature;
const temperature = this.props.temperature; // ...
Wiemy już, że atrybuty są tylko do odczytu. Kiedy temperature
znajdowało się w lokalnym stanie, komponent TemperatureInput
mógł po prostu wywołać this.setState()
, by je zmienić. Jednak, ponieważ teraz atrybut temperature
jest przekazywany od rodzica, komponent TemperatureInput
nie ma nad nim żadnej kontroli.
W Reakcie rozwiązujemy to na ogół poprzez przekształcenie komponentu w “kontrolowany”. Tak samo jak w drzewie DOM, gdzie element <input>
akceptuje zarówno atrybut value
, jak i onChange
jako atrybuty, tak i komponent TemperatureInput
może akceptować od rodzica atrybuty temperature
oraz onTemperatureChange
.
Teraz za każdym razem, gdy komponent TemperatureInput
zechce zaktualizować temperaturę, wywoła funkcję this.props.onTemperatureChange
:
handleChange(e) {
// Wcześniej było: this.setState({temperature: e.target.value});
this.props.onTemperatureChange(e.target.value); // ...
Wskazówka:
Nazwy, których używamy w tym poradniku, takie jak
temperature
czyonTemperatureChange
, są tylko nazwami przykładowymi i można je zmienić w dowolny sposób. Jednak przyjęło się nazywać je np.value
ionChange
.
Atrybuty onTemperatureChange
oraz temperature
zostaną przekazane przez rodzica - komponent Calculator
. Obsłuży on zmianę poprzez zmodyfikowanie swojego stanu lokalnego, zatem oba inputy zostaną ponownie wyrenderowane z nowymi wartościami. Już wkrótce przyjrzymy się nowej implementacji komponentu Calculator
.
Zanim jednak zagłębimy się w zmiany w komponencie Calculator
, podsumujmy co zmieniło się w TemperatureInput
. Pozbyliśmy się stanu lokalnego i zamiast korzystać z this.state.temperature
, możemy teraz używać this.props.temperature
. Zamiast wywoływać this.setState()
, kiedy chcemy dokonać zmiany, możemy teraz wywołać this.props.onTemperatureChange()
, które udostępniane jest przez komponent Calculator
.
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(e) {
this.props.onTemperatureChange(e.target.value); }
render() {
const temperature = this.props.temperature; const scale = this.props.scale;
return (
<fieldset>
<legend>Podaj temperaturę w {scaleNames[scale]}:</legend>
<input value={temperature}
onChange={this.handleChange} />
</fieldset>
);
}
}
Przyjrzyjmy się teraz komponentowi Calculator
.
Wartości atrybutów obecnego inputa - temperature
oraz scale
będziemy przechowywać w stanie lokalnym. Użyjemy stanu, który “wynieśliśmy w górę” z inputów i będzie on nam służył za “źródło prawdy”. Zarazem jest to najmniejsza ilość danych, wystarczająca do wyrenderowania obydwóch inputów.
Na przykład, jeśli do inputa z Celsjuszami wpiszemy 37
, stan komponentu Calculator
będzie wyglądał następująco:
{
temperature: '37',
scale: 'c'
}
Jeśli zdecydujemy się później edytować pole z Fahrenheitami i wpiszemy liczbę 212
, stan komponentu Calculator
będzie się przedstawiał tak:
{
temperature: '212',
scale: 'f'
}
Moglibyśmy przechowywać wartości dla obydwóch inputów, jednak jest to zupełnie zbędne. Trzymanie wartości ostatnio zmienianego inputa i jego skali wystarczy do uzyskania pożądanego wyniku. Możemy teraz wyliczyć wartość drugiego inputa na podstawie obecnej wartości temperature
oraz scale
.
Inputy są ze sobą zsynchronizowane, dzięki temu, że ich wartości są obliczane na podstawie tego samego stanu.
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
this.state = {temperature: '', scale: 'c'}; }
handleCelsiusChange(temperature) {
this.setState({scale: 'c', temperature}); }
handleFahrenheitChange(temperature) {
this.setState({scale: 'f', temperature}); }
render() {
const scale = this.state.scale; const temperature = this.state.temperature; const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature; const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
return (
<div>
<TemperatureInput
scale="c"
temperature={celsius} onTemperatureChange={this.handleCelsiusChange} /> <TemperatureInput
scale="f"
temperature={fahrenheit} onTemperatureChange={this.handleFahrenheitChange} /> <BoilingVerdict
celsius={parseFloat(celsius)} /> </div>
);
}
}
Nie ważne teraz, który z inputów edytujemy, w komponencie Calculator
aktualizowane będą zarówno this.state.temperature
, jak i this.state.scale
.
Podsumujmy teraz, co dzieje się, gdy edytujemy jeden z inputów:
- React wywołuje funkcję
onChange
na elemencie DOM<input>
. W naszym przypadku jest to metodahandleChange
z komponentuTemperatureInput
. - Metoda
handleChange
w komponencieTemperatureInput
wywołujethis.props.onTemperatureChange()
z nową wartością. Atrybuty tego komponentu, takie jakonTemperatureChange
, pochodzą od rodzica - komponentuCalculator
. - Podczas poprzedniego renderu, komponent
Calculator
określił, że atrybutonTemperatureChange
przychodzący z komponentuTemperatureInput
z Celsjuszami, jest wywoływany metodąhandleCelsiusChange
, natomiastonTemperatureChange
z komponentu z Fahrenheitami, jest teraz wywoływany metodąhandleFahrenheitChange
. Dzięki temu, w zależności od tego, który input dostanie nową wartość, jedna z tych metod zostanie wywołana. - Wewnątrz tych metod komponent
Calculator
prosi Reacta o ponowne wyrenderowanie komponentu poprzez użyciethis.setState()
z nową wartością inputa oraz obecnie używaną skalą. - React wywołuje metodę
render
komponentuCalculator
, by dowiedzieć się, jak powinien wyglądać jego UI. Wartości obu inputów są ponownie obliczane, bazując na aktualnej temperaturze i skali. To w tym miejscu odbywa się konwersja temperatury. - React wywołuje metodę
render
poszczególnych komponentówTemperatureInput
, która otrzymuje nowe atrybuty od komponentuCalculator
. Dzięki temu dowiaduje się, jak powinien wyglądać komponent. - React wywołuje metodę
render
komponentuBoilingVerdict
, przekazując do niego temperaturę w Celsjuszach jako atrybut. - Paczka “React DOM” aktualizuje drzewo DOM otrzymaną strukturą, dopasowując się do wartości inputów. Input, którego właśnie edytowaliśmy, otrzymuje swoją obecną wartość, natomiast drugi input otrzymuje temperaturę po konwersji.
Każda aktualizacja przechodzi ten sam proces, więc inputy są zawsze zsynchronizowane.
Wnioski
Wszelkie dane, które zmieniają się w aplikacji reactowej, powinny mieć swoje pojedyncze “źródło prawdy”. Na ogół stan dodaje się najpierw do komponentu, który potrzebuje go podczas renderowania. Następnie, jeśli inny komponent potrzebuje tych samych danych, możemy “wynieść je w górę” do najbliższego wspólnego przodka. Zamiast próbować synchronizować stan między różnymi komponentami, lepiej polegać na przepływie danych “z góry na dół”.
Wynoszenie stanu w górę skutkuje powstaniem większej ilości tzw. kodu szablonowego (ang. boilerplate code), niż wiązanie dwukierunkowe (ang. two-way binding), jednak pozwala na łatwiejsze znalezienie i wyizolowanie błędów. Ponieważ każdy stan “żyje” w jakimś komponencie i tylko ten komponent może go zmienić, ryzyko powstania błędów jest znacznie mniejsze. Ponadto, można dodać własną logikę, która pozwoli odrzucić lub przekształcić dane wejściowe od użytkownika.
Jeśli jakąś wartość można wydedukować na podstawie zarówno atrybutów, jak i stanu komponentu, to prawdopodobnie nie powinna się ona znaleźć w stanie. Na przykład, zamiast przetrzymywać jednocześnie celsiusValue
i fahrenheitValue
, przechowujemy jedynie ostatnio edytowane temperature
oraz scale
. Wartość drugiego inputa może być przecież wyliczona w metodzie render()
. Pozwala nam to na czyszczenie lub zaokrąglanie wartości w drugim polu bez utraty dokładności w danych pochodzących od użytkownika.
Jeśli zauważysz nieprawidłowości w interfejsie swojej aplikacji, możesz użyć Narzędzi deweloperskich dla Reacta, aby zbadać atrybuty i przemieszczać się po drzewie, dopóki nie znajdziesz komponentu odpowiedzialnego za zmianę stanu. Pozwoli Ci to znaleźć przyczynę błędów: