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.