Les 6: Global state

Tijdens deze les bespreken we verschillende manieren om global state toe te voegen aan een applicatie, en bekijken we de voor- en nadelen van deze opties.

Startbestanden

Context

Global state is een veel voorkomend, maar complex, probleem. Omdat global state niet voor elke applicatie nodig is, bevat de core React bibliotheek geen echte oplossing voor global state.

React bevat, sinds versie 16.3 wel de optie om global state toe te voegen, maar in tegenstelling tot tools zoals libraries zoals Reduxopen in new window, MobXopen in new window, of Recoilopen in new window niet bedoeld om complexe objecten globaal te bewaren. Objecten die via de context APIopen in new window bewaard worden kunnen niet geüpdatet worden door componenten die deze state consumeren, dit kan enkel in de component waar de context waarde gedefinieerd wordt. Daarbovenop heeft de context API enkele performance problemen als de stateful data regelmatig geüpdatet wordt.

Ondanks deze gebreken, is de context API wel ideaal om data die niet of nauwelijks geüpdatet wordt tijdens de levensduur van de applicatie te bewaren. Zaken zoals localization setting, thema keuzes, user data, of session data kunnen perfect bewaard worden via de context API.

useContext

Hieronder gebruiken we context om de ingelogde gebruiker en het bijhorende sessionId te bewaren. Om context te gebruiken, kunnen we gebruik maken van de createContext methode uit de React bibliotheek. De createContext methode vraagt één parameter, een default waarde die gebruikt wordt als er verder in de applicatie geen specifieke waarde opgegeven wordt.

/src/app.js
import React from 'react';

export const UserContext = React.createContext({session: null, username: null});

// Niet relevante code weggelaten.
1
2
3
4
5
Copied!

Om context te gebruiken binnen een component, moet één van de bovenstaande componenten een provider bevatten. Zo'n provider definieert een waarde voor de context, als deze waarde wijzigt worden alle componenten die deze waarde consumeren opnieuw gerenderd.

Aangezien de gebruiker en session data vanaf de App component nodig zijn, moet de provider hier gedefinieerd worden. De useProfile hook in onderstaand voorbeeld is zo geschreven dat het profiel automatisch bijgewerkt wordt bij elke wijziging in de database. Ook het sessionId wordt automatisch aangepast.

/src/app.js
// Niet relevante code weggelaten.
export const App = () => {
    const  {session, username} = useProfile();

    if (!session) {
        return <Authentication/>;
    }

    if (session && !username) {
        return <UsernameForm/>
    }

    return <UserContext.Provider value={{session, username}}>
        <Main/>
    </UserContext.Provider>;
}

const Main = () => {
    return <div>
        <NavBar/>
        <Container>
            {/* Inhoud weggelaten voor de duidelijkheid. */}
        </Container>
    </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
Copied!

Omdat de Main component binnen de UserContext.Provider component staat, kunnen alle kinderen van de Main component ook gebruik maken van de context waarden. Standaard worden deze waarden niet automatisch ingelezen, om gebruik te maken van de context waarden moeten deze opgevraagd worden via de useContext hook.

/src/navbar/navbar.js
import {useContext} from 'react';
import {UserContext} from '../app';

const NavBar = () => {
    const {username} = useContext(UserContext);

    // Niet relevante code weggelaten.

    return <Navbar bg="dark" expand="lg" variant="dark">
        <Container fluid>
            {/* Niet relevante JSX code weggelaten. */}
            <Navbar.Collapse className="justify-content-end">
                <Navbar.Text>
                    Welcome {username} 
                    <span  style={{color: 'white'}} onClick={logOutHandler}>(Log out)</span>
                </Navbar.Text>
            </Navbar.Collapse>
        </Container>
    </Navbar>;
}
 
 


 








 






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

Updates

Een provider definieert de waarde die in de context bewaard wordt, dit is dan ook de enige plaats waar deze waarde aangepast kan worden. Wil je de context bijwerken, dan moet een functie doorgegeven worden via de context. Net zoals dit nodig is als we de context waarden expliciet zouden doorgeven via properties.

We illustreren dit door de optie toe om een taal te kiezen, we implementeren enkel het menu, de i18nopen in new window code zelf, valt buiten de scope van deze cursus. In de startbestanden is een array met 4 talen terug te vinden.

/src/data/i18n.js
export const languages = [
    {i18n: 'en_us', name: 'English US', flag: '🇺🇸'},
    {i18n: 'en_uk', name: 'English UK', flag: '🇬🇧'},
    {i18n: 'nl_nl', name: 'Nederlands', flag: '🇳🇱'},
    {i18n: 'fr_fr', name: 'Français', flag: '🇫🇷'},
]
1
2
3
4
5
6
Copied!

We kunnen de App component uitbreiden met een tweede context, die de internationaliseringsinstellingen bevat.

/src/app.js
// Niet relevante code weggelaten.
export const LanguageContext = React
    .createContext({selectedLanguage: undefined, i18nChangeHandler: () => undefined});
1
2
3
Copied!

Als default waarde gebruiken we het eerste element in bovenstaande array. Als de gebruiker de taal gewijzigd heeft, wordt dit weggeschreven naar de localstorage. Deze persistente waarde zal dan gebruikt worden in de plaats van de defaultwaarde als de applicatie de volgende keer opstart (lijn 3).

Om de wijziging aan de context te bewaren is er natuurlijk een callback functie nodig, deze wordt gedefinieerd op lijnen 10-13.

Tenslotte kunnen we deze callback functie en de ingelezen of default taal toevoegen in een nieuwe Provider. Het is dus perfect mogelijk om twee of meer context providers in eenzelfde applicatie te gebruiken. Meer zelfs, het is aan te raden om data die niet samen wijzigt ook niet samen in één context te bewaren. Je splits deze best op zoals we dit ook deden bij de useState hook.

/src/app.js
export const LanguageContext = React.createContext({selectedLanguage: undefined, i18nChangeHandler: () => undefined});

const chosenLanguage = JSON.parse(localStorage.selectedLanguage || null) || languages[0]

export const App = () => {
    const [selectedLanguage, setSelectedLanguage] = useState(chosenLanguage);

    // Niet relevate code weggelaten.

    const i18nChangeHandler = (newSelectedLanguage) => {
        setSelectedLanguage(newSelectedLanguage);
        localStorage.selectedLanguage = JSON.stringify(newSelectedLanguage);
    }

    return <LanguageContext.Provider 
                value={{selectedLanguage: selectedLanguage, i18nChangeHandler}}>
        <UserContext.Provider value={{session, username}}>
            <Main/>
        </UserContext.Provider>
    </LanguageContext.Provider>;
}


 






 
 
 
 

 
 



 

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

De nieuwe context kan dan geconsumeerd worden in de NavBar component, waar we een dropdownmenu toevoegen waarmee de taal geselecteerd kan worden. Als een nieuwe taal geselecteerd wordt, dan wordt de i18nChangeHandler methode in de context ook opgeroepen.

/src/navbar/navbar.js
const NavBar = () => {
    const {username} = useContext(UserContext);
    const {selectedLanguage, i18nChangeHandler} = useContext(LanguageContext);

    const logOutHandler = (event) => {
        event.preventDefault();
        signOut();
    }

    return <Navbar bg="dark" expand="lg" variant="dark">
        <Container fluid>
            {/* Niet relevante code weggelaten. */}
            <Navbar.Collapse className="justify-content-end">
                <NavDropdown title={`${selectedLanguage.flag} ${selectedLanguage.name}`}>
                    {languages.map(l => 
                        <NavDropdown.Item key={l.i18n}
                                          onClick={() => i18nChangeHandler(l)}>
                            {l.flag} {l.name}
                        </NavDropdown.Item>)
                    }
                </NavDropdown>
            </Navbar.Collapse>
        </Container>
    </Navbar>;
}


 










 


 








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!

Recoil

Recoilopen in new window is een global state management framework, ontwikkeld door Facebook, de ontwikkelaar van React.

React is een geweldig framework, maar het state management heeft enkele gebreken. De enige optie om state te gebruiken in verschillende componenten, onderaan de componentenboom, is om deze state naar boven te verplaatsen. Regelmatig zelfs tot helemaal bovenaan in de componentenboom. Dit betekent dat veel onderliggende componenten opnieuw gerenderd moeten worden. De context API voegt wel globale context toe, maar is, omwille van technische niet zo performant als de complexere third-party oplossingen. Het React team heeft Recoil in het leven geroepen om deze problemen op te lossen.

Recoil bied een eenvoudige oplossingen om global state toe te voegen, zonder dat hierbij een hoop boilerplate code komt kijken, iets wat bij de alternatieven niet altijd het geval is. Daarnaast lijkt Recoil, in gebruik, heel sterk op local state management door middel van hooks. Dit wil echter niet zeggen dat Recoil het beste, of meest populaire, global state framework is, een snelle zoekopdracht toont ons verschillende vergelijkingen waar andere tools zoals MobX duidelijk sneller zijn. Als we puur kijken naar het aantal gebruiker is Redux de winnaar, zoals blijkt uit onderstaande grafiek.

Fig 1: Recoil vs MobX vs Redux

Bron: npmtrends.com - 1/12/2021open in new window

Ondanks dat Recoil één van de minst populaire frameworks is, is de lage leercurve en hoge integratie met nieuwe React features een zeer goede reden om het toch te gebruiken. Asynchronous data fetching wordt zeer eenvoudig met Recoil, en features zoals Suspenseopen in new window kunnen gebruikt worden om je code eenvoudiger te bouwen. React en Recoil worden door hetzelfde team gebouwd, wat betekent dat de nieuwste React features ook snel geïntegreerd zullen worden in nieuwe versies van Recoil.

Om Recoil toe te voegen aan een React applicatie moet het volgende NPM-commando uitgevoerd worden.

npm install recoil
1
Copied!

Vervolgens moeten we ergens in de applicatie een RecoilRoot component toevoegen, alle kinderen van deze RecoilRoot kunnen gebruik maken van de globale state. Omdat we de state in de volledige applicatie gebruiken, en aangezien Recoil efficient omgaat met rerenders, kunnen we de state definiëren in de root van onze applicatie.

/src/index.js
// Niet relevante imports weggelaten.
import {RecoilRoot} from 'recoil';

ReactDOM.render(
    <BrowserRouter>
        <RecoilRoot>
            <App/>
        </RecoilRoot>
    </BrowserRouter>,
    document.getElementById('root')
)

 



 

 



1
2
3
4
5
6
7
8
9
10
11
Copied!

Atomen

Een atoom is het meest eenvoudige element in Recoil. De source-of-truth voor de applicatie bestaat uit een verzameling atomen. Deze verzameling bevat dus, met andere woorden, de state. Een atoom kan gelezen en gewijzigd worden, net zoals het geval is voor de state in class components en state die via de useState hook toegevoegd is.

Er zijn enkele belangrijke verschillen. Een atoom is global, dus kan een atoom in meerdere componenten gebruikt worden, al deze componenten kunnen de waardes een atoom lezen en aanpassen. Daarbovenop is het niet meer nodig om clean-up code te schrijven, Recoil atomen zijn globaal en blijven dus in memory, ook als er geen enkele component meer is die gebruik maakt van de state. Pas als de component waarin de RecoilRoot gebruikt wordt, uit de DOM verwijderd wordt, zal de Recoil state verwijderd worden.

Een ander belangrijk verschil is cleanup. Als we de setState functie gebruiken, moesten we ervoor zorgen dat deze niet uitgevoerd werd als de component unmounted is, ditzelfde probleem deed zich voor als we asynchrone data ophaalden via een useEffect call. In Recoil wordt het ophalen van data voortgezet, ook al is de component niet langer zichtbaar, de state is tenslotte globaal en kan vervolgens altijd aangepast worden, zelfs als er in de DOM geen component is die de state gebruikt. Als de component die gebruik maakt van de asynchrone data vervolgens terug geladen wordt, is de data al beschikbaar en dus onmiddellijk zichtbaar.

Zoals bovenaan gezegd, bouwen we een applicatie waarin gebruikers noties kunnen nemen in markdown formaat. We zullen gebruikers de optie geven om deze noties te groeperen in mappen. Aangezien we geen opties bieden om extra mappen aan te maken, kunnen we deze data lokaal bewaren, zonder dat er nood is aan een database. We gebruiken volgende data (die in de startbestanden te vinden is).

/src/data/folders.js
const folders = [
    {id: 1, name: "Mobile Apps", parent: null},
    {id: 2, name: "React", parent: null},
    {id: 3, name: "Vue", parent: null},
    {id: 4, name: "Svelte", parent: null},
    {id: 5, name: "Angular", parent: 1},
    {id: 6, name: "Ionic", parent: 1},
    {id: 7, name: "RxJS", parent: 1},
    {id: 8, name: "Data Binding", parent: 5},
    {id: 9, name: "Services", parent: 5},
    {id: 10, name: "Context", parent: 12},
    {id: 11, name: "State", parent: 2},
    {id: 12, name: "Global State", parent: 2},
    {id: 13, name: "Recoil", parent: 12},
    {id: 14, name: "MobX", parent: 12},
    {id: 15, name: "Redux", parent: 12}
];
export default folders;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Copied!

We kunnen deze data vervolgens verwerken in een atoom. Een atoom heeft twee attributen, een defaultwaarde en een key, de key moet uniek zijn over alle atomen en selectors heen. Twee gelijke keys leiden tot defecte code. Om een atoom aan te maken, voorziet Recoil een functie atom().

/src/recoilState/filesystem.js
import {atom} from 'recoil';
import folders from '../data/folders';

export const filesystem = atom({
    key: 'filesystem',
    default: folders
});
1
2
3
4
5
6
7
Copied!

useRecoilValue

De useRecoilValue hook kan gebruikt worden om een atoom in read-only mode te gebruiken. Dit betekent dat we de waarde van een atoom kunnen uitlezen, maar niet kunnen aanpassen. Via deze hook, en de component Folder die aanwezig is in de startbestanden, kunnen we een overzicht bouwen van alle folders in het "filesystem".

/src/filesystem/filesystem.js
import {useRecoilValue} from 'recoil';
import {filesystem} from '../recoilState/filesystem';

const Filesystem = () => {
    const folders = useRecoilValue(filesystem);

    return <Row>
        {folders.map(f => <Folder key={f.id} name={f.name}/>)}
    </Row>
}

 


 





1
2
3
4
5
6
7
8
9
10
Copied!
Fig 2: Mappenstructuur gebouwd via Recoil

Selectors

Natuurlijk is het niet ideaal om alle mappen te tonen. Zoals in de data hierboven zichtbaar is, is de mappenstructuur hiërarchisch gebouwd, i.e. een map kan zelf andere mappen bevatten. Via een selector kunnen we derived state ophalen, dit is een gegeven dat bepaald kan worden op basis van de huidige state en dus zelf niet in een atoom bewaard moet worden. Om door de mappenstructuur te kunnen browsen moeten we eerste de huidige map kennen, hiervoor kunnen we opnieuw een atoom aanmaken. Aangezien we in de root vertrekken, gebruiken we in onderstaand atoom de defaultwaarde null om aan te geven dat er geen bovenliggende map is.

/src/recoilState/filesystem.js
// Niet relevante code weggelaten.
export const currentDirectoryId = atom({
    key: 'currentDirectoryId',
    default: null
})
1
2
3
4
5
Copied!

Vervolgens kunnen we een selector gebruiken om het filesystem te filteren en enkel de inhoud van de huidige map op te halen. Dit kan door middel van de selector functie die geëxporteerd wordt door Recoil. Een selector en atoom zijn heel gelijkaardig, beide hebben een key attribuut en zijn, wat betreft de useRecoilValue hook (en de andere Recoil hooks), volledig gelijk.

Het enige verschil is dat een selector een get attribuut heeft in de plaats van een default attribuut. Dit get attribuut krijgt als waarde een functie die de derived state berekend. De eenvoudigste vorm van deze functie heeft één argument, een object dat eveneens een functie get bevat. Deze functie kan binnen de selector gebruikt worden om de waarde van een atoom of een andere selector in te lezen. In onderstaande code sorteren we de mappen alfabetisch, voor meer informatie over hoe deze comparison functie werkt, verwijzen we de geïnteresseerde lezer naar de MDN documentatieopen in new window .

/src/recoilState/filesystem.js
import {atom, selector} from 'recoil';

// Niet relevante code weggelaten. 

export const currentDirectoryContent = selector({
    key: 'currentDirectoryContent',
    get: ({get}) => {
        const parentId = get(currentDirectoryId);
        return get(filesystem)
            .filter(d => d.parent === parentId)
            .sort((a, b) => a.name < b.name ? -1 : 1);
    }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
Copied!

Deze selector kan vervolgens gebruikt worden om enkel een overzicht te tonen van de mappen in de huidige directory. Dit kan opnieuw met de useRecoilValue hook.

/src/filesystem/filesystem.js
import {useRecoilValue} from 'recoil';
import {currentDirectoryContent} from '../recoilState/filesystem';

const Filesystem = () => {
    const folders = useRecoilValue(currentDirectoryContent);

    return <Row>
        {folders.map(f => <Folder key={f.id} name={f.name}/>)}
    </Row>
}
 
 


 





1
2
3
4
5
6
7
8
9
10
Copied!
Fig 3: Mappenstructuur gebouwd via een Recoil selector

useSetRecoilState

Tot nu toe hebben we de globale state enkel uitgelezen, dit is natuurlijk niet voldoende, de state moet ook regelmatig aangepast worden. Hiervoor kunnen we de useSetRecoilState hook gebruiken, deze hook geeft enkel een setter terug, en de effectieve waarde dus niet.

Dankzij deze hook kunnen we de Filesystem component heel eenvoudig uitbreiden. De Folder component bevat een attribuut clickHandler, de functie die hieraan toegekend wordt, wordt uitgevoerd als er op een folder geklikt wordt.

/src/filesystem/filesystem.js
import {useRecoilValue, useSetRecoilState} from 'recoil';
import {currentDirectoryContent, currentDirectoryId} from '../recoilState/filesystem';

const Filesystem = () => {
    const folders = useRecoilValue(currentDirectoryContent);
    const setCurrentDirectoryId = useSetRecoilState(currentDirectoryId);

    return <Row>
        {folders.map(f => <Folder key={f.id} name={f.name} 
                                  clickHandler={() => setCurrentDirectoryId(f.id)}/>)}
    </Row>
}

 







 


1
2
3
4
5
6
7
8
9
10
11
12
Copied!

In onderstaande gif is duidelijk te zien dat we door de mappenstructuur kunnen navigeren. Daarbovenop zien we ook dat de state globaal is, als we navigeren naar de link Notes wordt de Filesystem component verwijderd uit de DOM, maar als we daarna terugkeren naar de link Folders komen we terug in dezelfde map.

Fig 4: Navigatie door de mappenstructuur

Het is ook duidelijk dat we nog niet terug kunnen navigeren naar een parent directory, hiervoor kunnen we opnieuw een selector toevoegen. Er is geen limiet op het aantal selectors die voor een atoom gemaakt kunnen worden.

/src/recoilState/filesystem.js
// Niet relevante code weggelaten.

export const currentDirectoryParent = selector({
    key: 'currentDirectoryParent',
    get: ({get}) => {
        const currentId = get(currentDirectoryId);
        if (!currentId) {
            return undefined;
        }
        return get(filesystem).find(f => f.id === currentId).parent;
    }
})
1
2
3
4
5
6
7
8
9
10
11
12
Copied!

Deze selector kan dan gebruikt worden in de Filesystem component.

/src/filesystem/filesystem.js
import {useRecoilValue, useSetRecoilState} from 'recoil';
import {
    currentDirectoryContent,
    currentDirectoryId,
    currentDirectoryParent
} from '../recoilState/filesystem';

const Filesystem = () => {
    const folders = useRecoilValue(currentDirectoryContent);
    const setCurrentDirectoryId = useSetRecoilState(currentDirectoryId);
    const parentId = useRecoilValue(currentDirectoryParent);

    return <Row>
        {parentId !== undefined ? 
            <Folder name=".." clickHandler={() => setCurrentDirectoryId(parentId)}/> : 
            <></>
        }
        {folders.map(f => <Folder key={f.id} name={f.name} 
                                  clickHandler={() => setCurrentDirectoryId(f.id)}/>)}
    </Row>
}




 





 


 
 
 
 




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

Asynchrone data

Tot nu toe kwam er bij het ophalen van asynchrone data heel wat kijken. Requests moesten steeds geannuleerd worden en we moesten er rekening mee houden dat we geen state updates uitvoeren op unmounted components. Zoals al gezegd tijdens de inleiding van Recoil, moeten we ons hier nu niets meer van aantrekken. Dankzij selectors en suspense wordt het heel eenvoudig om asynchrone data op te halen.

Voor we asynchrone data kunnen ophalen, moeten we eerste de mogelijkheid bieden om een nieuwe notitie aan te maken, hiervoor kunnen we de Filesystem component uitbreiden met de NewNote component, die in de startbestanden aanwezig is. Deze component definieert een modaal venster. Het enige wat we moeten doen is ervoor zorgen dat deze component op het gepaste moment getoond wordt.

Om het modale venster te tonen gebruiken we local state, dit is tenslotte een UI-beslissing en heeft niets te maken met de rest van de applicatie. We gebruiken de File component, die ook al in de startbestanden aanwezig is, om een knop te tonen waarmee het modaal venster zichtbaar gemaakt kan worden.

/src/filesystem/filesystem.js
const Filesystem = () => {
    const folders = useRecoilValue(currentDirectoryContent);
    const setCurrentDirectoryId = useSetRecoilState(currentDirectoryId);
    const parentId = useRecoilValue(currentDirectoryParent);
    const [showNewNote, setShowNewNote] = useState(false);

    return <Row>
        {parentId !== undefined ?
            <Folder name=".." clickHandler={() => setCurrentDirectoryId(parentId)}/> :
            <></>
        }
        {folders.map(f => <Folder key={f.id} name={f.name} 
                                  clickHandler={() => setCurrentDirectoryId(f.id)}/>)}
        
        <File clickHandler={() => setShowNewNote(true)}/>
        <NewNote show={showNewNote}
                 closeHandlerHook={() => setShowNewNote(false)}/>
    </Row>
}




 









 
 
 


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

Asynchrone selectors

Een selector kan heel eenvoudig omgevormd worden om asynchrone data op te halen, waar deze data vandaan komt is niet belangrijk voor de selector. Een asynchrone selector kan dus gebruikt worden in combinatie met fetch, axios, RxJS, ... Het enige verschil met een synchrone selector is dat de get methode nu asynchroon is. Om de notities op te halen voor een kunnen we dus volgende selector gebruiken.

/src/recoilState/notes.js
import {selector} from 'recoil';
import {getNotesForDirectory} from '../api/notesApi';
import {currentDirectoryId} from './filesystem';

export const notesInCurrentDirectory = selector({
    key: 'notesInCurrentDirectory',
    get: async ({get}) => {
        return await getNotesForDirectory(get(currentDirectoryId));
    }
})






 
 
 

1
2
3
4
5
6
7
8
9
10
Copied!

Deze selector kan op exact dezelfde manier gebruikt worden als een synchrone selector. We voegen ook alvast een link toe naar de detailpagina waar de notitie bewerkt kan worden.

/src/filesystem/noteList.js
import {useRecoilValue} from 'recoil';
import {notesInCurrentDirectory} from '../recoilState/notes';
import File from './file';
import {useHistory} from 'react-router-dom';

const NoteList = () => {
    const history = useHistory();
    const notes = useRecoilValue(notesInCurrentDirectory);
    return <>
        {notes.map(n =>
            <File useNewFileIcon={false} key={n.id} name={n.title}
                  clickHandler={() => history.push(`/notes/${n.id}`)}/>)}
    </>
}

export default NoteList;
 
 





 

 
 
 




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

Als we de NoteList component gebruiken in de Filesystem component krijgen we (voorlopig) onderstaande error te zien.

Fig 5: Foutboodschap omwille van asynchrone selector zonder suspense

Suspense

Asynchrone data wordt, per definitie, niet onmiddellijk geladen. In de voorgaande lessen hebben we dit opgelost door een lege array te gebruiken als default waarde. Als we over een lege array itereren werd er gewoon niets getoond. Voor selectors is dit niet meer mogelijk, een selector heeft tenslotte geen default waarde.

React bevat een oplossing, via de Suspense component kunnen we asynchrone data laden en terwijl deze is aan het laden een "loading" component tonen. Let op, de ontwikkelaars van bibliotheken zoals axios of RxJS moeten hier (momenteel) ondersteuning voor voorzien. Je kan de Suspense component dus niet zomaar gebruiken met elke bibliotheek. Gelukkig is deze integratie aanwezig in Recoil. In onderstaande code maken we gebruik van de LoadingFile component die aanwezig is in de startbestanden.

/src/filesystem/filesystem.js
// Niet relevante code weggelaten.
import React from 'react';
import NoteList from './noteList';
import LoadingFile from './loadingFile';

const Filesystem = () => {
    const folders = useRecoilValue(currentDirectoryContent);
    const setCurrentDirectoryId = useSetRecoilState(currentDirectoryId);
    const parentId = useRecoilValue(currentDirectoryParent);
    const [showNewNote, setShowNewNote] = useState(false);

    return <Row>
        {parentId !== undefined ?
            <Folder name=".." clickHandler={() => setCurrentDirectoryId(parentId)}/> :
            <></>
        }
        {folders.map(f => <Folder key={f.id} name={f.name} 
            clickHandler={() => setCurrentDirectoryId(f.id)}/>)}

        <React.Suspense fallback={<LoadingFile/>}>
            <NoteList/>
        </React.Suspense>
        <File clickHandler={() => setShowNewNote(true)}/>

        <NewNote show={showNewNote}
                 closeHandlerHook={() => setShowNewNote(false)}/>
    </Row>
}

 
 
















 
 
 






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!

In onderstaande video wordt deze code gedemonstreerd, het is duidelijk dat de Suspense component zijn werkt doet, zo lang de data niet geladen is, wordt een spinner getoond.

Fig 6: Laden via Suspense & illustratie van caching problemen

Caching

In bovenstaande video is ook te zien dat we na het aanmaken van een nieuwe notitie, de pagina moeten herladen om het resultaat te zien. Om zo'n performant mogelijke applicatie te bouwen, zorgt Recoil er voor dat data gecachet wordt. Memoizationopen in new window wordt gebruikt om het resultaat van elke selector te cachen.

Dit cachen is een goede oplossing, dankzij dit caching mechanisme kunnen we wisselen tussen componenten en toch meteen de ingeladen data zien als we terugkeren naar een vorige component. Dit is in bovenstaande video ook te zien als we navigeren naar de "Mobile Apps" folder. Als we terugkeren naar de bovenliggende component, werd de LoadingFile component niet getoond, we zagen onmiddellijk de "Demo" notitie.

Recoil werkt met pure functies, dit zijn functies die gegeven eenzelfde input ook steeds dezelfde output genereren. Aangezien een selector (voorlopig) geen parameters heeft, zou deze dus steeds dezelfde waarde moeten teruggeven volgens Recoil. Natuurlijk gebruiken we in de selector wel het atoom currentDirectoryId, en wordt dit dus gebruikt tijdens het memoization process. Dit is de reden dat we toch verschillende resultaten krijgen per map.

We kunnen, na het toevoegen van een noties, toch de data refreshen hiervoor zijn drie oplossingen, de eerste twee worden als oefening gelaten. De derde wordt hieronder besproken.

  1. In een atoom, per map, een requestId bijhouden. Telkens de data ververst moet worden kan het requestId met één verhoogt worden. Dit requestID kan dan opgevraagd worden in de asynchrone selector. Omdat het requestId anders is, zal de query opnieuw naar de database gestuurd worden en zal er een nieuwe entry toegevoegd worden aan de cache. Let op, als je het requestId terug verlaagd, zul je nog steeds de oude data te zien krijgen, deze zit tenslotte nog steeds in de cache.

  2. De data niet bewaren in een selector maar in een atoom. Dit atoom kan dan via een

  3. useEffect hook ingesteld worden. Voor een update kan dan een dependency shouldUpdate toegevoegd worden zoals we in de les over hooks gedaan hebben.

  4. De Recoil cache voor de selector invalideren.

De hook useRecoilRefresher_UNSTABLEopen in new window kan gebruikt worden om de cache van een selector te invalideren. Deze methode is UNSTABLE wat betekend dat je deze best nog niet gebruikt in een productie applicatie, maar voor onze doeleinden werkt de methode perfect.

/src/filesystem/filesystem.js
import {
    useRecoilRefresher_UNSTABLE, 
    useRecoilValue, useSetRecoilState} from 'recoil';

// Niet relevante imports weggelaten.

const Filesystem = () => {
    const folders = useRecoilValue(currentDirectoryContent);
    const setCurrentDirectoryId = useSetRecoilState(currentDirectoryId);
    const parentId = useRecoilValue(currentDirectoryParent);
    const [showNewNote, setShowNewNote] = useState(false);
    const refreshDataForCurrentDirectory =
        useRecoilRefresher_UNSTABLE(notesInCurrentDirectory);

    return <Row>
        {parentId !== undefined ?
            <Folder name=".." clickHandler={() => setCurrentDirectoryId(parentId)}/> :
            <></>
        }
        {folders.map(f => <Folder key={f.id} name={f.name} 
                                  clickHandler={() => setCurrentDirectoryId(f.id)}/>)}

        <React.Suspense fallback={<LoadingFile/>}>
            <NoteList/>
        </React.Suspense>
        <File clickHandler={() => setShowNewNote(true)}/>

        <NewNote show={showNewNote}
                 closeHandlerHook={() => setShowNewNote(false)}
                 createHandlerHook={() => {refreshDataForCurrentDirectory()}}/>
    </Row>
}

 









 
 
















 


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
Copied!

In onderstaande video is het duidelijk dat de nieuwe noties nu wel getoond worden, het is echter ook duidelijk dat alle data gerefresht wordt. De useRecoilRefresher_UNSTABLE methode invalideert alle caches voor een selector, niet enkel de cache voor een bepaalde directory.

Fig 7: Invalidatie van de selector cache via useRecoilRefresher_UNSTABLE

Selector Family

De notities moeten natuurlijk nog bewerkt kunnen worden. Hiervoor moeten we één specifieke notitie ophalen, we moeten dus een parameter toevoegen aan een selector. Hiervoor voorziet Recoil de selectorFamily functie. Deze werkt op exact dezelfde manier als een selector, maar voorziet de mogelijkheid om extra parameters door te geven aan het get attribuut van de selector. Let op, gebruikt de selectorFamily functie enkel in combinatie met parameters, zonder een parameter werkt deze niet.

/src/recoilState/notes.js
import {selector, selectorFamily} from 'recoil';

export const noteById = selectorFamily({
    key: 'noteById',
    get: id => async ({get}) => {
        return await getNote(id);
    }
})
 



 



1
2
3
4
5
6
7
8
Copied!

Vervolgens kunnen we de nieuwe selector dan gebruiken om de inhoud van een notitie op te halen in de NoteEditor component, die via suspense geladen wordt in de NoteDetail component.

/src/notes/noteEditor.js
// Imports weggelaten.
const NoteEditor = () => {
    const {id} = useParams();
    const note = useRecoilValue(noteById(id));
    
    return  <Row className={"h-100 flex-column mt-1"}>
        <Row className={"flex-grow-1"}>
            <Col sm={12} md={6} >
                <Form.Control as="textarea" className={'h-100'}
                              value={note.content}/>
            </Col>
            <Col sm={12} md={6} className={"flex-grow-1"}>
                <ReactMarkdown>
                    {note.content}
                </ReactMarkdown>
            </Col>
        </Row>
        <Col sm={12} className={'p-1 m-1 d-grid bg-light text-center'}>
            <Button>Save changes</Button>
        </Col>
    </Row>
}


 
 





 



 








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

Zoals in onderstaande video te zien is, produceert deze code, zoals verwacht, een editor en een weergave van de gerenderde markdown code. Hiervoor zijn de bibliotheken react-markdownopen in new window en react-spinneropen in new window gebruikt.

Fig 8: Resultaat na het toevoegen van de selectorFamily

atomFamily

De notities kunnen nu wel geopend worden, maar nog steeds niet bewerkt worden. De meest voor de hand liggende optie, in het notitie-object dat teruggeven wordt door de useRecoilValue hook, het content attribuut aanpassen, is niet mogelijk. Data die door een selector of atoom teruggeven wordt is steeds immutable, tenzij en setter gebruikt wordt, zoals deze die teruggegeven wordt door useSetRecoilState.

We kunnen de selector uitbreiden zodat deze ook gebruikt kan worden om de notitie te bewerken, maar dit is niet echt een oplossing omdat we nog steeds geen data hebben om bij te werken. We kunnen natuurlijk bij elke gewijzigde letter een bericht naar de server sturen en vervolgens de useRecoilRefresher_UNSTABLE hook gebruiken om de selector opnieuw uit te voeren. Dit is echter zware overkill, en zou ook niet voor een aangename user experience zorgen, want dan moeten we na elke wijziging wachten tot de nieuwe data terug geladen is vanop de server. En daarbovenop ondersteund een selector momenteel geen asynchrone updates.

De oplossing bestaat er uit om een atoom aan te maken, dat de lokale data bevat, dit atoom kunnen we zoveel aanpassen als noodzakelijk en naar de server sturen als de gebruiker op de "Save changes" knop drukt. Hier is echter ook nog steeds een probleem, we hebben verschillende notities, en dus hebben we ook verschillende atomen nodig. De functie atomFamily bied de oplossing. Deze functie geeft, op basis van een parameter, een nieuw atoom terug. Zo kunnen we voor elke geopende lokale notie de data bewaren.

/src/recoilState/notes.js
// Niet relevante code weggelaten 
export const editedNote = atomFamily({
    key: 'editedNote',
    default: undefined
})
1
2
3
4
5
Copied!

Vervolgens kunnen we de selector dan aanpassen zodat deze ook een set attribuut heeft. Merk op dat we nu de functie set gebruiken, waar we bij het attribuut get de get functie gebruiken. Dit is puur toeval, we kunnen bij zowel het set als get attribuut de get en set functies gebruiken. Voor het set attribuut is eveneens een extra parameter vereist, net zoals voor het get attribuut. Dit komt omdat we de selectorFamily functie gebruiken, als we het set attribuut zouden toevoegen aan een selector dan moest deze parameter er niet staan. Merk op dat we in het get attribuut nu ook gebruik maken van het editNote atoom.

/src/recoilState/notes.js
// Niet relevante code weggelaten
export const noteById = selectorFamily({
    key: 'noteById',
    get: id => async ({get}) => {
        return get(editedNote(id)) || await getNote(id)
    },
    set: id => ({set}, newValue) => {
        set(editedNote(id), newValue);
    }
})




 

 
 
 

1
2
3
4
5
6
7
8
9
10
Copied!

Tenslotte kunnen we de NoteEditor component aanpassen zodat we elke wijziging wegschrijven naar de selector. Dit gebeurt via de useRecoilState hook, een combinatie van useRecoilValue en useSetRecoilValue. We voegen ook een functie toe om de wijzigingen naar de server weg te schrijven.

/src/notes/noteEditor.js
import {useRecoilState} from 'recoil';

// Imports en niet relevant code weggelaten.

const NoteEditor = () => {
    const {id} = useParams();
    const [note, setNote] = useRecoilState(noteById(id));

    const updateNote = (evt) => {
        setNote({...note, content: evt.target.value});
    }

    const saveNote = (evt) => {
        upsertNote(note.title, note.folder_id, note.id, note.content);
        evt.target.blur();
    }

    return  <Row className={"h-100 flex-column mt-1"}>
        <Row className={"flex-grow-1"}>
            <Col sm={12} md={6} >
                <Form.Control as="textarea" className={'h-100'}
                              value={note.content}
                              onChange={updateNote}/>
            </Col>
            <Col sm={12} md={6} className={"flex-grow-1"}>
                <ReactMarkdown>
                    {note.content}
                </ReactMarkdown>
            </Col>
        </Row>
        <Col sm={12} className={'p-1 m-1 d-grid bg-light text-center'}>
            <Button onClick={saveNote}>Save changes</Button>
        </Col>
    </Row>
}
 





 

 
 
 

 
 
 
 






 








 



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
Copied!

Onderstaande video toont dat updates wel werken, maar dat er zich nog een significant probleem voordoet. Na elke wijziging wordt de spinner heel even getoond. Dit is een resultaat van de asynchrone methode in de selector Omdat de methode asynchroon is, zal de Suspense component de preloader tonen, maar omdat deze methode eigenlijk geen asynchroon werk doet, verdwijnt de spinner vrijwel onmiddellijk.

Fig 9: Flikkerend scherm omwille van de Suspense component

We kunnen dit probleem eenvoudig oplossen door in de selector een synchrone methode te gebruiken die een promise teruggeeft. De Suspense component zal dan de preloader tonen zolang de promise niet geresolved is.

/src/recoilState/notes.js
// Niet relevante code weggelaten.

export const noteById = selectorFamily({
    key: 'noteById',
    get: id => ({get}) => {
        return get(editedNote(id)) ||  getNote(id)
    },
    set: id => ({set}, newValue) => {
        set(editedNote(id), newValue);
    }
})




 
 
 




1
2
3
4
5
6
7
8
9
10
11
Copied!
Fig 10: Eindresultaat

Uitgewerkte lesvoorbeelden

Last Updated: 2/2/2022, 11:17:35 AM
Contributors: Sebastiaan Henau