Lekcja 11: Podnoszenie Stanu (Lifting State Up)
Często zdarza się, że kilka komponentów w aplikacji React potrzebuje dostępu do tych samych, zmieniających się danych lub musi na nie reagować. W takich sytuacjach pojawia się pytanie: gdzie przechowywać ten wspólny stan? Odpowiedzią jest wzorzec "podnoszenia stanu" (lifting state up).
Problem: Stan Rozproszony
Wyobraźmy sobie aplikację z konwerterem temperatur, gdzie mamy dwa pola input: jedno dla stopni Celsjusza, drugie dla Fahrenheita. Chcemy, aby zmiana wartości w jednym polu automatycznie aktualizowała wartość w drugim.
Gdyby każdy komponent input zarządzał swoim własnym, lokalnym stanem, synchronizacja wartości między nimi byłaby trudna. Zmiana w jednym komponencie musiałaby jakoś poinformować drugi komponent o potrzebie aktualizacji, co prowadziłoby do skomplikowanych przepływów danych.
Przykład: Konwerter Temperatur
import React, { useState } from \'react\';
// Funkcje konwertujące temperatury
function toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
// Funkcja próbująca konwersji, zwraca pusty string w razie błędu
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();
}
// Komponent wyświetlający informację o wrzeniu wody
function BoilingVerdict({ celsius }) {
if (celsius >= 100) {
return <p>Woda wrze.</p>;
}
return <p>Woda nie wrze.</p>;
}
const scaleNames = {
c: \'Celsjusza\',
f: \'Fahrenheita\'
};
// Komponent dla pojedynczego pola input temperatury
function TemperatureInput({ scale, temperature, onTemperatureChange }) {
const handleChange = (e) => {
// Informujemy rodzica o zmianie
onTemperatureChange(e.target.value);
};
return (
<fieldset>
<legend>Wprowadź temperaturę w stopniach {scaleNames[scale]}:</legend>
<input value={temperature} onChange={handleChange} />
</fieldset>
);
}
// Komponent nadrzędny - Calculator
function Calculator() {
// Stan podniesiony do komponentu nadrzędnego
const [temperature, setTemperature] = useState(\'\');
const [scale, setScale] = useState(\'c\'); // \'c\' lub \'f\'
// Handlery przekazywane do dzieci
const handleCelsiusChange = (temp) => {
setScale(\'c\');
setTemperature(temp);
};
const handleFahrenheitChange = (temp) => {
setScale(\'f\');
setTemperature(temp);
};
// Obliczanie wartości dla obu pól na podstawie stanu
const celsius = scale === \'f\' ? tryConvert(temperature, toCelsius) : temperature;
const fahrenheit = scale === \'c\' ? tryConvert(temperature, toFahrenheit) : temperature;
return (
<div>
<TemperatureInput
scale="c"
temperature={celsius}
onTemperatureChange={handleCelsiusChange} />
<TemperatureInput
scale="f"
temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange} />
<BoilingVerdict celsius={parseFloat(celsius)} />
</div>
);
}
export default Calculator;
Analiza przykładu:
- Stan
temperature
(aktualnie wpisywana wartość) iscale
(skala tej wartości) jest przechowywany wCalculator
. Calculator
renderuje dwa komponentyTemperatureInput
.- Do
TemperatureInput
dla Celsjusza przekazuje obliczoną wartośćcelsius
i funkcjęhandleCelsiusChange
. - Do
TemperatureInput
dla Fahrenheita przekazuje obliczoną wartośćfahrenheit
i funkcjęhandleFahrenheitChange
. - Gdy użytkownik wpisuje coś w polu Celsjusza, wywoływany jest
handleCelsiusChange
, który ustawiascale
na \'c\' i aktualizujetemperature
. - Gdy użytkownik wpisuje coś w polu Fahrenheita, wywoływany jest
handleFahrenheitChange
, który ustawiascale
na \'f\' i aktualizujetemperature
. - Przy każdym renderowaniu
Calculator
oblicza wartościcelsius
ifahrenheit
na podstawie aktualnego stanutemperature
iscale
, a następnie przekazuje je do odpowiednich pól input.
Dzięki temu oba pola są zawsze zsynchronizowane, a stan jest zarządzany w jednym miejscu.
Zalety Podnoszenia Stanu
- Jedno źródło prawdy: Upraszcza rozumowanie o stanie aplikacji, ponieważ dane znajdują się w jednym, przewidywalnym miejscu.
- Łatwiejsza synchronizacja: Komponenty zależne od tego samego stanu są automatycznie aktualizowane, gdy stan się zmienia.
- Lepsza reużywalność: Komponenty podrzędne stają się bardziej "głupie" (dumb components), ponieważ tylko wyświetlają dane (propsy) i wywołują funkcje zwrotne, co czyni je bardziej reużywalnymi.
Kiedy Podnosić Stan?
Podnoś stan, gdy:
- Kilka komponentów potrzebuje odzwierciedlać te same zmieniające się dane.
- Stan musi być dostępny dla komponentu nadrzędnego lub rodzeństwa.
- Chcesz uniknąć skomplikowanej komunikacji między komponentami na tym samym poziomie.
Zawsze staraj się umieszczać stan na najniższym możliwym wspólnym przodku.
Ćwiczenie praktyczne
Pokaż rozwiązanie
Wzorzec "lifting state up" polega na przeniesieniu współdzielonego stanu do najbliższego wspólnego przodka komponentów, które tego stanu potrzebują. Ten wspólny przodek staje się "jedynym źródłem prawdy" dla tego stanu.
W naszym przykładzie konwertera temperatur, wspólnym przodkiem dla obu pól input byłby komponent nadrzędny (np. Calculator
). To on będzie przechowywał aktualną temperaturę i informację o skali (Celsjusz czy Fahrenheit).
Jak to działa:
- Przenieś stan: Usuń lokalny stan z komponentów podrzędnych (np. z
TemperatureInput
) i przenieś go do wspólnego przodka (Calculator
). - Przekaż stan w dół (props): Wspólny przodek przekazuje aktualną wartość stanu do komponentów podrzędnych jako propsy.
- Przekaż funkcje aktualizujące w dół (props): Wspólny przodek przekazuje również funkcje (callbacki) do komponentów podrzędnych jako propsy. Te funkcje pozwalają komponentom podrzędnym informować przodka o potrzebie zmiany stanu.
Cel: Stworzyć dwa niezależne liczniki, których sumę będzie wyświetlał komponent nadrzędny.
Kroki:
- Stwórz komponent
Counter
, który ma własny lokalny stan dla licznika i przycisk do jego inkrementacji. Wyświetla aktualną wartość licznika. - Stwórz komponent nadrzędny
CountersContainer
. - W
CountersContainer
użyjuseState
do przechowywania wartości dla dwóch liczników (np.count1
icount2
). - Zmodyfikuj komponent
Counter
tak, aby przyjmował aktualną wartość licznika (value
) i funkcję do jej aktualizacji (onIncrement
) jako propsy. Usuń z niego lokalny stan. - W
CountersContainer
wyrenderuj dwa komponentyCounter
, przekazując im odpowiednie wartości stanu (count1
,count2
) i funkcje aktualizujące (np.() => setCount1(c => c + 1)
). - W
CountersContainer
wyświetl sumę obu liczników (count1 + count2
).
Rozwiązanie
// src/Counter.jsx
import React from \'react\';
// Komponent Counter teraz przyjmuje wartość i funkcję onIncrement z propsów
function Counter({ label, value, onIncrement }) {
return (
<div style={{ border: \'1px solid grey\', padding: \'10px\', margin: \'5px\' }}>
<p>{label}: {value}</p>
<button onClick={onIncrement}>Zwiększ {label}</button>
</div>
);
}
export default Counter;
// src/CountersContainer.jsx
import React, { useState } from \'react\';
import Counter from \'./Counter\';
function CountersContainer() {
// Stan podniesiony do rodzica
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// Funkcje aktualizujące przekazywane jako propsy
const handleIncrement1 = () => {
setCount1(prevCount => prevCount + 1);
};
const handleIncrement2 = () => {
setCount2(prevCount => prevCount + 1);
};
const total = count1 + count2;
return (
<div>
<h2>Liczniki</h2>
<Counter label="Licznik 1" value={count1} onIncrement={handleIncrement1} />
<Counter label="Licznik 2" value={count2} onIncrement={handleIncrement2} />
<h3>Suma: {total}</h3>
</div>
);
}
export default CountersContainer;
// W App.jsx
import React from \'react\';
import CountersContainer from \'./CountersContainer\';
function App() {
return (
<div>
<CountersContainer />
</div>
);
}
export default App;
Zadanie do samodzielnego wykonania
Stwórz aplikację z listą zadań (Todo List), gdzie stan listy zadań jest podniesiony do komponentu nadrzędnego.
- Stwórz komponent
TodoList
, który przyjmuje listę zadań (todos
) jako props i renderuje ją (użyjmap
i pamiętaj o kluczach). - Stwórz komponent
AddTodoForm
, który zawiera pole input i przycisk do dodawania nowego zadania. Gdy użytkownik wpisze zadanie i kliknie przycisk, komponent powinien wywołać funkcję zwrotną (np.onAddTodo
) przekazaną przez props, podając tekst nowego zadania. - Stwórz komponent nadrzędny
TodoApp
. - W
TodoApp
użyjuseState
do przechowywania tablicy zadań (todos
). - Zdefiniuj w
TodoApp
funkcjęhandleAddTodo
, która przyjmuje tekst nowego zadania, tworzy nowy obiekt zadania (z unikalnym ID) i aktualizuje stantodos
, dodając do niego nowe zadanie. - Wyrenderuj w
TodoApp
komponentyAddTodoForm
(przekazująchandleAddTodo
jakoonAddTodo
) iTodoList
(przekazując aktualną listętodos
).
FAQ - Podnoszenie Stanu (Lifting State Up)
Czy podnoszenie stanu nie prowadzi do zbyt dużych komponentów nadrzędnych?
Czasami tak. Jeśli komponent nadrzędny zaczyna zarządzać zbyt dużą ilością stanu dla wielu różnych dzieci, może stać się skomplikowany. W takich sytuacjach warto rozważyć inne techniki zarządzania stanem, takie jak Context API (omówiony później) lub zewnętrzne biblioteki (Redux, Zustand), które pozwalają na bardziej globalne zarządzanie stanem bez przekazywania propsów przez wiele poziomów.
Co jeśli komponenty potrzebujące stanu są bardzo daleko od siebie w drzewie komponentów?
Podnoszenie stanu działa najlepiej, gdy wspólny przodek nie jest zbyt wysoko w drzewie. Jeśli stan musi być współdzielony przez komponenty w zupełnie różnych częściach aplikacji, przekazywanie propsów przez wiele poziomów pośrednich (tzw. "prop drilling") staje się uciążliwe. Wtedy zdecydowanie warto użyć Context API lub biblioteki do zarządzania stanem globalnym.
Czy podniesienie stanu wpływa na wydajność?
Gdy stan w komponencie nadrzędnym się zmienia, powoduje to ponowne renderowanie tego komponentu oraz wszystkich jego dzieci, które otrzymują nowe propsy. Jeśli drzewo komponentów jest duże, może to wpłynąć na wydajność. Można temu zaradzić, stosując techniki optymalizacji, takie jak `React.memo`, `useCallback`, `useMemo` (omówione później), aby zapobiec niepotrzebnym ponownym renderowaniom komponentów, które nie zależą od zmienionego stanu.
Czy zawsze muszę podnosić stan, gdy dwa komponenty potrzebują tych samych danych?
Nie zawsze. Jeśli dane są statyczne lub pochodzą z zewnętrznego źródła (np. API) i nie są modyfikowane przez te komponenty, każdy z nich może pobrać te dane niezależnie (choć może to prowadzić do redundancji). Podnoszenie stanu jest kluczowe, gdy stan jest *współdzielony i modyfikowalny* przez różne komponenty.
Jak nazwać funkcje zwrotne przekazywane jako propsy?
Dobrą konwencją jest używanie nazw opisujących zdarzenie, np. `onClick`, `onChange`, `onSubmit`. Dla funkcji aktualizujących stan przekazywanych w dół, często używa się nazw typu `on[NazwaStanu]Change`, np. `onTemperatureChange`, `onColorChange`, lub bardziej generycznych jak `onUpdate`, `onSelect`.
Czy komponenty podrzędne mogą bezpośrednio modyfikować stan rodzica?
Nie. Komponenty podrzędne nie mają bezpośredniego dostępu do stanu rodzica. Mogą jedynie wywołać funkcję zwrotną (callback) przekazaną przez rodzica jako props. To rodzic decyduje, jak zareagować na to wywołanie i jak zaktualizować swój własny stan. To zapewnia jednokierunkowy przepływ danych.
Czy podnoszenie stanu to jedyny sposób na współdzielenie logiki między komponentami?
Nie. Do współdzielenia logiki (niekoniecznie stanu) można również używać niestandardowych Hooków (custom hooks), które pozwalają na ekstrakcję i reużycie logiki stanowej i efektów ubocznych w wielu komponentach funkcyjnych. Omówimy je w jednej z kolejnych lekcji.