React ja koukut
Tämä sivu käsittelee Reactin koukut (hooks). Koukku on funktio, jonka nimi alkaa sanalla use ja joka antaa funktiokomponentille jotain, mitä pelkkä funktio ei osaisi: muistin renderöintien välillä, yhteyden ulkomaailmaan tai pääsyn elementteihin.
Koukut ovat Reactin nykyinen tapa kirjoittaa komponentteja. Luokkakomponentteja ja niiden setState-metodia näkee vanhoissa projekteissa, mutta uutta koodia niillä ei kirjoiteta. Reactin perusasiat — JSX, komponentit ja props — käydään läpi sivulla React.
Koukkujen kaksi sääntöä
Koukut näyttävät tavallisilta funktiokutsuilta, mutta niitä sitoo kaksi sääntöä. React tunnistaa koukut kutsujärjestyksen perusteella, ei nimen, joten järjestyksen on oltava joka renderöinnillä sama.
- Kutsu koukkuja vain komponentin ylimmällä tasolla. Ei ehtolauseiden, silmukoiden eikä sisäkkäisten funktioiden sisällä.
- Kutsu koukkuja vain React-funktiokomponentista tai toisesta koukusta.
const Komponentti = function (props) {
// EI näin: koukkua kutsutaan vain joskus, jolloin järjestys vaihtelee
if (props.näytäLaskuri) {
const [laskuri, setLaskuri] = useState(0);
}
// Näin: koukku aina, ehto vasta käytössä
const [laskuri, setLaskuri] = useState(0);
if (!props.näytäLaskuri) {
return null;
}
return (<p>{laskuri}</p>);
};
Säännöt on helppo rikkoa vahingossa, joten niiden noudattamista kannattaa valvoa eslint-plugin-react-hooks-lisäosalla. Kts. Rules of Hooks.
useState
useState antaa komponentille muistin. Se palauttaa taulukon, jossa on kaksi alkiota: nykyinen arvo ja funktio jolla arvo vaihdetaan.
import { useState } from "react";
const Laskuri = function () {
const [laskuri, setLaskuri] = useState(0);
return (
<p>
Arvo on {laskuri}
<button type="button" onClick={() => setLaskuri(laskuri + 1)}>
Kasvata
</button>
</p>
);
};
Parametri on alkuarvo, ja sitä käytetään vain ensimmäisellä renderöinnillä. Asetusfunktion kutsuminen ei muuta muuttujaa vaan pyytää Reactia renderöimään komponentin uudelleen uudella arvolla.
Tila on tilannekuva
Tämä on koukkujen tavallisin väärinkäsitys. laskuri on kyseisen renderöinnin ajan vakio. Sen arvo ei muutu kesken funktion, vaikka asetusfunktiota kutsuttaisiin:
setLaskuri(laskuri + 1);
console.log(laskuri); // vanha arvo — ei kasvatettu
Samasta syystä peräkkäiset kasvatukset eivät kerry, koska molemmat laskevat samasta vanhasta arvosta:
setLaskuri(laskuri + 1);
setLaskuri(laskuri + 1); // laskuri kasvaa yhdellä, ei kahdella
Kun uusi arvo riippuu edellisestä, anna asetusfunktiolle funktio. React antaa sille aina tuoreimman arvon:
setLaskuri((edellinen) => edellinen + 1);
setLaskuri((edellinen) => edellinen + 1); // nyt kasvaa kahdella
Kts. State as a snapshot.
Objektit ja taulukot
Tilaa ei muuteta paikallaan. React vertaa vanhaa ja uutta arvoa, ja jos kyseessä on sama objekti, mitään ei tapahdu — vaikka sen sisältö olisi muuttunut.
// EI näin: sama objekti, React ei huomaa muutosta
tila.osoite = "Uusikatu 2";
setTila(tila);
// Näin: uusi objekti, jossa yksi avain on korvattu
setTila({ ...tila, osoite: "Uusikatu 2" });
Taulukot käsitellään samalla periaatteella. Käytä metodeja, jotka palauttavat uuden taulukon — push ja splice muuttavat vanhaa:
| Toimenpide | Näin |
|---|---|
| lisää loppuun | setLista([...lista, uusi]) |
| poista | setLista(lista.filter((x) => x.id !== id)) |
| muuta yhtä | setLista(lista.map((x) => x.id === id ? { ...x, valmis: true } : x)) |
| järjestä | setLista(lista.toSorted(vertailu)) |
Esimerkki muuttuvasta ja muuttumattomasta tilasta (mutable.js) näyttää konkreettisesti, mitä tapahtuu kun tilaa muutetaan paikallaan.
Syvälle sisäkkäinen tila on merkki siitä, että tilarakennetta kannattaa yksinkertaistaa. Kts. Updating objects in state ja Updating arrays in state.
Missä tila asuu
Tila kuuluu alimmalle komponentille, joka sisältää kaikki sitä tarvitsevat. Jos kaksi sisarkomponenttia tarvitsee saman tiedon, tila nostetaan niiden yhteiselle vanhemmalle ja välitetään alaspäin propseina.
Älä kopioi propseja tilaan. Se on tavallinen virhe, ja seurauksena on kaksi totuutta samasta asiasta:
// EI näin: nimi ei enää päivity, jos props muuttuu
const [nimi, setNimi] = useState(props.nimi);
// Näin: käytä propsia suoraan
return (<p>{props.nimi}</p>);
useEffect
useEffect yhdistää komponentin johonkin Reactin ulkopuoliseen: verkkopyyntöön, ajastimeen, tapahtumankäsittelijään tai selaimen rajapintaan. Efekti suoritetaan renderöinnin jälkeen, kun muutokset ovat jo näytöllä.
import { useEffect, useState } from "react";
const Kello = function () {
const [aika, setAika] = useState(new Date());
useEffect(function () {
const tunniste = setInterval(function () {
setAika(new Date());
}, 1000);
// Siivousfunktio: React kutsuu tätä ennen seuraavaa suoritusta ja
// silloin kun komponentti poistetaan. Ilman sitä ajastin jäisi päälle.
return function () {
clearInterval(tunniste);
};
}, []);
return (<p>{aika.toLocaleTimeString("fi")}</p>);
};
Riippuvuustaulukko
Toinen parametri ratkaisee, milloin efekti suoritetaan.
| Toinen parametri | Milloin efekti ajetaan |
|---|---|
| puuttuu kokonaan | jokaisen renderöinnin jälkeen |
| [] | vain kerran, kun komponentti ilmestyy |
| [a, b] | kun a tai b on muuttunut |
Taulukossa on oltava kaikki efektin käyttämät arvot, jotka voivat muuttua — propsit, tilamuuttujat ja niistä lasketut arvot. Jos jokin puuttuu, efekti käyttää vanhentunutta arvoa, ja vika näkyy vasta satunnaisesti. Tämän tarkistaa eslint-plugin-react-hooks.
Siivousfunktio
Jos efekti aloittaa jotain, joka jatkuu — ajastin, tapahtumankäsittelijä, yhteys — se on myös lopetettava. Efektistä palautettu funktio hoitaa sen:
useEffect(function () {
const kasittelija = function () {
setLeveys(window.innerWidth);
};
window.addEventListener("resize", kasittelija);
return function () {
window.removeEventListener("resize", kasittelija);
};
}, []);
Kehitystilassa React suorittaa efektin tarkoituksella kahdesti (StrictMode) juuri siksi, että puuttuva siivous paljastuisi heti. Jos efekti toimii kahdesti ajettuna, se toimii myös oikeasti.
Milloin efektiä EI tarvita
Efekti on helppo ylikäyttää. Sitä ei tarvita, jos arvo voidaan laskea renderöinnin aikana:
// EI näin: turha tila ja turha efekti
const [kokoNimi, setKokoNimi] = useState("");
useEffect(function () {
setKokoNimi(`${etunimi} ${sukunimi}`);
}, [etunimi, sukunimi]);
// Näin: laske suoraan
const kokoNimi = `${etunimi} ${sukunimi}`;
Efektiä ei tarvita myöskään käyttäjän toiminnon käsittelyyn. Painikkeen painallus hoidetaan tapahtumankäsittelijässä, ei efektissä. Kts. You Might Not Need an Effect.
Datan hakeminen
Tämä on efektin tavallisin käyttö. Mukana on kolme asiaa, jotka jäävät helposti tekemättä: virhetilanteen tarkistus, latausindikaattori ja peruutus. Ilman peruutusta nopeasti vaihtuva hakuehto voi saada vanhemman vastauksen saapumaan uudemman jälkeen ja korvaamaan sen.
const Hakutulokset = function (props) {
const [tulokset, setTulokset] = useState([]);
const [lataa, setLataa] = useState(false);
const [virhe, setVirhe] = useState(null);
useEffect(function () {
// AbortController peruuttaa keskeneräisen pyynnön
const ohjain = new AbortController();
const hae = async function () {
setLataa(true);
setVirhe(null);
try {
const parametrit = new URLSearchParams({ haku: props.haku });
const vastaus = await fetch(`/api/haku?${parametrit}`, { signal: ohjain.signal });
if (!vastaus.ok) {
throw new Error(`HTTP-virhe ${vastaus.status}`);
}
setTulokset(await vastaus.json());
}
catch (e) {
// peruutus ei ole virhe
if (e.name !== "AbortError") {
setVirhe(e.message);
}
}
finally {
setLataa(false);
}
};
hae();
return function () {
ohjain.abort();
};
}, [props.haku]);
if (lataa) {
return (<p>Haetaan…</p>);
}
if (virhe !== null) {
return (<p className="virhe">Haku epäonnistui: {virhe}</p>);
}
return (
<ul>
{tulokset.map((rivi) => <li key={rivi.id}>{rivi.nimi}</li>)}
</ul>
);
};
Huomaa, että efektin funktio itse ei voi olla async: sen on palautettava siivousfunktio tai ei mitään, ja async-funktio palauttaa aina lupauksen. Siksi asynkroninen työ tehdään sisemmässä funktiossa.
Toimivat esimerkit: fetch-esimerkki (fetch.js) ja sama await-syntaksilla (fetch2.js).
useRef
useRef antaa arvon, joka säilyy renderöintien yli mutta ei aiheuta uudelleenrenderöintiä muuttuessaan. Sillä on kaksi käyttöä.
Pääsy elementtiin. Silloin kun DOM-rajapintaa oikeasti tarvitaan — kohdistus, vieritys, mediaelementin hallinta:
import { useRef } from "react";
const Hakulomake = function () {
const kentta = useRef(null);
const tyhjenna = function () {
kentta.current.value = "";
kentta.current.focus();
};
return (
<p>
<input type="text" ref={kentta} />
<button type="button" onClick={tyhjenna}>Tyhjennä</button>
</p>
);
};
Arvon säilyttäminen renderöintien yli. Esimerkiksi ajastimen tunniste, jota ei näytetä käyttäjälle:
const tunniste = useRef(null);
const aloita = function () {
tunniste.current = setInterval(tick, 1000);
};
const lopeta = function () {
clearInterval(tunniste.current);
};
Arvo on aina current-ominaisuudessa. Jos arvon muuttumisen pitäisi näkyä käyttöliittymässä, kyseessä on tila eikä viite.
Esimerkki refsin käytöstä (refs.js) näyttää molemmat käyttötavat — ja sen, ettei viitteen muuttuminen päivitä näyttöä.
Kontrolloitu lomake
Kontrolloidussa lomakkeessa kentän arvo tulee tilasta ja jokainen näppäinpainallus päivittää tilan. Silloin tila ja näkyvä lomake ovat aina samaa mieltä, ja tarkistukset voi tehdä missä kohdassa tahansa.
const Ilmoittautuminen = function () {
const [tiedot, setTiedot] = useState({ nimi: "", sposti: "" });
const [virheet, setVirheet] = useState({});
const muuttui = function (e) {
const { name, value } = e.target;
setTiedot((edellinen) => ({ ...edellinen, [name]: value }));
};
const tarkista = function () {
const uudet = {};
if (tiedot.nimi.trim() === "") {
uudet.nimi = "Nimi on pakollinen";
}
if (!tiedot.sposti.includes("@")) {
uudet.sposti = "Tarkista sähköpostiosoite";
}
return uudet;
};
const laheta = function (e) {
e.preventDefault();
const uudet = tarkista();
setVirheet(uudet);
if (Object.keys(uudet).length === 0) {
console.log("Lähetetään", tiedot);
}
};
return (
<form onSubmit={laheta}>
<p>
<label htmlFor="nimi">Nimi</label>
<input type="text" id="nimi" name="nimi"
value={tiedot.nimi} onChange={muuttui} />
{virheet.nimi && <span className="virhe">{virheet.nimi}</span>}
</p>
<p>
<label htmlFor="sposti">Sähköposti</label>
<input type="text" id="sposti" name="sposti"
value={tiedot.sposti} onChange={muuttui} />
{virheet.sposti && <span className="virhe">{virheet.sposti}</span>}
</p>
<p><button type="submit">Lähetä</button></p>
</form>
);
};
Kolme JSX-yksityiskohtaa näkyy tässä. Luokka on className, koska
class on javascriptin varattu sana. Nimilapun attribuutti on
htmlFor samasta syystä. Ja {ehto && <span>…</span>}
on tavallinen tapa näyttää jotain ehdollisesti — epätotuudellinen arvo ei
renderöi mitään.
Huomaa myös aaltosulkeet setTiedot((edellinen) => ({ ... }))
-kutsussa: nuolifunktion palauttama objekti on kääräistävä sulkeisiin, muuten
aaltosulkeet tulkitaan funktion rungoksi.
Muut koukut lyhyesti
| Koukku | Mihin |
|---|---|
| useContext | arvon jakaminen komponenttipuussa ilman propsien välittämistä tason kerrallaan |
| useReducer | monimutkainen tila, jossa on useita toisiinsa liittyviä muutoksia |
| useMemo | raskaan laskennan tuloksen muistaminen |
| useCallback | funktion identiteetin säilyttäminen renderöintien yli |
| useId | uniikki tunniste esimerkiksi label- ja input-elementtien yhdistämiseen |
useMemo ja useCallback ovat suorituskykyoptimointeja. Älä lisää niitä varmuuden vuoksi: ne tekevät koodista monimutkaisempaa ja maksavat itsekin jotain. Lisää ne vasta kun mittaus osoittaa tarpeen.
Omat koukut
Kun sama koukkujen yhdistelmä toistuu monessa komponentissa, siitä voi tehdä oman koukun. Se on tavallinen funktio, jonka nimi alkaa sanalla use:
const useIkkunanLeveys = function () {
const [leveys, setLeveys] = useState(window.innerWidth);
useEffect(function () {
const kasittelija = function () {
setLeveys(window.innerWidth);
};
window.addEventListener("resize", kasittelija);
return function () {
window.removeEventListener("resize", kasittelija);
};
}, []);
return leveys;
};
// käyttö
const Palkki = function () {
const leveys = useIkkunanLeveys();
return (<p>Ikkuna on {leveys} pikseliä leveä</p>);
};
Oma koukku ei jaa tilaa komponenttien välillä — jokainen kutsu saa oman tilansa. Se jakaa logiikan, ei dataa.
Projektin perustaminen
Yksinkertaisen esimerkin voi ajaa selaimessa ilman käännösvaihetta, mutta oikeaan työhön tarvitaan käännöstyökalu. create-react-app ei ole enää ylläpidossa, ja Reactin oma dokumentaatio ohjaa käyttämään Viteä tai jotain kehystä.
npm create vite@latest oma-sovellus -- --template react
cd oma-sovellus
npm install
npm run dev
Ilman käännösvaihetta kokeiltaessa React ladataan ESM-muodossa: React 19:stä lähtien React-tiimi ei enää julkaise script-elementillä ladattavia UMD-paketteja, ja suositus on ESM-pohjainen CDN kuten esm.sh.
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel" data-type="module">
import { useState } from "https://esm.sh/react@19";
import { createRoot } from "https://esm.sh/react-dom@19/client";
const Laskuri = function () {
const [laskuri, setLaskuri] = useState(0);
return (
<button type="button" onClick={() => setLaskuri((edellinen) => edellinen + 1)}>
Painettu {laskuri} kertaa
</button>
);
};
createRoot(document.getElementById("main")).render(<Laskuri />);
</script>
Babel muuntaa vain skriptit, joiden tyyppi on text/babel tai text/jsx, ja data-type="module" kertoo sille, että skriptissä käytetään import-lauseita.
Lisätietoa
- React — JSX, komponentit ja props
- React: Quick Start
- Built-in React Hooks
- Rules of Hooks
- You Might Not Need an Effect
- State as a snapshot
- Reusing logic with custom hooks
Käyttäjien kommentit