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.