Les 5: Hooks

Tot React 16.8 waren class components de enige oplossing om state toe te voegen aan een component. Ook side effects als het aanpassen van elementen in de DOM (via rechtstreekse DOM manipulatie) en het downloaden van data via AJAX/Fetch/Websocket waren enkel mogelijk in combinatie met een class component.

Daarnaast is het ook niet eenvoudig om logica te herbruiken. Wil je stateful logica herbruiken zoals het ophalen van data uit een API, dan moest je een Higher order Component (HOC)open in new window gebruiken. In andere gevallen moest je render propsopen in new window gebruiken om een volledig herbruikbare component te schrijven (zie onderaan les 3 voor een concreet voorbeeld). Al deze patronen zijn complex en moeilijk te begrijpen. Hooks lossen deze problemen op en bieden een eenvoudiger alternatief.

Daarbovenop is het voor een compiler niet mogelijk om een class component volledig te optimaliseren, functiecomponenten zijn eenvoudiger en beter te optimaliseren. Daarom zijn hooks gebouwd zodat deze gebruikt kunnen worden in combinatie met een functiecomponent. Met hooks is het mogelijk een React applicatie te schrijven zonder class components.

Hooks zijn volledig opt-int, dit betekent dat het niet noodzakelijk is om in een oude applicatie alle class components om te vormen naar een functiecomponent. Hooks kunnen gebruikt worden voor nieuwe componenten, maar de oude class components kunnen nog steeds blijven bestaan. Hooks zijn echter wel de aanbevolen manier om nieuwe applicaties te schrijven. Momenteel wordt de volledige React documentatie herschreven, zodat deze gebruik maakt van hooks. De beta documentatie is (op het moment van schrijven) te raadplegen op https://beta.reactjs.org/open in new window.

Waarschuwing

Hooks zijn een vervanging voor class components, dus is het niet mogelijk een hook te gebruiken in een class component.

Collaborative To-Do app

Tijdens deze les bouwen we een collaborative To-Do app, een gebruiker kan een To-Do lijst aanmaken en deze samen met andere gebruikers bewerken. De gebruiker heeft ook de keuze een lijst privé te maken, zodat enkel de gebruiker de rechten heeft om deze te bewerken. Voor deze les maken we gebruik van Supabaseopen in new window een backend as a service (BaaS), waarmee user authentication, databases en blob storage geïmplementeerd kunnen worden. We bespreken de communicatie met deze BaaS niet, er worden functies aangeboden in de startbestanden, die deze communicatie implementeren. De geïnteresseerde lezer kan een gratis account aanmaken op Supabase en de documentatieopen in new window raadplegen om te weten te komen hoe Supabase geïntegreerd kan worden in een React project.

Voor deze applicatie kunnen gebruikers inloggen via een magic linkopen in new window, dit betekent dat je kan inloggen via je email adres en zonder wachtwoord. Let wel op dat je de bevestigingslink opent in dezelfde browser als waar je de app aan het testen bent.

Fig 1: Login flow via magic link

Het bouwen van de inlogpagina wordt niet besproken in deze les, aangezien hier, op vlak van hooks, niets speciaals gebruikt wordt dat niet ook in andere delen van de les aan bod komt. De startbestanden bevatten dan ook een volledig uitgewerkte inlogpagina.

Omdat we gebruik maken van een back-end waarvoor ingelogd moet worden, kan het zijn dat je niet voor alle voorbeelden dezelfde informatie te zien krijgt. Andere studenten kunnen To-Do lijsten aanmaken, taken toevoegen/verwijderen, ... Je kan steeds een privé To-Do lijst maken om alles uit te testen op jouw gebruikersaccount. De beveiliging op de back-end zorgt er voor dat niemand jouw privélijsten kan aanpassen of bekijken.

useState

De meest gebruikte en belangrijkste hooks is useState, deze hook stelt een gebruiker in staat om stateful logica toe te voegen aan een functiecomponent.

De ToDoListForm component, die terug te vinden in de startbestanden en getoond wordt na het kiezen van een gebruikersnaam, definieert onderstaand formulier.

Fig 2: New To-Do list form

Via de useState hook kunnen we de listName en isPrivate bewaren en herbruiken tussen verschillende renders.

We kunnen de ToDoListForm component uitbreiden met twee oproepen van de setState hook. Als initiële state geven we een lege string mee voor de listName en false voor isPrivate. Net zoals bij class components, is het hier absoluut noodzakelijk om een initiële state mee te geven. De initiële state wordt genegeerd vanaf de tweede render. React detecteert dan dat de state al een waarde heeft (uit de vorige render), en zal deze waarde gebruiken.

We kunnen de returnwaardes van de setState call gebruiken om de state waardes te koppelen aan de formulierelementen (lijnen 12 & 18), en om de state bij te werken (lijnen 13 & 19).

/src/components/todo/toDoListForm.js
import {useState} from 'react';

const ToDoListForm = () => {
    const [listName, setListName] = useState('');
    const [isPrivate, setIsPrivate] = useState(false);
    
    return <Form>
        <h1>New To-Do List</h1>
        <Form.Group className="mb-3">
            <Form.Label>To-Do list name</Form.Label>
            <Form.Control type="text" required placeholder="List Name"
                          value={listName}
                          onChange={evt => setListName(evt.target.value)}/>
        </Form.Group>

        <Form.Group className="mb-3">
            <Form.Check type="checkbox" label="Is private"
                        checked={isPrivate} 
                        onChange={evt => setIsPrivate(evt.target.checked)}/>
        </Form.Group>

        <div className="d-grid gap-2">
            <Button variant="primary" type="submit">
                Create To-Do list
            </Button>
        </div>
    </Form>
}
 










 
 




 
 









1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Copied!

Stel, we willen de optie om de lijst privé te maken enkel zichtbaar maken voor premium (betalende) gebruikers. We zouden dan kunnen zeggen, als de gebruiker geen premium gebruiker is, moeten we de status van het privé/publiek veld niet bijhouden in de state, en dus hebben we geen tweede useState nodig.

/src/components/todo/toDoListForm.js
const ToDoListForm = (props) => {
    const [listName, setListName] = useState('');
    
    if (props.isPremium) {
        const [isPrivate, setIsPrivate] = useState(false);
    }
    
    // Niet relevante code weggelaten.
}



 
 
 



1
2
3
4
5
6
7
8
9
Copied!

Bovenstaande code kan niet gecompileerd worden en produceert onderstaande foutmelding.

React hook "useState" is called conditionally. React hooks must be called in the exact same order in every component render.

Omwille van de manier waarop React hooks geïmplementeerd heeft, moet elke hook in een component steeds in dezelfde volgorde uitgevoerd worden. Dit betekent dat hooks steeds in het bovenste niveau van de functie component staan en niet mogen voorkomen in geneste controlestructuren.

Concept: useState

De useState hook, is een functie met één argument. Dit argument stelt de initiële waarde van de state variabele voor. De useState hook geeft een paar terug, van de vorm [stateVariable, setter]. De setter is een functie waarmee de state van waarde gewijzigd kan worden.

De setter heeft net dezelfde functionaliteit als de setState methode bij class components, i.e. de component en alle kinderen worden opnieuw gerenderd nadat de state aangepast is.

Een hook moeten verplicht bovenaan een functie component geplaatst worden en mag nooit voorkomen in:

  • Een conditioneel statement
  • Een lus
  • Een geneste functie

Replace vs. merge

Uit class components zijn we gewoon om alle state in één object te bewaren, voor een functiecomponent met hooks doe je dit beter niet. Het is echter wel mogelijk om alle state in één object te bewaren, maar dit brengt een belangrijk probleem met zich mee. De setter methodes die teruggeven worden door de useState hook vervangen de state, terwijl de setState methode die we in een class component gebruikt hebben, de state samenvoegt met de parameter. Dit betekent dat onderstaande code niet werkt.

/src/components/todo/toDoListFormWithSingleState.js
const ToDoListFormWithSingleState = () => {
    const [list, setList] = useState({
        name: '', 
        isPrivate: false
    });

    return <Form>
        <h1>New To-Do List</h1>
        <Form.Group className="mb-3">
            <Form.Label>To-Do list name</Form.Label>
            <Form.Control type="text" required placeholder="List Name"
                          value={list.name}
                          onChange={evt => setList({name: evt.target.value})}/>
        </Form.Group>

        <Form.Group className="mb-3">
            <Form.Check type="checkbox" label="Is private"
                        checked={list.isPrivate} 
                        onChange={evt => setList({isPrivate: evt.target.checked})}/>
        </Form.Group>

        <div className="d-grid gap-2">
            <Button variant="primary" type="submit">
                Create To-Do list
            </Button>
        </div>
    </Form>
}

 
 
 
 






 
 




 
 









1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Copied!
Fig 3: Error omwille van foutief gebruik van de state

Zodra we een formulierveld wijzigen wordt er een foutmelding getoond omdat de state (in bovenstaand voorbeeld) enkel nog de listName bevat, de state is overschreven. Om dit probleem op te lossen, moeten we de oude state mee kopiëren. Dit kan op twee manieren, ofwel geven we voor elk element expliciet aan dat de oude waarde gekopieerd moet worden (lijn 10), of we maken gebruik van de spread operator (lijn 16). Merk op dat de spread operator vooraan moet komen, het object {a: 1, b: 2, c: 3, a: 4} wordt uiteindelijk het object {b: 2, c: 3, a: 4}, de eerste waarde voor a wordt dus genegeerd.

/src/components/todo/toDoListFormWithSingleState.js
const ToDoListFormWithSingleState = () => {
    const [list, setList] = useState({name: '', isPrivate: false});

    return <Form>
        <h1>New To-Do List</h1>
        <Form.Group className="mb-3">
            <Form.Label>To-Do list name</Form.Label>
            <Form.Control type="text" required placeholder="List Name"
                          value={list.name}
                          onChange={evt => setList({isPrivate: list.isPrivate, name: evt.target.value})}/>
        </Form.Group>

        <Form.Group className="mb-3">
            <Form.Check type="checkbox" label="Is private"
                        checked={list.isPrivate} 
                        onChange={evt => setList({...list, isPrivate: evt.target.checked})}/>
        </Form.Group>

        <div className="d-grid gap-2">
            <Button variant="primary" type="submit">
                Create To-Do list
            </Button>
        </div>
    </Form>
}









 





 









1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Copied!

Hint

Gebruik bij voorkeur verschillende useState oproepen, dit zorgt voor duidelijkere code. Eén object gebruiken, waarin alle state bijgehouden wordt, is niet de bedoeling voor hooks. Dit betekent natuurlijk niet dat je nooit objecten in de state mag bewaren, doe dit enkel als dit object effectief één ding is, zoals bijvoorbeeld een To-Do lijst op een detailpagina.

useRef

De useRef hook vervangt de klassieke refs zoals gebruikt in les 4. Dezelfde regels gelden, gebruik refs zo weinig mogelijk en enkel als directe DOM manipulatie absoluut noodzakelijk is.

In dit voorbeeld zullen we een ref gebruiken om de focus van de submit knop te halen, als er via het onClick event van de submit button gewerkt werd, is het beter om event.target.blur() te gebruiken in de plaats van een ref. Het onSubmit event van het formulier is semantisch correcter dan het onClick event van de knop, daarom gebruiken we dit hier.

/src/components/todo/toDoListForm.js
import {useRef, useState} from 'react';

const ToDoListForm = () => {
    const [listName, setListName] = useState('');
    const [isPrivate, setIsPrivate] = useState(false);
    const submitRef = useRef(null);

    const submitForm = async (event) => {
        event.preventDefault();
        submitRef.current.blur();
        const uuid = await createList(listName, isPrivate);
    }

    return <Form onSubmit={submitForm}>
        <h1>New To-Do List</h1>
        <Form.Group className="mb-3">
            <Form.Label>To-Do list name</Form.Label>
            <Form.Control type="text" required placeholder="List Name" value={listName}
                          onChange={evt => setListName(evt.target.value)}/>
        </Form.Group>

        <Form.Group className="mb-3">
            <Form.Check type="checkbox" label="Is private"
                        checked={isPrivate} onChange={evt => setIsPrivate(evt.target.checked)}/>
        </Form.Group>

        <div className="d-grid gap-2">
            <Button variant="primary" type="submit"
                    ref={submitRef}>
                Create To-Do list
            </Button>
        </div>
    </Form>
}
 




 



 


















 





1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Copied!

Concept: useRef

De useRef hook kan gebruikt worden om een referentie naar een HTMLElement in de DOM op te halen. Via deze referentie kan de DOM rechtstreeks gemanipuleerd worden. De useRef hook heeft één argument, de initiële waarde. Deze is zo goed als altijd null of undefined.

Het HTMLElement waarnaar de ref verwijst wordt doorgegeven via het ref attribuut in de JSX-code die teruggegeven wordt door de functiecomponent.

const RefDemo = (props) => {
    const someTagRef = useRef(null);
    
    return <div>
            <some-tag ref={someTagRef}></some-tag>
        <div>
}

 


 


1
2
3
4
5
6
7
Copied!

useHistory

In les 3 hebben we gebruik gemaakt van de history property om terug te navigeren naar een vorige pagina. Het history object was enkel beschikbaar voor directe kinderen van een Route component, of als we render props gebruikt hadden om de property door te geven. React Router voorziet een alternatief, de useHistoryopen in new window hook.

Via de useHistory hook kunnen we het history object eenvoudig gebruiken in elke component. Bijvoorbeeld om de gebruiker automatisch door te sturen naar de detailpagina als een nieuwe To-Do list aangemaakt is.

/src/components/todo/toDoListForm.js
import {useHistory} from 'react-router-dom';

const ToDoListForm = () => {   
    const history = useHistory();
    
    // Niet relevante code weggelaten.

    const submitForm = async (event) => {
        event.preventDefault();
        submitRef.current.blur();
        const uuid = await createList(listName, isPrivate);
        if (uuid) {
            history.push(`/lists/${uuid}`);
        }
    }
}
 


 








 



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Copied!

useEffect

Naast useState is useEffect ook een veel gebruikte hook. Waar we in class components de lifecycle methodes componentDidMount, componentDidUpdate en componentWillUnmount gebruiken, kunnen we dit, in een functiecomponent, allemaal combineren in één hook, useEffect.

Deze hook is dus ideaal om data op te halen en eventuele DOM-manipulaties te doen. Net zoals de componentDidMount methode, wordt de useEffect hook uitgevoerd na een initiële render. Maar daarnaast wordt de methode ook uitgevoerd na elke daaropvolgende render.

We kunnen de useEffect hook als volgt gebruiken om alle publieke To-Do lists op te halen. We bouwen een nieuwe asynchrone functie in de useEffect call omdat we zo het await keyword kunnen gebruiken. React staat geen asynchrone callbacks toe voor de useEffect hook, dus de geneste functie is nodig (en de door React aanbevolen manier).

/src/components/todo/toDoLists.js
import {useEffect, useState} from 'react';

const ToDoLists = () => {
    const [publicLists, setPublicLists] = useState([]);
    const [userLists, setUserLists] = useState([]);

    useEffect(() => {
        const fetchData = async () => {
            const lists = await fetchAllPublicLists();
            console.log("Fetched data in use effect.");
            setPublicLists(lists);
        };
        fetchData();
    })
    
    // Niet relevante code weggelaten.
}
 





 
 
 
 
 
 
 
 



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Copied!

Concept: useEffect

De useEffect hook kan gebruikt worden om side effects zoals het ophalen van data, het communiceren met API's en het manipuleren van de DOM uit te voeren. Deze hook vervangt de drie meest gebruikte lifecycle methodes.

De useEffect hook is een functie met één of twee argumenten. Het eerste argument is steeds een functie die de code voor het side effect bevat. Als de tweede parameter niet aanwezig is, wordt de methode na elke render uitgevoerd.

useEffect(() => {
    // Voer een side effect uit na elke render.  
})
1
2
3
Copied!

Dependencies

Bovenstaande code heeft een heel groot probleem, er wordt een oneindige lus gegenereerd. De useEffect hook is, onder anderen, een combinatie van componentDidMount en componentDidUpdate. Dit betekent dat de useEffect hook uitgevoerd wordt na elke render. Omdat we gebruik maken van de setPublicLists methode in de useEffect call, hebben we dus een oneindige lus gecreëerd, want setPublicLists triggerd een re-render, en dus ook een nieuwe call van de useEffect hook. Onderstaande gif demonstreert dit probleem.

Fig 4: Oneindige lus in de useEffect hook

We kunnen een dependency array toevoegen aan de useEffect call, als tweede argument. Zodra één of meerdere elementen in deze array wijzigen (en er een re-render gebeurd), wordt de useEffect hook opnieuw uitgevoerd. Onderstaande code voegt een nieuwe state variabele dependency toe, deze variabele wordt met 1 verhoogt als de gebruiker op de nieuwe knop drukt.

In onderstaande code op lijn 19 wordt een functie gebruikt om de state te updaten, de reden hiervoor is ietwat complex en moet niet gekend zijn voor dit vak, we verwijzen de geïnteresseerde lezer door naar de appendix van deze les.

/src/components/todo/toDoLists.js
const ToDoLists = () => {
    const [publicLists, setPublicLists] = useState([]);
    const [userLists, setUserLists] = useState([]);
    const [dependency, setDependency] = useState(0);

    useEffect(() => {
        const fetchData = async () => {
            const lists = await fetchAllPublicLists();
            console.log("Fetched data in use effect.");
            setPublicLists(lists);
        };
        fetchData();
    }, [dependency])

    return <div>

        <div className={'d-grid mb-4'}>
            <Button variant={'primary'}
                    onClick={() => setDependency((dependency) => dependency + 1)}>
                Increment dependencies &amp; trigger <i>useEffect</i>
            </Button>
        </div>

        <Tabs justify defaultActiveKey="public" id="uncontrolled-tab-example" className="mb-3">
            <Tab eventKey="public" title="Public lists">
                <h1>Public To-Do lists</h1>
                <ListGroup>
                    {publicLists.map(l => <ToDoListItem key={l.uuid} {...l}/>)}
                </ListGroup>
            </Tab>
            <Tab eventKey="user" title="My lists">
                <h2>My To-Do lists</h2>
                {userLists.map(l => <ToDoListItem key={l.uuid} {...l}/>)}
            </Tab>
        </Tabs>
    </div>
}












 





 


















1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Copied!

Zoals onderstaande gif toont, is het probleem nu opgelost en wordt de data enkel opgehaald als we op de knop drukken.

Fig 5: UseEffect hook met dependencies

Als we de data enkel moeten ophalen bij het openen van de pagina, kan een lege dependency array meegegeven worden. Daarnaast kunnen we verschillende useEffect calls gebruiken binnen eenzelfde component. Waar de lifecycle methodes gegroepeerd werden per moment dat deze uitgevoerd moesten worden, kunnen we door middel van verschillende useEffect hooks de side effects groeperen volgens concern. Dit zorgt voor betere separation of concernsopen in new window in onze applicaties, iets wat altijd positief is.

/src/components/todo/toDoLists.js
useEffect(() => {
    const fetchData = async () => {
        const lists = await fetchAllPublicLists();
        console.log("Fetched data in use effect.");
        setPublicLists(lists);
    }
    fetchData();
}, [dependency]);

useEffect(() => {
    const fetchData = async () => {
        const lists = await fetchAllListCreatedByUser();
        setUserLists(lists);
    }
    fetchData();
}, []);







 







 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Copied!

Concept: Dependencies array

Het is niet altijd ideaal om de side effects uit te voeren na elke render, een API request kan geld kosten, of over veel data gaan en bijgevolg traag zijn. In zo'n gevallen kan het tweede argument van de useEffect hook gebruikt worden, dit is een array van dependencies, als één of meerdere elementen in de array aangepast worden, zal het side effect opnieuw uitgevoerd worden.

useEffect(() => {
    // Voer een side effect uit na de eerste render,
    // of als dependency1 of dependency2 gewijzigd is.
}, [dependency1, dependency2])
1
2
3
4
Copied!

Een lege array kan gebruikt worden als het side effect enkel na de eerste render uitgevoerd mag worden.

useEffect(() => {
    // Voer een side effect uit na de eerste render.  
}, [])
1
2
3
Copied!

Elke property of variabele die gebruikt wordt in de useEffect call moet toegevoegd worden aan de dependency array. Anders krijg je problemen omdat je met oude waarden van deze variabelen werkt binnen useEffect.

Cleanup

We hebben tot nu toe, enkel besproken hoe de useEffect hook gebruikt kan worden om de componentDidMount en componentDidUpdate methodes te vervangen, maar natuurlijk is het met hooks nog steeds belangrijk om memory leaks te vermijden, daarom kan de useEffect hook ook gebruikt worden om HTTPRequests te annuleren, of andere cleanup te doen.

De JavaScript specificaties bevatten een klasse AbortControlleropen in new window, deze klasse kan gebruikt worden om fetchopen in new window request te annuleren. De aangeleverde API methodes hebben allemaal een optionele parameter abortController, deze kan gebruikt worden om een AbortController instantie mee te geven, vervolgens kan een request geannuleerd worden via de abort() methode van deze instantie.

Een geannuleerd request zal onderstaande error produceren:

{
  "message": "FetchError: Aborted",
  "details": "",
  "hint": "",
  "code": 20
}
1
2
3
4
5
6
Copied!

In de API klassen worden alle errors opgevangen, in het geval dat de API call een array teruggeeft wordt een lege array teruggeven als er zich een fout voordoet, in het geval de API call één object teruggeeft, wordt er undefined teruggegeven als er zich een error voordoet. In het geval de API call niets teruggeeft (update, insert, delete), geeft de methode true terug als de call succesvol uitgevoerd is, en false in het andere geval.

De cleanup code is een functie die als resultaat teruggeven wordt door de callback van de useEffect hook. We kunnen de useEffect hooks dus als volgt uitbreiden, zodat memory leaks niet meer kunnen voorkomen en de state van unmounted componenten dus niet geüpdatet wordt.

useEffect(() => {
    const abortController = new AbortController();
    const fetchData = async () => {
        const lists = await fetchAllPublicLists(abortController);
        console.log("Fetched data in use effect.");
        if (lists.length !== 0) setPublicLists(lists);
    }
    fetchData();

    return () => abortController.abort();
}, [dependency]);

useEffect(() => {
    const abortController = new AbortController();
    const fetchData = async () => {
        const lists = await fetchAllListCreatedByUser(abortController);
        if (lists.length !== 0) setUserLists(lists);
    }
    fetchData();

    return () => abortController.abort();
}, []);

 



 



 



 


 



 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Copied!

Concept: useEffect cleanup

Een asynchrone operatie binnen een component kan geannuleerd worden door een functie terug te geven uit de useEffect hook. Deze cleanup code wordt voor elke re-render en vlak voor het unmounten uitgevoerd.

useEffect(() => {
    // Voer een side effect uit.

    return () => {
        // Annuleer het side effect als de 
        // component uit de DOM verwijderd wordt. 
    }
}, dependencyArray)
1
2
3
4
5
6
7
8
Copied!

De useEffect hook kan nog steeds gebruikt worden om timers, intervals en andere zaken te annuleren die doorheen de volledige levensduur van de component moeten voortbestaan, hiervoor schrijf je een useEffect hook met een cleanup functie die enkel uitgevoerd wordt als de component uit de DOM verwijderd wordt.

useEffect(() => {
    return () => {
        console.log("The component unmounted!");
        // Cleanup code
    }
}, [])
1
2
3
4
5
6
Copied!

useRouteMatch

Naast de useHistory hook die we al gebruikt hebben bevat React Router ook de useRouteMatchopen in new window hook. We kunnen deze hook gebruiken om de ToDoListItem component uit te breiden met een extra link, zodat we naar de detailpagina gestuurd worden als we op een To-Do lijst klikken. De useRouteMatch hook kan dus, net zoals het match property dat we in les 3open in new window gebruikt hebben, van pas komen om geneste routes op te bouwen.

/src/components/todo/toDoListItem.js
import {useHistory, useRouteMatch} from 'react-router-dom';

const ToDoListItem = (props) => {
    const match = useRouteMatch();
    const history = useHistory();

    const navigateToDetail = () => {
        history.push(`${match.path}/${props.uuid}`);
    }

    return <ListGroupItem className={'d-flex justify-content-between align-items-start'}
                          action onClick={navigateToDetail}>
        <div className="ms-2 me-auto">
            <div className="fw-bold fs-3">
                {props.name}
            </div>
            <div className={'text-muted'}>
                Created by {props.username} {props.profiles.username}
            </div>
        </div>
    </ListGroupItem>
}
 


 



 



 










1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Copied!

useParams

In de startbestanden, vind je een eerste versie van de detailpagina (/src/todo/toDoListDetail.js). De UI is volledig afgewerkt, ook de methodes om taken toe te voegen aan de To-Do lijst of om de status aan te passen, zijn (grotendeels) beschikbaar. Er ontbreekt echter nog een belangrijk onderdeel, de code binnen de useEffect hooks staat nog in commentaar.

/src/components/todo/toDoListDetail.js
const ToDoListDetail = (props) => {
    const [toDoList, setToDoList] = useState({});
    
    // Niet relevante code weggelaten.

    useEffect(() => {
        const abortController = new AbortController();
        const fetchData = async () => {
            // const toDoList = await fetchList(id, abortController);
            // if (toDoList) setToDoList(toDoList);
        }
        fetchData();

        return () => abortController.abort();
    }, [])
}








 
 






1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Copied!

Om deze code uit commentaar te halen, moeten we eerst het id van de To-Do list ophalen, hierboven hebben we het id meegegeven via de URL. Net zoals voor de history en match properties die door een Route component doorgegeven werden, voorziet React Router ook een hook voor het ophalen van parameters. Via de useParams hook kunnen we eenvoudig een parameter ophalen, hier gebruiken we natuurlijk dezelfde naam voor de parameter(s) als we gedefinieerd hebben in de Route componenten. Let op, het id moet nu meegegeven worden als dependency van de useEffect hook, we gebruiken deze variabele namelijk in de useEffect hook.

/src/components/todo/toDoListDetail.js
import {useParams} from 'react-router-dom';

const ToDoListDetail = (props) => {
    const [toDoList, setToDoList] = useState({});
    const {id} = useParams();
    
    // Niet relevante code weggelaten.

    useEffect(() => {
        const abortController = new AbortController();
        const fetchData = async () => {
            const toDoList = await fetchList(id, abortController);
            if (toDoList) setToDoList(toDoList);
        }
        fetchData();

        return () => abortController.abort();
    }, [id])
}
 



 






 
 




 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Copied!

Events & side effects

Zoals zichtbaar is in onderstaande gif, werkt de detailpagina nog niet zoals verwacht. Wijzigingen worden pas getoond na een reload.

Fig 6: CRUD werkt, maar pas zichtbaar na een reload

Om dit probleem op te lossen kunnen we een extra variabele toevoegen aan de state, deze variabele geeft aan of er een update moet gebeuren, i.e. of het side effect opnieuw uitgevoerd moet worden. Deze variabele kunnen we vervolgens toevoegen aan de dependencies van de useEffect call. Als er iets mis loopt met een request geeft de fetchList methode undefined terug, we kunnen dit feit gebruiken om vervolgens te controleren of de state al dan niet geüpdatet moet worden (een geannuleerd verzoek produceert ook een error). We voegen ook een log statement toe aan de fetchData functie, zo zien we hoeveel keer deze functie uitgevoerd wordt.

/src/components/todo/toDoListDetail.js
const ToDoListDetail = (props) => {
    // Niet relevante code weggelaten.
    const [shouldUpdate, setShouldUpdate] = useState(true);

    useEffect(() => {
        const abortController = new AbortController();
        const fetchData = async () => {
            const toDoList = await fetchList(id, abortController);
            if (toDoList) {
                setToDoList(toDoList);
                setShouldUpdate(false);
            }
            console.log(toDoList);
        }
        fetchData();
        return () => abortController.abort();
    }, [id, shouldUpdate])
}


 





 
 
 
 



 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Copied!

De event handlers aanpassen is iets moeilijker, de updateTask, deleteTask en createTask methodes geven wel een boolean terug die aangeeft of de methode als dan niet succesvol uitgevoerd is, maar dit is niet voldoende. De methodes geven een error terug als het request niet succesvol afgerond is, maar we hebben geen AbortController toegevoegd. We gaan ervan uit dat een wijziging doorgevoerd moet worden als de gebruiker op een delete/update/insert knop drukt, ook al is hij/zij daarna onmiddellijk naar een andere pagina genavigeerd. We willen we deze requests dus niet annuleren.

We kunnen een variabele toevoegen die aangeeft of de component nog gemount is, als dit niet meer het geval is voeren we de updates niet uit. Om deze variabele, vlak voor het unmounted, te wijzigen kunnen we gebruik maken van een tweede useEffect hook.

const ToDoListDetail = (props) => {
    // Niet relevante code weggelaten.
    let isMounted = false;

    useEffect(() => {
        isMounted = true;
        return () => {
            isMounted = false
        };
    }, [])
}
1
2
3
4
5
6
7
8
9
10
11
Copied!

Deze code produceert echter een foutmelding in de console en terminal.

Assignments to the 'isMounted' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the '.current' property. Otherwise, you can move this variable directly inside useEffect react-hooks/exhaustive-deps

De reden dat we deze foutmelding krijgen is dat we niet langer met klassen zijn aan het werken, de data blijft niet bewaard doorheen verschillende renders. Dit probleem is, zoals de foutmelding vertelt, eenvoudig op te lossen door de data te wrappen in een useRef hook. Let op, useState zou ook werken, maar dit is geen goed idee omdat we het aantal elementen in de state steeds willen beperken en omdat, elke wijziging een trigger is voor een re-render. De useRef hook kan gebruikt worden voor persistente storage van variabelen die het render process niet beïnvloeden.

const ToDoListDetail = (props) => {
    // Niet relevante code weggelaten.
    let isMounted = useRef(false);

    useEffect(() => {
        isMounted.current = true;
        return () => {
            isMounted.current = false
        };
    }, [])
}
1
2
3
4
5
6
7
8
9
10
11
Copied!

Vervolgens kunnen we de event handlers dan aanpassen zodat de setShoulUpdate call enkel uitgevoerd wordt als de component nog in de DOM aanwezig is.

/src/components/todo/toDoListDetail.js
const ToDoListDetail = (props) => {
    // Niet relevante code weggelaten.

    const changeTaskStatus = async (name, taskId, newStatus) => {
        const success = await updateTask(toDoList.id, taskId, newStatus);
        if (success && isMounted.current) setShouldUpdate(true);
    }

    const deleteTaskFromList = async (taskId) => {
        const success = await deleteTask(taskId);
        if (success && isMounted.current) setShouldUpdate(true);
    }

    const addTaskToList = async (name) => {
        const success = await createTask(name, toDoList.id);
        if (success && isMounted.current) setShouldUpdate(true);
    }
}





 




 




 


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Copied!

Zoals in onderstaande gif te zien is, wordt elk API request twee keer uitgevoerd.

Fig 7: Het resultaat van CRUD operaties is onmiddellijk zichtbaar maar, elk request wordt twee keer uitgevoerd.

Dit komt natuurlijk omdat we in de callback van useEffect de waarde van shouldUpdate updaten naar false, dit is een update aan de state en bijgevolg wordt de dependency array opnieuw bekeken. Hier staat als twee argument nu false in de plaats van true vervolgens wordt de callback van de useEffect hook dus uitgevoerd. We kunnen dit probleem eenvoudig oplossen door een check toe te voegen die het side effect annuleert als shouldUpdate false is. Zo wordt elke API request slechts één keer uitgevoerd (te controleren via de console).

/src/components/todo/toDoListDetail.js
const ToDoListDetail = (props) => {
    // Niet relevante code weggelaten.
    const [shouldUpdate, setShouldUpdate] = useState(true);

    useEffect(() => {
        if (!shouldUpdate) return;

        const abortController = new AbortController();
        const fetchData = async () => {
            const toDoList = await fetchList(id, abortController);
            if (toDoList) {
                setToDoList(toDoList);
                setShouldUpdate(false);
            }
            console.log(toDoList);
        }
        fetchData();
        return () => abortController.abort();
    }, [id, shouldUpdate])
}





 














1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Copied!

Custom hooks

Door middel van bovenstaande hooks kunnen we nu alles wat we met class components konden doen, ook in functiecomponenten implementeren. Er is echter nog één belangrijk probleem dat we niet bekeken hebben. Hooks stellen een ontwikkelaar in staat om stateful code eenvoudig te herbruiken. Dit kan door eigen hooks te schrijven. Een hook is niets anders dan een functie, bijgevolg kan elke functie als hook gebruikt worden. React legt één limitatie op eigen hooks, de functienaam moet beginnen met het woord use. Door stateful code af te zonderen in een nieuwe hook kunnen we deze code herbruiken in verschillende componenten, zo wordt het, bijvoorbeeld, eenvoudig om verschillende view te bouwen voor dezelfde data.

Laten we de code uit de ToDoListDetail component, waarmee data opgehaald en geüpdatet wordt afzonderen in een eigen hook. We hoeven enkel het id van de To-Do lijst mee te geven aan deze custom hook.

/src/hooks/useToDoStore.js
const useToDoStore = (id) => {
    const [toDoList, setToDoList] = useState({});
    const [shouldUpdate, setShouldUpdate] = useState(true);
    
    let isMounted = useRef(false);

    useEffect(() => {
        if (!shouldUpdate) return;

        const abortController = new AbortController();
        const fetchData = async () => {
            const toDoList = await fetchList(id, abortController);
            if (toDoList) {
                setToDoList(toDoList);
                setShouldUpdate(false);
            }
            console.log(toDoList);
        }
        fetchData();
        return () => abortController.abort();
    }, [id, shouldUpdate])

    useEffect(() => {
        isMounted.current = true;
        return () => {isMounted.current = false};
    }, [])

    const changeTaskStatus = async (name, taskId, newStatus) => {
        const success = await updateTask(toDoList.id, taskId, newStatus);
        if (success && isMounted.current) setShouldUpdate(true);
    }

    const deleteTaskFromList = async (taskId) => {
        const success = await deleteTask(taskId);
        if (success && isMounted.current) setShouldUpdate(true);
    }

    const addTaskToList = async (name) => {
        const success = await createTask(name, toDoList.id);
        if (success && isMounted.current) setShouldUpdate(true);
    }

    return [toDoList, changeTaskStatus, deleteTaskFromList, addTaskToList];
}
 











































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Copied!

De ToDoListDetail component kan dan eenvoudig aangepast worden zodat deze gebruik maakt van de nieuwe hook. Merk op dat useToDoStore niet de enige custom hook is in onderstaande code, de hook useProfile() die aanwezig was in de startbestanden wordt ook gebruikt.

/src/components/todo/toDoListDetail.js
const ToDoListDetail = () => {
    const [newTaskName, setNewTaskName] = useState('');
    const createButton = useRef(null);
    const {userId} = useProfile()
    const {id} = useParams();
    const [
        toDoList, 
        changeTaskStatus, 
        deleteTaskFromList, 
        addTaskToList] = useToDoStore(id);

    const createTaskHandler = async (event) => {
        event.preventDefault();
        createButton.current.blur();
        setNewTaskName('');
        await addTaskToList(newTaskName);
    }

    // Niet relevante render code weggelaten.
}



 

 
 
 
 
 










1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Copied!

Prop-types

React dwingt ons om properties van bovenaf door te geven, regelmatig doorheen een grote componentenboom. Aangezien properties zo veel doorgegeven moeten worden, is het eenvoudig om fouten te maken. React onderhoud de bibliotheek prop-typesopen in new window, deze bibliotheek kan gebruikt worden om voor een component aan te geven welke properties er verwacht worden en om default properties te specifiëren. Tijdens het development process controleert React of de ontvangen properties overeenkomen met gespecifieerde properties, als er een mismatch gevonden wordt, wordt dit uitgeprint via de console. In een production build, wordt er geen controle uitgevoerd, dit is te kostelijk en vertraagd de applicatie. De prop-types bibliotheek kan geïnstalleerd worden via het onderstaande commando.

npm install prop-types
1
Copied!

We kunnen de properties op elke component definiëren, maar dat zou betekenen dat we elk object volledig moeten beschrijven, dit zou heel veel duplicatie betekenen en heeft niet echt nut. We definiëren de vereiste properties enkel op de componenten die de properties effectief gebruiken, voor componenten die de properties enkel doorgeven moet de controle niet toegevoegd worden. Zodra de property effectief nodig is zal er een foutmelding getoond worden als er een probleem is, via de React dev tools kan je vervolgens detecteren waar in boomstructuur het mis gaat.

De TaskItem component bevat relatief veel properties, laten we hier een controle toevoegen. Voor elke property moeten we aangeven wat het type is, en of dit vereist is.

/src/components/todo/taskItem.js
import PropTypes from 'prop-types';

const TaskItem = (props) => {
    return <ListGroupItem className={'d-flex flex-column'}>
        {/* Rest van de render code weggelaten voor de duidelijkheid.*/}
    </ListGroupItem>
}

TaskItem.propTypes = {
    name: PropTypes.string.isRequired,
    createdBy: PropTypes.shape({
        username: PropTypes.string.isRequired
    }),
    completedBy: PropTypes.shape({
        username: PropTypes.string
    }),
    complete: PropTypes.bool.isRequired,
    allowDelete: PropTypes.bool.isRequired,
    deleteTaskHandler: PropTypes.func.isRequired
    changeTaskStatus: PropTypes.func.isRequired,
}
 







 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Copied!

Stel, we voegen een niet-bestaande property toe, dan krijgen we een foutboodschap in de console. Gelijkaardige foutmeldingen zullen getoond worden als het datatype niet overeenkomt met het gespecifieerde datatype.

/src/components/todo/taskItem.js
// Niet relevante code weggelaten.

TaskItem.propTypes = {
    name: PropTypes.string.isRequired,
    createdBy: PropTypes.shape({
        username: PropTypes.string.isRequired
    }),
    completedBy: PropTypes.shape({
        username: PropTypes.string
    }),
    complete: PropTypes.bool.isRequired,
    allowDelete: PropTypes.bool.isRequired,
    deleteTaskHandler: PropTypes.func.isRequired,
    changeTaskStatus: PropTypes.func.isRequired,
    thisPropertyWillGenerateAWarning: PropTypes.string.isRequired
}














 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Copied!
Fig 8: Foutmelding omwille van ontbrekende property

Tenslotte kunnen we ook default waardes specifiëren voor een bepaalde property, bijvoorbeeld:

/src/components/todo/taskItem.js
TaskItem.defaultProps = {
    allowDelete: false
}
1
2
3
Copied!

Destructuring

De props parameter is een object, bijgevolg kunnen we deconstructingopen in new window gebruiken om de properties expliciet op te lijsten in de component. Zo wordt de render code korter omdat we nergens nog props. moeten vermelden en wordt het duidelijker welke properties nodig zijn. Zoals hieronder te zien, kan dit wel tot lange componentendefinities leiden.

/src/components/todo/taskItem.js
const TaskItem = ({name, createdBy, changeTaskStatus, 
                     completedBy, complete, allowDelete, deleteTaskHandler}) => {
    
    return <ListGroupItem className={'d-flex flex-column'}>
        <div className={'d-flex flex-row'}>
            <div className="ms-2 me-auto">
                <div className="fw-bold fs-3">
                    {name}
                </div>
                <div className={'text-muted'}>
                    Created by {createdBy.username}
                </div>
            </div>
            <div className={'flex-grow-1'}></div>
            <div className="ms-2 me-auto d-flex flex-column align-items-end">
                <div className="fw-bold fs-3" onClick={changeTaskStatus}>
                    {complete ? <ImCheckmark/> : <ImCheckmark2/>}
                </div>
                <div className={'text-muted'}>
                    {complete ? <span>Completed by {completedBy?.username}</span> : <></>}
                    <div/>
                </div>
            </div>
        </div>
        <div>
            {allowDelete ?  <div className={'d-grid mt-2'}>
                <Button variant={'danger'} onClick={deleteTaskHandler}>Delete task</Button>
            </div> : <></>}
        </div>
    </ListGroupItem>
}
 
 





























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Copied!

Uitgewerkte code van het lesvoorbeeld

Uitgewerkt lesvoorbeeld

Appendix

Deze appendix bevat facultatieve onderwerpen, i.e. onderdelen die geen deel uitmaken van de leerstof en dus ook niet op een examen gevraagd zullen worden. Alle studenten worden desondanks warm aanbevolen om deze sectie door te lezen.

Batching state updates

Deze sectie is een uitbreiding/verduidelijking op de sectie over dependencies, meer specifiek over de reden waarom in het code voorbeeld gebruik gemaakt wordt van een anonieme functie om de state aan te passen.

Als de state geüpdatet moet worden op basis van de huidige waarden is het best om een anonymous function te gebruiken als argument van de setX call. State updates zijn asynchroon, het is dus mogelijk dat de callback verschillende keren in de queue geplaatst is voor de eerste state-update uitgevoerd is.

Stel de callback wordt als volgt geschreven.

/src/components/todo/toDoLists.js
<Button variant={'primary'}
        onClick={() => {
            setDependency(dependency + 1);
            setDependency(dependency + 1);
        }}>
    Increment dependencies (current value: {dependency}) &amp; 
    trigger <i>useEffect</i>
</Button>


 
 




1
2
3
4
5
6
7
8
Copied!

Na het uitvoeren van deze code zou je verwachten dat de nieuwe waarde van dependency 2 is, we hebben setDependency tenslotte 2 keer opgeroepen. Dit is echter niet het geval. Zoals eerder gezegd, zijn state-updates asynchroon dit betekent dat de JavaScript engine de parameters van setDependency eerst berekent en vervolgens de setDependency call toevoegt aan de queue van uit te voeren code. Als we de pagina herladen en één keer op de knop drukken wordt dit dus:

/src/components/todo/toDoLists.js
<Button variant={'primary'}
        onClick={() => {
            // De huidige waarde van dependency is 0, dus 0 + 1 wordt 1.
            setDependency(1);
            setDependency(1);
        }}>
    Increment dependencies (current value: {dependency}) &amp; 
    trigger <i>useEffect</i>
</Button>


 
 
 




1
2
3
4
5
6
7
8
9
Copied!

Door een functie mee te geven aan de setDependency calls, vertellen we de JavaScript engine om de parameter pas te berekenen als de setDependency call echt uitgevoerd wordt, in tegenstelling tot wanneer deze aan de event queue toegevoegd wordt. Onderstaande code zal de state dus met 2 verhogen als op de knop gedrukt wordt.

<Button variant={'primary'}
        onClick={() => {
            setDependency((dependency) => dependency + 1);
            setDependency((dependency) => dependency + 1);
        }}>
    Increment dependencies (current value: {dependency}) &amp; 
    trigger <i>useEffect</i>
</Button>


 
 




1
2
3
4
5
6
7
8
Copied!
Last Updated: 2/2/2022, 11:17:35 AM
Contributors: Sebastiaan Henau