Poznajemy dalej bardzo ważny hook biblioteki React, jakim jest useEffect. Do dzieła.
Ok, rzecz pierwsza:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(count + 1); // You want to increment the counter every second...
}, 1000)
return () => clearInterval(intervalId);
}, [count]); // 🚩 ... but specifying `count` as a dependency always resets the interval.
// ...
}
Mamy tutaj count jako dependency, bo go w setCount używamy, a jest on zmienną reaktywną. Z tego powodu zmiana state powoduje nie tylko re-render komponentu, ale i odpalenie sie useEffect, przed re-renderem (cleanup) i po re-renderze (setup).
Z uwagi na to jak nasz useEffect wygląda nie chcemy tego. Proste – nie używać count w setCount. Co przez to rozumiem?
Cóż, można tam użyć 42 czy innej zahardcodowanej wartości, ale tego też nie chcemy. To jak przekazać inkrementację nie używając count?
Poprzez updater function:
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(c => c + 1); // ✅ Pass a state updater
}, 1000);
return () => clearInterval(intervalId);
}, []); // ✅ Now count is not a dependency
return <h1>{count}</h1>;
}
Ok, teraz co nieco o stałych, zmiennych, funkcjach i obiektach:
- Jak coś importujemy to to jest utworzone raz
- Jak coś zapiszemy do zmiennej/stałej powyżej komponentu, to to jest zapisane raz
- Jak zdefiniujemy funkcję powyżej komponentu, to to jest zdefiniowane raz, tak samo obiekt, tablica, bla bla
- Jak coś zapiszemy do zmiennej/stałej w komponencie, to ta operacja jest wykonywana od zera przy każdym re-renderze
- Każda zdefiniowana funkcja czy utworzony obiekt albo tablica również jest wykonywana od zera przy każdym re-renderze
- Stan to takie coś, co jest tworzone raz i potrafi przetrwać od rendera do rendera, ale zmiana stanu wywołuje re-render
- Props to jest coś, co pozwala otrzymać informacje od innego komponentu, ale one zawsze mają unidirectional data flow (idą z góry na dół), w związku z tym mutowanie ich nie jest logiczne
- Stan ma narzędzie do mutowania go (useState zwraca state i setState), ale w przypadku stanu innego niż typy proste musimy zapomnieć o tradycyjnej mutowalności i metodach in-place (push, pop i tak dalej) – zawsze tworzymy nowy obiekt w oparciu o poprzedni
- setState może przyjąć updater function i pod jej argument podstawić sobie aktualny stan, żeby wiedzieć co i jak zrobić bez wykorzystywania zmiennej state
- samo setState wewnątrz event handlerów nie uruchamia re-rendera od razu, event handler musi wykonać się do końca i stan w state może się jeszcze nie zgadzać
- swoją drogą jak chcemy wywołać setState np. 3 razy pod rząd to musimy podać updater function. Taka ciekawostka, ale w momencie, w którym zrobimy setCount(count + 1) trzy razy, de facto count wynosi tyle samo i robimy tę samą operację 3 razy (więcej o tym w lekcji o setState).
Ok, to teraz przykład na to, co mówię:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = { // 🚩 This object is created from scratch on every re-render
serverUrl: serverUrl,
roomId: roomId
};
useEffect(() => {
const connection = createConnection(options); // It's used inside the Effect
connection.connect();
return () => connection.disconnect();
}, [options]); // 🚩 As a result, these dependencies are always different on a re-render
// ...
Nie dość, że options musimy wpisać do zależności, to jeszcze co re-render ten obiekt jest tworzony od zera. Chciałbym to podkreślić – co każdy re-render, nie tylko ten wywołany przez useEffect.
Problemu by nie było, gdyby options było powyżej funkcji ChatRoom, ale wtedy ani nie przekażemy tam z propsów roomId, ani też nie mamy możliwości, aby coś się kiedykolwiek zmieniło tam.
No ale możemy niżej przekazać, do useEffect:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return (
<>
<h1>Welcome to the {roomId} room!</h1>
<input value={message} onChange={e => setMessage(e.target.value)} />
</>
);
}
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom roomId={roomId} />
</>
);
}
I też bym chciał to podkreślić – to nie jest tak, że teraz options są tworzone jeden, jedyny raz albo jakoś keszowane. Są tworzone od zera – za każdym razem, gdy useEffect działa.
A useEffect działa za każdym razem, gdy dojdzie do zmiany roomId. A roomId to stan komponentu App, który tam może się zmieniać i poprzez unidirectional data flow być przekazywanym do dziecka przez propsy.
Tak – props może zmieniać się u rodzica. Bo u rodzica jest stanem, i jako atrybut przekazywany do dziecka w postaci propsa. To dziecko nie może (raczej nie ma w tym logicznego sensu) modyfikować props, bo jest jednostronny przepływ danych i ten props nie poleci do rodzica jako nowy stan.
Hook useEffect może być przydatny do działań asynchronicznych i to też jest w dokumentacji jako przykład podane, ważne aby nie korzystać z tych wbudowanych fetchów (jeśli możemy) tylko jakichś bardziej zoptymalizowanych biblioteczek:
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
}
}, [person]);
return (
<>
<select value={person} onChange={e => {
setPerson(e.target.value);
}}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
<option value="Taylor">Taylor</option>
</select>
<hr />
<p><i>{bio ?? 'Loading...'}</i></p>
</>
);
}
Ok, rzecz ostatnia, czyli praca z różnymi zewnętrznymi widgetami. Apka:
import { useState } from 'react';
import Map from './Map.js';
export default function App() {
const [zoomLevel, setZoomLevel] = useState(0);
return (
<>
Zoom level: {zoomLevel}x
<button onClick={() => setZoomLevel(zoomLevel + 1)}>+</button>
<button onClick={() => setZoomLevel(zoomLevel - 1)}>-</button>
<hr />
<Map zoomLevel={zoomLevel} />
</>
);
}
Co my tu mamy? Stan u rodzica, z interfejsem do jego zmiany u rodzica, przekazuje wartość stanu dziecku jako props. Standard.
A jak wygląda komponent Map (korzystający z jakiegoś zewnętrznego kodu do utworzenia mapy)?
Tak:
import { useRef, useEffect } from 'react';
import { MapWidget } from './map-widget.js';
export default function Map({ zoomLevel }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (mapRef.current === null) {
mapRef.current = new MapWidget(containerRef.current);
}
const map = mapRef.current;
map.setZoom(zoomLevel);
}, [zoomLevel]);
return (
<div
style={{ width: 200, height: 200 }}
ref={containerRef}
/>
);
}
Sam MapWidget bierze DOMNode jako konstruktor, my tutaj tworzymy ref do jego kontenera (diva) i jego też sobie w konstruktor wrzucamy, ale do samego MapWidget też sprytnie sobie referencję robimy.
Więcej Reacta już niedługo.