Tworzymy w React prosty komponent CountDown, który odlicza od przekazanej w props wartości w dół co każdą sekundę. W momencie, gdy odliczanie się zakończy, komponent wyświetla wartość przekazaną w JSX pomiędzy jego otwierającym i zamykającym tagiem (poznajemy props.children). Wykonujemy pierwszy prop forwarding.

Tworzymy aplikację React – standardowe kroki

Tradycyjnie, przechodzimy do folderu, w którym chcemy utworzyć naszą aplikację React i wykonujemy poniższą komendę:

npx create-react-app countdown-app

Gdy wszystko zainstaluje się poprawnie, przechodzimy do tego folderu komendą change directory:

cd countdown-app

Aplikację możemy zacząć serwować komendą:

npm start

Serwowanie wyłączamy poprzez CTRL+C

Tworzymy komponent CountDown

W App.js importujemy nasze ulubione hooki:

import { useState, useEffect } from 'react';

Tworzymy funkcję (w App.js) naszego komponentu:

function CountDown(props) {
  const [timeLeft, setTimeLeft] = useState(props.time);
  return (
    <p>Time: {timeLeft}</p>
  )
}

Wartość timeLeft ma pochodzić z propsów. Sprawdzamy, czy wszystko działa jak należy używając naszego komponentu w App:

function App() {
  return (
    <div className="App">
      <CountDown time={10} />
    </div>
  );
}

export default App;

Na razie nasz komponent jeszcze niczego nie robi poza wyświetlaniem czasu przekazanego z propsów.

Implementujemy odliczanie – hook useEffect

W naszym komponencie potrzebny nam będzie hook useEffect z zależnością timeLeft:

const [timeLeft, setTimeLeft] = useState(props.time);
  useEffect(()=> {

  }, [timeLeft]);

Jeżeli czas się wyczerpie – ma ustawić czas na „null”:

useEffect(()=> {
    if(timeLeft===0){
      setTimeLeft(null)
   }

  }, [timeLeft]);

Teraz dodajemy interwał, który co sekundę zmniejsza czas:

useEffect(()=> {
    if(timeLeft===0){
      setTimeLeft(null)
   }
   const intervalId = setInterval(() => {
    setTimeLeft(timeLeft - 1);
  }, 1000);

  }, [timeLeft]);

Pozostało nam jeszcze dodać return z funkcją czyszczącą, która zwraca czyszczenie interwału:

useEffect(()=> {
    if(timeLeft===0){
      setTimeLeft(null)
   }
   const intervalId = setInterval(() => {
    setTimeLeft(timeLeft - 1);
  }, 1000);
  return () => clearInterval(intervalId);
  }, [timeLeft]);

Dostosujmy teraz return naszej funkcji:

return (
    <div>{timeLeft}</div>
  )

Testujemy. Z jakichś względów 0 nam znika, ale później timer dalej kontynuuje do -1, -2 i tak dalej. Musimy dodać „wyjście” gdy timeLeft przestanie istnieć:

useEffect(()=> {
    if(timeLeft===0){
      setTimeLeft(null)
   }
   if (!timeLeft) return;
(...)

Teraz powinno działać bez zarzutu.

Zawartość po skończeniu odliczania

Dodanie zawartości wyświetlającej się po skończeniu odliczania to fajny pomysł. W prosty sposób możemy to osiągnąć tak:

return (
    <div>{timeLeft ? timeLeft : <p>CountDown finished</p>}</div>
  )

Problem w tym, że nie jest to w żaden sposób dynamiczne. Powinniśmy dać naszemu użytkownikowi więcej kontroli. Tutaj props może nas poratować:

return (
    <div>{timeLeft ? timeLeft : <p>{props.finished}</p>}</div>
  )

Oczywiście teraz musimy pamiętać o przekazaniu atrybutu finished do elementu <CountDown />:

function App() {
  return (
    <div className="App">
      <CountDown time={10} finished="Countdown finished" />
    </div>
  );
}

Jak widać nie jest to specjalnie wygodne ani intuicyjne. Ale jest inny sposób – props.children, czyli „dzieci” elementu. Nasz element jest „samozamykający” czyli dzieci żadnych nie ma – ale przecież wcale nie musi taki być.

Dodanie tego jest łatwiejsze, niż nam się wydaje. W naszym komponencie używamy props.children:

return (
    <div>{timeLeft ? timeLeft : props.children}</div>
  )

Teraz wewnątrz naszego komponentu użyjemy wartości, która ma zostać wyświetlana. To są właśnie dzieci komponentu:

function App() {
  return (
    <div className="App">
      <CountDown time={10}> 
      <p>Countdown finished!!!</p>
      </CountDown>
    </div>
  );
}

Możemy tam ładować dowolny JSX. To wszystko się wyświetli, dopiero po skończeniu odliczania – tak to sobie zaprogramowaliśmy.

Dekompozycja props – ułatwienie pisania kodu

Na chwilę obecną nasz komponent używy w App wygląda tak:

<CountDown time={10}> 
  <p>Countdown finished!!!</p>
</CountDown>

Atrybut time zawiera to, co przekazujemy do props.time i jest odczytywane tutaj:

const [timeLeft, setTimeLeft] = useState(props.time);

To, co mamy pomiędzy tagiem otwierającym i zamykającym (w naszym wypadku tag <p> z komunikatem o zakończeniu odliczania) to zaś props.children, które jest odczytywane tutaj:

return (
    <div>{timeLeft ? timeLeft : props.children}</div>
  )

Aby w ogóle dało się korzystać z props, nasza funkcja musi przyjmować taki argument:

function CountDown(props) {

Ciekawostka polega na tym, że możemy użyć nowej składni JavaScript, aby dokonać dekompozycji naszych propsów:

function CountDown({time, children}) {

Teraz możemy pominąć słówko „props” w naszej funkcji:

function CountDown({time, children}) {
  const [timeLeft, setTimeLeft] = useState(time);
  (...)
  return (
    <div>{timeLeft ? timeLeft : children}</div>
  )
}

Props forwarding – ustawianie dodatkowych atrybutów dla naszego <div>

Oto nasz komponent użyty w App:

<CountDown time={10}> 
      <p>Countdown finished!!!</p>
</CountDown>

Mamy tutaj prop „time” ustawiony na wartość 10 oraz prop „children”, którym jest tag <p> do wyświetlania, gdy już skończy się odliczanie. Oczywiście możemy ten tag „przyprawić” dowolnymi atrybutami:

<CountDown time={10}> 
      <p className='someClass' id='someid'>Countdown finished!!!</p>
</CountDown>

I rzeczywiście po zakończeniu odliczania ten nasz licznik pokaże nam <p> z taką klasą oraz ID. Zobaczmy jednak, co nasza funkcja Countdown zwraca:

 return (
    <div>{timeLeft ? timeLeft : children}</div>
  )

Zwraca <div>, który pokazuje albo czas do końca odliczania, albo nasze children. Czy ten <div> można ubarwić np. poprzez atrybuty className oraz id?

Można:

return (
    <div className='someClass' id='someid'>{timeLeft ? timeLeft : children}</div>
  )

Problem w tym, że zakodowaliśmy to „na twardo” w naszym komponencie. Co jednak jeśli chcemy, aby różne komponenty miały różne ID i klasy (oraz inne atrybuty, jeżeli trzeba)? Co jeżeli piszemy tylko biblioteczkę komponentów, a ich stylowanie chcemy pozostawić użytkownikowi? W taki sposób, aby mógł napisać ten kod:

<CountDown time={10} className='someClass' id='someid'> 
      <p>Countdown finished!!!</p>
</CountDown>

Propsy time oraz children mają być wyłapywane jak już to ma miejsce. Inne propsy mają być zebrane i przekazane naszemu elementowi <div> znajdującemu się tutaj:

return (
    <div>{timeLeft ? timeLeft : children}</div>
  )

Aby to osiągnąć musimy zebrać pozostałe, opcjonalne propsy nową składnią JavaScript (rest operator):

function CountDown({time, children, ...props}) {

Następnie musimy użyć spread operator, aby tymi propsami „przyprawić” nasz <div>:

return (
    <div {...props}>{timeLeft ? timeLeft : children}</div>
  )

Tym sposobem poza propsami time i children pozwalamy użytkownikowi na opcjonalne atrybuty, które nasz komponent potrafi poprawnie wykorzystać. Możemy teraz z nich skorzystać i zobaczyć, czy wszystko działa jak należy:

function App() {
  return (
    <div className="App">
      <CountDown time={10} className='someClass' id='someid'> 
      <p>Countdown finished!!!</p>
      </CountDown>
      <CountDown time={5} className='someOtherClass' id='someOtherid'> 
      <p>Countdown 2 finished!!!</p>
      </CountDown>
    </div>
  );
}

Tym sposobem osiągnęliśmy prop forwarding.

Własny, dynamiczny element zamiast <div> – kontener

Na razie nasza funkcja zwraca element <div> z możliwością nadawania mu dynamicznych propsów (jak id, className oraz inne), w którym wyświetla się odliczanie, a po jego zakończeniu wyświetlają się dzieci przekazane do tego elementu:

return (
    <div {...props}>{timeLeft ? timeLeft : children}</div>
  )

Co jednak, gdybyśmy chcieli jeszcze więcej dynamiki i ten element <div> zamienić na dowolny element, przekazywany jako props do naszej funkcji? Da się to osiągnąć. Po pierwsze, przyjmijmy kolejny prop:

function CountDown({time, children, countdownContainer, ...props}) {

Po drugie – przypiszmy go do stałej:

function CountDown({time, children, countdownContainer, ...props}) {
  const CountdownContainer = countdownContainer || 'div';

Ze względu na konwencję nazewniczą Reacta tego rodzaju zmienna (czyli taka, która zostanie użyta w ostrych nawiasach <>) musi zaczynać się z wielkiej litery. Teraz możemy użyć naszego kontenera:

 return (
    <CountdownContainer {...props}>{timeLeft ? timeLeft : children}</CountdownContainer>
  )

I oczywiście użyjmy takiego prop w naszym App:

<CountDown time={10} className='someClass' id='someid' countdownContainer={'section'}> 
      <p>Countdown finished!!!</p>
</CountDown>

Ten kod powinien się zamienić na:

<section class="someClass" id="someid"><p>Countdown finished!!!</p></section>

Oczywiście tag <p> i napis o zakończeniu odliczania pojawia się gdy już odliczanie się skończy – wcześniej nasz element <section> wyświetla odliczanie.

Hook useState – dlaczego const?

Rzućmy okiem na ten fragment kodu:

const [timeLeft, setTimeLeft] = useState(time);
  useEffect(()=> {
    if(timeLeft===0){
      setTimeLeft(null)
   }
   if (!timeLeft) return;
   const intervalId = setInterval(() => {
    setTimeLeft(timeLeft - 1);
  }, 1000);
  return () => clearInterval(intervalId);
  }, [timeLeft]);

Dlaczego const, czyli stała? W przypadku tablic stałe const muszą wskazywać na tę samą tablicę, ale ona sama w sobie jest mutowalna. Ale tutaj nie mamy do czynienia z tablicą tylko wartością prostą. Teoretycznie JavaScript nie powinien na coś takiego pozwalać, aby const timeLeft mógł ulegać zmianie.

W praktyce, nie wchodząc przesadnie na tym jeszcze poziomie zaawansowania w szczegóły ani nie udając, że sam wszystkie rozumy pozjadałem, powiem tak: zmiany stanu wywołują re-render lub co najmniej ponownie wywołanie naszej funkcji CountDown, której różne wrappery, jakimi React to opakowuje, przekazują nowy stan i tak koniec końców to de facto to jest 'const’ – tylko za każdym razem inne.

Jeżeli mamy ochotę trochę się zainteresować już teraz jak to wszystko „pod spodem” działa to doimportujmy sobie Children:

import { useState, useEffect, Children } from 'react';

Teraz przed naszym returnem wrzućmy jeszcze ten fragment:

const result = [];
  Children.forEach(children, (child, index) => {
    result.push(child);
    result.push(<hr key={index} />);
  });
console.log(children);

Sami zobaczymy, jak wiele razy nam to się wykona (choć dziecko przekazaliśmy jedno) oraz jak taki element JSXowy wygląda pod spodem.