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:

Dzięki temu oba pola są zawsze zsynchronizowane, a stan jest zarządzany w jednym miejscu.

Zalety Podnoszenia Stanu

Kiedy Podnosić Stan?

Podnoś stan, gdy:

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:

  1. Przenieś stan: Usuń lokalny stan z komponentów podrzędnych (np. z TemperatureInput) i przenieś go do wspólnego przodka (Calculator).
  2. Przekaż stan w dół (props): Wspólny przodek przekazuje aktualną wartość stanu do komponentów podrzędnych jako propsy.
  3. 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:

  1. Stwórz komponent Counter, który ma własny lokalny stan dla licznika i przycisk do jego inkrementacji. Wyświetla aktualną wartość licznika.
  2. Stwórz komponent nadrzędny CountersContainer.
  3. W CountersContainer użyj useState do przechowywania wartości dla dwóch liczników (np. count1 i count2).
  4. Zmodyfikuj komponent Counter tak, aby przyjmował aktualną wartość licznika (value) i funkcję do jej aktualizacji (onIncrement) jako propsy. Usuń z niego lokalny stan.
  5. W CountersContainer wyrenderuj dwa komponenty Counter, przekazując im odpowiednie wartości stanu (count1, count2) i funkcje aktualizujące (np. () => setCount1(c => c + 1)).
  6. 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.

  1. Stwórz komponent TodoList, który przyjmuje listę zadań (todos) jako props i renderuje ją (użyj map i pamiętaj o kluczach).
  2. 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.
  3. Stwórz komponent nadrzędny TodoApp.
  4. W TodoApp użyj useState do przechowywania tablicy zadań (todos).
  5. Zdefiniuj w TodoApp funkcję handleAddTodo, która przyjmuje tekst nowego zadania, tworzy nowy obiekt zadania (z unikalnym ID) i aktualizuje stan todos, dodając do niego nowe zadanie.
  6. Wyrenderuj w TodoApp komponenty AddTodoForm (przekazując handleAddTodo jako onAddTodo) i TodoList (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.