Les 2: State
Les 2: State
Vorige les hebben we eenvoudige functie componenten gebouwd, deze componenten hadden enkele belangrijke beperkingen. Het was niet mogelijk om state (data) te bewaren in deze componenten en te reageren op gebruikersacties (events). Deze les bekijken we hoe we dit kunnen toevoegen aan een component.
We maken gebruik van de developer tools om de state te inspecteren, we raden je aan de tutorial te doorlopen om vertrouwd te raken met de React developer tools.
Local state
Het concept state vormt een integraal deel van elke React applicatie. Een gebruikersnaam, wachtwoord, e-mailadres, adres, creditcardnummer, ... zijn enkele voorbeelden die frequent voorkomen. Formulieren zijn niet weg te denken uit een moderne webapp. Daarnaast bevat elke webapp ook knoppen, uitklapbare menu's, ... Componenten die reageren op acties van een gebruiker.
Begrip: Local state
De state van een React component is een verzameling van variabelen die de huidige situatie van de component bevatten.
Zo worden formulier elementen, de uit- of dichtgeklapte menu's, de themakeuze, data opgehaald van een API of database, ... bewaard in de state van een component.
Controlled Components
We beginnen met een eenvoudig formulier waar momenteel nog geen nieuwigheden inzitten, de tekst in het input veld kan aangepast worden, maar er gebeurt verder niets mee, we kunnen deze waarde nog op geen enkele manier uitlezen.
import styled from 'styled-components';
const FormContainer = styled.div`
background: #2B2B2B;
border-radius: 10px;
font-family: Oblique, Verdana, serif, sans-serif;
color: #F2F2F2;
padding: 1em;
margin: 1em 0;
`
const Example1 = () => {
return (
<FormContainer>
<p>Tekst aanpassen is heel eenvoudig!</p>
<p>De huidige waarde is nu: </p>
<p>In onderstaand input veld kan je deze waarde aanpassen:</p>
<div>
<input type="text"/>
</div>
</FormContainer>
)
}
export default Example1;
import ReactDOM from 'react-dom/client'
import {StrictMode} from 'react'
import Example1 from './example1.jsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<Example1/>
</StrictMode>
);
Zoals eerder gezegd worden formulierelementen in de state bewaard, om deze state te definiëren moeten we gebruik maken van een hook.
Begrip: Hook
Een hook is een herbruikbare functie die bovenaan in een component opgeroepen wordt en een bepaalde actie uitvoert. Dit kan gaan van het bewaren van UI state tot het synchroniseren met een externe databron en nog veel meer. We zullen hooks doorheen deze cursus onder anderen gebruiken om state te bewaren, rechtstreeks te communiceren met de dom, data op te halen van een API, te navigeren tussen meerdere pagina's in onze applicatie, id's te generen en nog heel wat meer.
Een hook moeten verplicht bovenaan een functie component geplaatst worden en mag nooit voorkomen in:
- Een conditioneel statement
- Een lus
- Een geneste functie
Om state toe te voegen maken we gebruik van de useState hook. Deze functie krijgt een initiële waarde als argument en geeft een array met 2 elementen terug. Het eerste element is de huidige waarde van de state, het tweede element is een setter functie die gebruikt kan worden om de state aan te passen.
In onderstaand voorbeeld wordt slechts één useState hook gebruikt, in een realistische applicatie kunnen dit er natuurlijk een pak meer zijn.
import {useState} from 'react';
const Example1 = () => {
const [text, setText] = useState("Initiële waarde");
return (
<FormContainer>
<p>Tekst aanpassen is heel eenvoudig!</p>
<p>De huidige waarde is nu:</p>
<p>In onderstaand input veld kan je deze waarde aanpassen:</p>
<div>
<input type="text"/>
</div>
</FormContainer>
)
}
Deconstructing syntax
In bovenstaand voorbeeld maken we gebruik van deconstructing syntax om de elementen die teruggegeven worden door de useState hook een naam te geven. Volgende twee fragmenten zijn identiek.
Het is duidelijk dat de tweede optie veel meer code vereist en minder duidelijk is, maak dus zoveel mogelijk gebruik van deconstructing syntax. Niet enkel voor de useState hook, maar ook in het algemeen.
const Example1 = () => {
const [text, setText] = useState("Initiële waarde");
return (
<FormContainer>
...
</FormContainer>
)
}
const Example1 = () => {
const array = useState("Initiële waarde");
const text = array[0];
const setText = array[1];
return (
<FormContainer>
...
</FormContainer>
)
}
We gebruiken het eerste element van de state-array vervolgens twee keer. We printen de state uit in het tweede <p> tag en koppelen het aan het formulier-element.
const Example1 = () => {
const [text, setText] = useState("Initiële waarde");
return (
<FormContainer>
<p>Tekst aanpassen is heel eenvoudig!</p>
<p>De huidige waarde is nu: {text}</p>
<p>In onderstaand input veld kan je deze waarde aanpassen:</p>
<div>
<input type="text" value={text}/>
</div>
</FormContainer>
)
}
State bijwerken
Het is duidelijk dat deze applicatie nog niet heel zinvol is. We hebben een variabele aangemaakt waarin we de huidige waarde van het formulier bijhouden, maar we doen hier nog niets mee. Zoals onderstaande video demonstreert, is het onmogelijk om de state aan te passen, we hebben een read-only veld gebouwd. De enige manier om de state aan te passen is via de React dev-tools.
React detecteert deze fout ook, als we de development server starten en in de console kijken, zien we volgende foutmelding:
Warning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.
Vervangen
Zoals bovenstaande foutmelding zegt, moeten we het onChange attribuut gebruiken om de state aan te passen als de gebruiker het formulier probeert aan te passen.
Merk op dat we de setter gebruiken, de state mag nooit rechtstreeks aangepast worden. Als je dit probeert, zal je merken dat de wijzigingen nog steeds niet zichtbaar worden. Via de setter laat je aan React weten dat de state gewijzigd is en dat de component (en alle kinderen ervan) opnieuw gerenderd moet worden.
const Example1 = () => {
const [text, setText] = useState("Initiële waarde");
return (
<FormContainer>
<p>Tekst aanpassen is heel eenvoudig!</p>
<p>De huidige waarde is nu: {text}</p>
<p>In onderstaand input veld kan je deze waarde aanpassen:</p>
<div>
<input type="text" value={text}
onChange={(evt) => setText(evt.target.value)}/>
</div>
</FormContainer>
)
}
evt.target.value
- evt bevat het change event
- evt.target is het input element dat het event getriggerd heeft.
- evt.target.attributeName kan gebruikt worden om elk attribuut van het input element op te vragen:
- evt.target.value --> De nieuwe waarde in het tekst veld
- evt.target.type --> text
De component Example1 is nu een controlled component.
Begrip: Controlled component
Een controlled component is een component waarin de formuliergegevens door React beheerd worden. Dit betekent dat er voor elk formulierelement een corresponderende useState hook is.
Controlled components staan tegenover uncontrolled components, i.e. componenten die de formuliergegevens laten beheren door de DOM, door de browser. In dit soort componenten worden de formuliergegevens niet bewaard in de state van een component, maar in de DOM. Dit betekent dat je in zo'n situatie bij het inzenden van een formulier alle formuliergegevens moet uitlezen via het React equivalent van een document.getElementById.
React heeft dan geen controle meer, dit maakt het bijvoorbeeld moeilijker om tijdens het ingeven van data aan validatie te doen. Verder is het idee achter React dat alles een functie is van de state, alles zou uit de state berekend moeten worden, dit betekent dat een uncontrolled component dus tegen de principes van React ingaat.
Op basis van de vorige state
Stel, we willen bijhouden hoeveel keer het formulier gewijzigd is, hiervoor hebben we een nieuwe useState hook nodig. We passen ook de onChange listener op het formulier aan naar een aparte methode omdat de logica te veel wordt om op een duidelijke manier in de JSX-code te schrijven.
Om het probleem te illustreren verhogen we hier de state 2 keer met 0.5. Op het eerste zicht kan dit artificieel lijken, maar dit illustreert het probleem duidelijk.
const Example1 = () => {
const [text, setText] = useState("Initiële waarde");
const [changeCount, setChangeCount] = useState(0);
const changeHandler = (evt) => {
setText(evt.target.value);
setChangeCount(changeCount + 0.5);
setChangeCount(changeCount + 0.5);
}
return (
<FormContainer>
<p>Tekst aanpassen is heel eenvoudig!</p>
<p>De huidige waarde is nu: {text}</p>
<p>In onderstaand input veld kan je deze waarde aanpassen:</p>
<p>De formulierwaarde is {changeCount} keer gewijzigd!</p>
<div>
<input type="text" value={text} onChange={changeHandler}/>
</div>
</FormContainer>
)
}
Zoals in onderstaande video gedemonstreerd wordt, wordt de teller na elke wijziging met .5 verhoogt.
De reden voor dit onverwacht gedrag is dat React alle state updates die in eenzelfde synchrone functie staan, op de queue zet, en pas als de functie afgewerkt is, de state update. Op het moment dat de updates op de queue gezet worden is de waarde van changeCount gelijk voor beide updates, stel dat changeCount = 0, dan worden volgende updates gedaan:
setChangeCount(0 + 0.5);
setChangeCount(0 + 0.5);
Dit is natuurlijk geen slechte zaak, als React dit niet deed, zou de component 3 keer gerenderd worden voor één change event.
React voert op de achtergrond soms gelijkaardige optimalisaties uit waarin verschillende state-updates op hetzelfde moment uitgevoerd worden, ook als niet onmiddellijk duidelijk is dat dit kan gebeuren. Gebruik daarom steeds de functionele setter als je de state aanpast op basis van de huidige waarde.
const Example1 = () => {
const [text, setText] = useState("Initiële waarde");
const [changeCount, setChangeCount] = useState(0);
const changeHandler = (evt) => {
setText(evt.target.value);
setChangeCount(oldChangeCount => oldChangeCount + 0.5);
setChangeCount(oldChangeCount => oldChangeCount + 0.5);
}
return (
<FormContainer>
<p>Tekst aanpassen is heel eenvoudig!</p>
<p>De huidige waarde is nu: {text}</p>
<p>In onderstaand input veld kan je deze waarde aanpassen:</p>
<p>De formulierwaarde is {changeCount} keer gewijzigd!</p>
<div>
<input type="text" value={text} onChange={changeHandler}/>
</div>
</FormContainer>
)
}
Arrays in JSX
Vorige les hebben we al lussen gebruikt om arrays om te vormen naar een reeks componenten. We hebben dit echter niet op de meest efficient of propere manier gedaan, we hebben ook geen rekening gehouden met de performantie van de applicatie.
Volgende lijst met vakken wordt gedefinieerd in /src/data/subjects.ts
Lijst met vakken uit fase 2 van het graduaat programmeren (dagopleiding)
const subjects = [
{
name: 'Javascript framework React',
sp: 5,
semester: 1,
id: '6203412b-14f1-466a-a561-a33e4f4311d6',
goals: [
{
goal: "De student ontwikkelt een Single Page Application in React",
id: "db7173cd-c23a-4cd3-b731-bce96b069d5b"
},
{
goal: "De student kan state management toepassen om data uit te wisselen tussen verschillende pagina's in een Single Page Application",
id: "de38eeaf-889c-48e2-b6d9-4d6dba7889fa"
},
{
goal: "De student kan een SPA opbouwen aan de hand van componenten",
id: "7cb62148-d404-421e-96eb-db60ca90db7b"
},
{
goal: "De student kan APIs aanspreken vanuit React en deze gebruiken in een Single Page Application",
id: "fa3a0f7f-ab35-4741-9a9b-9e9fabd51177"
},
],
},
{
name: 'Agile en testing',
sp: 3,
semester: 1,
id: 'a2af7e33-f198-44fb-8cc0-e34d27301834',
goals: [
{
goal: "De student identificeert wat Agile en Lean is",
id: "cb0d8804-eb8c-472a-92b9-eccd561e2560"
},
{
goal: "Kent de de Agile methode en kan dit toepassen in een projectwerk",
id: "eaa981e4-277f-42fe-a69f-1cef2206d016"
},
{
goal: "Kent de meeste voorkomende testvormen binnen softwareontwikkeling en weet wanneer en hoe deze toegepast worden",
id: "179be1ee-384e-46e1-a69b-ae8c49e6d263"
},
{
goal: "Kan testscenario’s schrijven en toepassen op een projectwerk",
id: "9d86cf79-8805-4dee-9d5f-802736f77392"
},
]
},
{
name: 'Mobiele applicaties',
sp: 6,
semester: 1,
id: '476e4ee2-feea-482d-a840-2a1bf2380a0a',
goals: [
{
goal: "De student kan een mobiele hybrid-webview applicatie schrijven",
id: "adc72c96-aa32-4e90-9fb8-c0cd1d55ac28"
},
{
goal: "De student kan een hybrid-webview applicatie publiceren als Android applicatie",
id: "ff9a35f4-6884-4fb8-9f06-2adbda3c2319"
},
{
goal: "De student kan een mobiele applicatie aanbieden als progressive web app (PWA)",
id: "9dbca9e8-2784-490a-9226-1d7aafabd557"
},
{
goal: "De student kan gebruik maken van een back-end-as-a-service",
id: "5ba6f1d4-1905-472d-9eb5-3b81fb245602"
},
{
goal: "De student kan communiceren met een API",
id: "38ac59ed-1c47-4fc8-935e-66013e97763b"
},
{
goal: "De student kan gebruikmaken van functies voorzien door het mobiele besturingssysteem (iOS of Android)",
id: "d8cb8e4e-37c9-4d2a-9100-84493bdd7395"
},
]
},
{
name: 'IT Topics',
sp: 3,
semester: 1,
id: 'acd9dd00-e8a1-4346-ab31-9a53b5b4a3c3',
goals: [
{
goal: "De student volgt nieuwe ontwikkelingen in IT",
id: "b232fa84-bb9b-454c-a09d-8c01cbedc2da"
},
{
goal: "Kan een project planning in een projecttool interpreteren.",
id: "9def09ce-e363-47f0-95ee-cc6c746094c9"
},
]
},
]
export default subjects;
Deze vakken worden geïmporteerd en via properties doorgegeven aan Example2.
root.render(
<StrictMode>
<Example1/>
<Example2 subjects={subjects}/>
</StrictMode>
);
We kunnen net zoals vorige les, een klassieke lus gebruiken om alle vakken uit te printen. De component AccordionItem is voorzien in de startbestanden.
const Example2 = ({subjects}) => {
const output = [];
for (const s of subjects) {
output.push(<AccordionItem {...s}/>);
}
return (
<div className={'accordion'}>
{output}
</div>
)
}
Key
Bovenstaande code werkt, maar als je de developer console opent, zie je dat React een waarschuwing geeft.
Warning: Each child in a list should have a unique "key" prop.
Er wordt gevraagd naar een unieke key. Dit is nodig omwille van hoe React bepaald welke elementen opnieuw gerenderd moeten worden. Zonder een unieke sleutel heeft React de mogelijkheid niet om te beslissen welk vak opnieuw gerenderd moet worden, i.e. welk vak gewijzigd is tenopzichte van de laatste render, bijgevolg zal alles opnieuw gerenderd moeten worden.
De meest voor de hand liggende oplossing is de index in de array subjects te gebruiken, dit is echter geen goed idee. De volgorde van elementen in een lijst kan veranderen, bijvoorbeeld omdat de lijst anders gesorteerd wordt of er een element verwijderd wordt. De index gebruiken kan rare bugs als gevolg hebben en moet zoveel mogelijk vermeden worden. Enkel als je absoluut zeker bent dat de lijst voor de volledige levensduur van je app gelijk blijft, kan je de index gebruiken.
Een betere optie zijn de ids van de elementen die in de lijst zitten. Als de data uit een database komt, is dit geen enkel probleem, je gebruikt dan de primary key of het object-id. Om dit te simuleren is een id toegevoegd in de subjects array.
Het id wordt meegegeven via de key property die op elke component beschikbaar is.
const Example2 = ({subjects}) => {
const output = [];
for (const s of subjects) {
output.push(<AccordionItem {...s} key={s.id}/>);
}
return (
<div className={'accordion'}>
{output}
</div>
)
}
Map functie
In de voorbeelden hierboven is een klassieke for-each lus gebruikt. We kunnen echter ook gebruik maken van een functionele aanpak via de Array.map functie. In tegenstelling tot bovenstaande lus kan deze functie wel in JSX-code gebruikt worden.
Omdat deze functie rechtstreeks in JSX-code gebruikt kan worden, leidt deze dikwijls tot kortere en overzichtelijkere code. Als de hoeveelheid JSX code die gegenereerd word in de lus groot begint te worden, is het daarentegen dikwijls properder om deze in een klassieke lus te zetten. Zo blijft de code beter leesbaar.
const AccordionItem = ({name, sp, semester, goals}) => {
const accordionContent = (
<ul>
{goals.map(g => <li key={g.id}>{g.goal}</li>)}
</ul>
)
return (
<div className={'accordion-item'}>
<div className={'title'}>{name}</div>
<div className={'chevron'}>
{false ? <span>∧</span> : <span>∨</span>}
</div>
<div className="subtitle">
{sp} studiepunten - semester {semester}
</div>
<div className={'content'}>
{accordionContent}
</div>
</div>
)
}
Lifting state
We passen de component AccordionItem aan zodat het item open en dichtgeklapt kan worden.
const AccordionItem = ({name, sp, semester, goals}) => {
const [isOpen, setIsOpen] = useState(false);
const accordionContent = (
<ul>
{goals.map(g => <li key={g.id}>{g.goal}</li>)}
</ul>
)
return (
<div className={'accordion-item'}>
<div className={'title'}>{name}</div>
<div className={'chevron'} onClick={() => setIsOpen(isOpen => !isOpen)}>
{isOpen ? <span>∧</span> : <span>∨</span>}
</div>
<div className={'content'}>
{isOpen ? accordionContent : null}
</div>
</div>
)
}
Momenteel is deze component niet heel complex, maar wat als we slechts één item tegelijkertijd willen openen? Als we op een ander item klikken, moet dit opengaan en moeten alle andere items gesloten worden. Hieronder wordt de componentenboom gevisualiseerd.
Als we slechts één van de items tegelijkertijd kunnen openen, moet er gecommuniceerd worden tussen de verschillende componenten. Item 1 moet weten wanneer item N open is en omgekeerd. Deze communicatie is echter niet mogelijk in React. We moeten de status van de verschillende items bijhouden in de bovenliggende component, Example2.
Begrip: Lifting state
Als twee of meer componenten dezelfde data nodig hebben, moet deze data bewaard worden in de dichtstbijzijnde gemeenschappelijke ouder. Dit fenomeen wordt lifting state genoemd.
We kunnen deze redenering dan volgen en in Example2 het id van het geopende item toevoegen. Natuurlijk moet dit id ook aangepast worden als er op de chevron geklikt wordt. Net zoals via properties wordt doorgegeven of een item open is, moet ook de changeHandler van bovenaf doorgegeven worden.
const Example2 = ({subjects}) => {
const [openItem, setOpenItem] = useState(null);
const openCloseHandler = (id) => {
if (openItem === id) {
setOpenItem(null);
} else {
setOpenItem(id);
}
}
const output = [];
for (const s of subjects) {
output.push(<AccordionItem {...s} key={s.id}
isOpen={openItem === s.id}
setIsOpen={() => openCloseHandler(s.id)}/>);
}
return (
<div className={'accordion'}>
{output}
</div>
)
}
const AccordionItem = ({name, sp, semester, goals, isOpen, setIsOpen}) => {
const accordionContent = (
<ul>
{goals.map(g => <li key={g.id}>{g.goal}</li>)}
</ul>
)
return (
<div className={'accordion-item'}>
<div className={'title'}>{name}</div>
<div className={'chevron'} onClick={setIsOpen}>
{isOpen ? <span>∧</span> : <span>∨</span>}
</div>
<div className="subtitle">
{sp} studiepunten - semester {semester}
</div>
<div className={'content'}>
{isOpen ? accordionContent : null}
</div>
</div>
)
}
Samenvatting & voorbeeldcode
Volledig uitgewerkte lesvoorbeelden met commentaar
layout: Slide
<ExampleCode/>