Les 5: Mutating data

Sebastiaan Henauseptember 17, 2022Ongeveer 12 minuten

Les 5: Mutating data

Tijdens deze les bekijken we hoe we server-side state kunnen aanpassen via HTTP Request met react-query. We bekijken verschillende manieren waarop data aangepast kan worden, ten eerste bekijken we hoe we de react-query cache kunnen invalideren zodat data opnieuw opgehaald wordt nadat de aanpassingen succesvol toegepast zijn. Verder bekijken we hoe we optimistische kunnen toepassen en deze kunnen terugdraaien als het optimisme ongegrond blijkt. Tenslotte bekijken we hoe we in react kunnen omgaan met real-time data, i.e. data die gesynchroniseerd wordt tussen verschillende gebruikers.

Deze concepten worden geïllustreerd door een applicatie waarmee markdown notities gemaakt kunnen worden. Markdownopen in new window is een manier om tekst te schrijven met een aantrekkelijke, als zij het minimale, opmaak, zonder HTML te moeten gebruiken. Markdown wordt regelmatig gebruikt om blogs, post op social media , readmes op GitHub, documentatie, ... te schrijven.

Supabase

Als back-end maken we gebruik van Supabaseopen in new window een back-end as a service (BaaS), waarmee user authentication, databases, blob storage en edge functions geïmplementeerd kunnen worden. We bespreken de communicatie met deze BaaS niet, er worden functies aangeboden in de startbestandenopen in new window. 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, via een one-time password (OTP). Let wel op dat je de bevestigingslink opent in dezelfde browser als waar je de app aan het testen bent.

Figuur 1: Login flow via magic link

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 als in de screenshots/video's. Elke gebruiker kan folders en notities aanmaken, die zijn natuurlijk uniek voor die gebruiker.

useMutation

Naast de useQuery hook die vorige les besproken is, biedt TanStack Query ook de useMutationopen in new window hook aan. Deze hook wordt gebruikt om data aan te passen op de server. Deze hook biedt verschillende properties waarmee functies uitgevoerd kunnen worden als de aanpassingen succesvol afgerond worden, mislukken op beginnen.

De applicatie bevat een bestandssysteem waarmee de notities georganiseerd kunnen worden. Een ingelogde gebruiker kan een nieuwe directory aanmaken die niet zichtbaar is voor andere gebruikers. De NewFolder component is reeds aanwezig in de startbestanden, enkel de communicatie met de BaaS moet nog aangepast worden. We beginnen met een functie te schrijven die een nieuwe folder toevoegt.

De useMutation hook neem een object als parameter. Dit object bevat verplicht een property mutationFn, deze property bevat de functie die nodig gebruikt wordt om de server-side data aan te passen. Deze functie heeft maximaal één parameter die doorgegeven wordt aan de mutatiefunctie. Als de mutatiefunctie meerdere parameters nodig heeft, moeten deze als een object doorgegeven worden. Om geen verwarring te veroorzaken zullen we dit altijd doen, ook als we slechts één parameter hebben.

import {useMutation} from '@tanstack/react-query'

export const useCreateDirectory = () => {
    return useMutation({
        mutationFn: createDirectory
    })
}

const createDirectory = async ({name, parentId}) => {
    // Gegeven code, buiten de scope van de cursus. 
    // Kan eender welk POST request zijn. 
}




 



 



Vervolgens kunnen we de useCreateDirectory hook oproepen in de NewFolder component. De useMutation hook geeft, zoals in de documentatieopen in new window te zien is, dezelfde properties terug als de useQuery hook. Naast de reeds gekende isError, isIdle, isSuccess booleans en de data property wordt ook de mutate property teruggegeven, deze property bevat de mutatiefunctie die we eerder doorgegeven hebben aan de useMutation hook.

const NewFolder = ({currentDirId}) => {
    const [showNewFolderModal, setShowNewFolderModal] = useState(false)
    const [name, setName] = useState('')
    const [showErrorMessage, setShowErrorMessage] = useState(true)
    const {isError, mutate} = useCreateDirectory()

    const closeHandler = () => {
        setName('')
        setShowNewFolderModal(false)
    }

    const createFolder = () => {
        setShowErrorMessage(true)
        mutate({name, parentId: currentDirId})
        closeHandler()
    }

    return (
        <>
            {/* Inhoud verborgen aangezien dit niet relevant is. */} 
            <Modal show={showNewFolderModal} centered onHide={closeHandler}>
                {/* Inhoud verborgen aangezien dit niet relevant is. */} 
                <Modal.Footer>
                    <Button variant="secondary" onClick={closeHandler}>Cancel</Button>
                    <Button variant="primary" onClick={createFolder} disabled={name === ''}>
                        Create Folder
                    </Button>
                </Modal.Footer>
            </Modal>        
            <Modal show={isError && showErrorMessage} centered
                   onHide={() => setShowErrorMessage(false)}>
                {/* Inhoud verborgen aangezien dit niet relevant is. */}
            </Modal>
        </>
    )
}




 








 






















Onderstaande video demonstreert de huidige werking van de geïmplementeerde functionaliteit. Het is duidelijk dat dit nog niet ideaal is. De nieuwe map zou moeten verschijnen zonder dat de pagina herladen moet worden.

Figuur 2: Directory aanmaken - Refresh nodig

Invalidate queries

Om de nieuwe folder te tonen zonder de pagina te herladen moeten we de data opnieuw ophalen. React query beschouwd de data natuurlijk als stale (want de data is opgehaald en react query werkt via het stale while revalidate principe).

De parameter van de useMutation hook bevat een onSuccess property waaraan een functie gekoppeld kan worden die uitgevoerd wordt als de mutatie succesvol afgerond is. Binnen deze functie gebruiken we de invalidateQueriesopen in new window methode van de QueryClientopen in new window. Om een pointer naar de queryClient te krijgen, maken we gebruik van de useQueryClientopen in new window hook.

De invalidateQueries kan gebruikt worden om één of meerdere queries te invalideren. Voor een volledige lijst van mogelijke parameters verwijzen we door naar de documentatieopen in new window. In deze cursus invalideren we een query steeds met dezelfde queryKey die gebruikt is om de query te schrijven in de useQuery hook.

De data parameter die aan de onSuccess functie wordt meegegeven, is de data die teruggegeven wordt door de mutationFn. In de meeste gevallen is dit de aangepaste rij.

import {useMutation, useQueryClient} from '@tanstack/react-query'

export const useCreateDirectory = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: createDirectory,
        onSuccess: async (data) => {
            await queryClient.invalidateQueries(['directories', data.parentId])
        }
    })
}



 



 
 
 


Figuur 3: Directory aanmaken met invalidate

Optimistische updates

In bovenstaande video's was het duidelijk dat we even moeten wachten voordat we het resultaat, i.e. de nieuwe directory, zien. Een optimistische update kan hier een oplossing bieden. Dit is een update die de aanpassingen rendert voordat de query succesvol afgerond is. De gebruiker ziet dan onmiddellijk de wijzigingen, maar het is mogelijk dat de mutatie mislukt. In dat geval moeten de wijzigingen teruggedraaid worden. We implementeren de optimistische update voor de notities in het bestandssysteem.

We beginnen met de onMutate functie te implementeren. Deze functie wordt uitgevoerd op het moment dat de mutationFn opgeroepen wordt. Deze functie krijgt dezelfde parameters als de mutationFn functie.

De eerste stap is om eventuele actieve queries te annuleren, doe je dit niet, dan is het mogelijk dat de optimistische update overschreven wordt door een actieve query. Om een query te annuleren moeten we weer gebruik maken van de useQueryClient hook om een pointer naar de QueryClient op te halen, zodat we vervolgens de cancelQueries methode kunnen oproepen. We geven aan deze methode opnieuw de queryKey mee van de queries die geannuleerd moeten worden.

export const useCreateNote = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: addNewNote,
        onMutate: async newNote => {
            const queryKey = ['notes', newNote.folderId]
            await queryClient.cancelQueries({queryKey})
        },
    })
}





 
 
 
 


Vervolgens moeten we een kopie van de huidige data bewaren. Als er iets mis gaat en we de optimistische update moeten terugdraaien, hebben we deze kopie nodig om de optimistische update te overschrijven.

export const useCreateNote = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: addNewNote,
        onMutate: async newNote => {
            const queryKey = ['notes', newNote.folderId]
            await queryClient.cancelQueries({queryKey})
            const previousNotes = queryClient.getQueryData(queryKey)
        },
    })
}








 



Nu we een kopie van de oude data hebben, kunnen we de huidige gecachete data overschrijven met een optimistische update, i.e. alle oude notities en de nieuwe notitie. Let op, deze nieuwe notitie bevat nog geen id, de UI wordt dus geüpdatet, maar nog niet alle functionaliteiten zullen werken.

export const useCreateNote = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: addNewNote,
        onMutate: async newNote => {
            const queryKey = ['notes', newNote.folderId]
            await queryClient.cancelQueries({queryKey})
            const previousNotes = queryClient.getQueryData(queryKey)
            queryClient.setQueriesData(queryKey, old => [...old, newNote])
        },
    })
}









 



Tenslotte geven we de gecachete data van voor de optimistische update terug uit de onMutate functie. Deze data wordt doorgegeven aan de onError, onSettled en onSuccess functies.

export const useCreateNote = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: addNewNote,
        onMutate: async newNote => {
            const queryKey = ['notes', newNote.folderId]
            await queryClient.cancelQueries({queryKey})
            const previousNotes = queryClient.getQueryData(queryKey)
            queryClient.setQueriesData(queryKey, old => [...old, newNote])
            return {previousNotes}
        },
    })
}










 



Optimistische update annuleren

Om een update te annuleren is weinig code vereist. We maken gebruik van de onError functie die uitgevoerd wordt als er iets mis gaat in de mutationFn.

De onError functie krijgt drie parameters. De eerste parameters is de error die door de mutationFn gegooid is. De tweede parameter is opnieuw dezelfde parameter die doorgegeven werd aan de mutationFn en de onMutate functie. De laatste parameter is de data die teruggegeven werd door de onMutate functie.

We kunnen tenslotte opnieuw de setQueryData functie gebruiken om de oude data terug te plaatsen in de cache.

export const useCreateNote = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: addNewNote,
        onMutate: async newNote => {
            const queryKey = ['notes', newNote.folderId]
            await queryClient.cancelQueries({queryKey})
            const previousNotes = queryClient.getQueryData(queryKey)
            queryClient.setQueriesData(queryKey, old => [...old, newNote])
            return {previousNotes}
        },
        onError: (error, newTodo, context) => {
            queryClient.setQueryData(['notes', newTodo?.folderId], context.previousNotes)
        },
    })
}












 
 
 


Mutatie afhandelen

Als laatste stap schrijven we de onSettled functie. Deze functie wordt uitgevoerd na een succesvolle of mislukte mutatie. In deze functie invalideren we de gecachete data. In het geval dat de mutatie succesvol afgerond is, is dit nodig om het id van de nieuwe notitie in te vullen. In het geval van een mislukte mutatie, is het ook interessant om de data te verversen, als het probleem op de server zit, zullen we, door het verversen van de data, vermijden dat de gebruiker de applicatie nog kan gebruiken. In het ander geval, zijn we na het verversen van de data zeker dat het probleem bij de gebruiker zat.

Tenslotte koppelen we deze mutatie aan de NewFile component.

export const useCreateNote = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: addNewNote,
        onMutate: async newNote => {
            const queryKey = ['notes', newNote.folderId]
            await queryClient.cancelQueries({queryKey})
            const previousNotes = queryClient.getQueryData(queryKey)
            queryClient.setQueriesData(queryKey, old => [...old, newNote])
            return {previousNotes}
        },
        onError: (error, newTodo, context) => {
            queryClient.setQueryData(['notes', newTodo?.folderId], context.previousNotes)
        },
        onSettled: async (data) => {
            await queryClient.invalidateQueries(['notes', data?.folderId])
        },
    })
}















 
 
 


Figuur 4: Optimistische updates

Begrip: useMutation

De useMutationopen in new window wordt gebruikt om data aan te passen op de server. Deze hook biedt verschillende properties waarmee functies uitgevoerd kunnen worden als de aanpassingen succesvol afgerond worden, mislukken op beginnen.

const {isError, mutate, data, isSuccess, isIdle} = useMutation({
    mutationFn: (params) => {
        // Methode wordt uitgevoerd als de mutate property in de return value gecalled wordt.
    },
    onMutate: (params) => {
        // Methode wordt door de mutationFn uitgevoerd als de mutationFn opgeroepen wordt. 
        // De parameters zijn exact dezelfde als diegene die aan de mutationFn doorgegeven worden.
        return context // Object met data die aan de volgende functie doorgegeven worden.
    },
    onSuccess: (data, params, context) => {
        // Wordt uitgevoerd als de mutationFn zonder problemen afgerond is.
        // De data is hetgeen dat teruggeven wordt door de mutationFn. 
        // De params zijn dezelfde parameters als diengene die doorgegeven worden aan mutationFn en onMutate.
        // De context is hetgeen dat terugegeven werd door de onMutate functie.
    },
    onError: (error, params, context) => {
        // Wordt uitgevoerd als de mutationFn met errors uitgevoerd is.
        // De error parameter bevat de foutmelding die door de mutationFn gegooid werd. 
        // De params zijn dezelfde parameters als diengene die doorgegeven worden aan mutationFn en onMutate.
        // De context is hetgeen dat terugegeven werd door de onMutate functie.        
    }
})

useRef & useEffect voor DOM manipulatie

Momenteel moet de gebruiker eerst het formulierelement selecteren in het modaal venster waarmee de naam van een folder of notitie ingegeven kan worden. Dit is niet echt gebruiksvriendelijk. Via de useRef hook kunnen we de DOM aanspreken en het formulierelement in focus brengen.

Begrip: useRef

De useRefopen in new window hook kan gebruikt worden om een pointer naar een specifiek HTML element te verkrijgen. De useRef hook is het React alternatief voor de document.getElementById methode die gekend is uit klassieke JavaScript.

const SomeComponent = () => {
    const htmlRef = useRef()

    return (
        <div>
            <ul ref={htmlRef}></ul>
        </div>
    )
}

Deze hook is niet voldoende om het formulier in focus te brengen. Na elke render wordt het formulier opnieuw getekend, we moeten dus na elke render opnieuw de code uitvoeren om het formulier in focus te brengen. React voorziet de useEffect hook om code na een render uit te voeren.

Begrip: useEffect

De callback functie van de useEffectopen in new window hook wordt uitgevoerd na elke render.

const SomeComponent = () => {

    useEffect(() => {
        // Methode wordt uitgevoerd na elke render.
    })

    return <></>
}

De useEffect hook kan verder uitgebreid worden met een dependency array. Deze array bevat alle verschillende variabelen die invloed hebben op de callback methode (eerste argument). Als één van de elementen in de dependency array wijzigt, wordt de hook opnieuw uitgevoerd, in de plaats van na elke render.

const SomeComponent = () => {

    useEffect(() => {
        // Methode wordt uitgevoerd na elke render.
    }, [dependencyVar1, dependencyVar2, ...])

    return <></>
}

We kunnen deze twee hooks gebruiken om het formulier in de NewFolder en NewFile componenten in focus te brengen als het modaal venster zichtbaar gemaakt wordt.

import {useEffect, useRef, useState} from 'react'

const NewFile = ({currentDirId}) => {
    const [showNewNoteModal, setShowNewNoteModal] = useState(false)
    const [showErrorMessage, setShowErrorMessage] = useState(true)
    const [title, setTitle] = useState('')
    const {mutate, isError} = useCreateNote()
    const formRef = useRef()

    useEffect(() => {
        if (showNewNoteModal) {
            formRef.current?.focus()
        }
    }, [showNewNoteModal])

    const closeHandler = () => { // ... }

    const createFile = () => { // ... }

    return (
        <>
            {/* Inhoud verborgen aangezien dit niet relevant is. */}
            <Modal show={showNewNoteModal} centered onHide={closeHandler}>
                {/* Inhoud verborgen aangezien dit niet relevant is. */}
                <Modal.Body>
                    <p>Please enter a title for the new markdown note.</p>
                    <Form.Group className="mb-3">
                        <Form.Control type="text" placeholder="The title of the new note"
                                      value={title} 
                                      ref={formRef}
                                      onChange={(evt) => setTitle(evt.target.value)}/>
                    </Form.Group>
                </Modal.Body>
                {/* Inhoud verborgen aangezien dit niet relevant is. */}
            </Modal>
            {/* Inhoud verborgen aangezien dit niet relevant is. */}
        </>
    )
}
 






 

 
 
 
 
 















 









useEffect voor real-time data

De startbestanden bevatten reeds een pagina waarmee de notities bekeken kunnen worden. De detailpagina (editor) moet nog uitgebouwd worden. We willen op deze pagina real-time data tonen, dit betekent dat als we de pagina meerdere keren openen, of in een toekomstige versie van de applicatie de optie toevoegen om notities te delen met andere gebruikers, we de aanpassingen op elk scherm willen zien, bij elke gebruiker.

Natuurlijk zouden we elke seconde de data opnieuw kunnen ophalen via de refetchInterval parameter die in de vorige les besproken is, maar dit is niet efficient. Om data te synchroniseren tussen verschillende gebruikers, is het veel efficiënter als de server de nieuwe data naar de gebruikers stuurt dan dat we voor elke gebruiker elke seconde een verzoek naar de server sturen. In de startbestanden is een functie fetchNoteChangesInRealTime voorzien die een abonnement opent op de wijzigingen voor een bepaalde notitie. Deze functie vraagt als tweede argument een functie die opgeroepen wordt telkens er een wijziging gebeurt in het de database.

Om data te synchroniseren tussen verschillende bronnen, kunnen we geen gebruik maken van TanStack query, maar moeten we gebruik maken van de useEffect hook. Hierboven hebben we de useEffect hook gebruikt om een DOM-element in focus te brengen omdat we via de dependency array kunnen aangeven wanneer deze hook uitgevoerd wordt, is dit ideaal om de data op te halen op het moment dat de pagina gerenderd is.

Om locale updates niet te overschrijven, voegen we een controle toe die bijhoudt wanneer de laatste update gebeurd is door de gebruiker. Omdat dit geen invloed heeft op de update, bewaren we dit moment via de useRef hook.

const Editor = () => {
    const {id} = useParams()
    const [note, setNote] = useState({})
    const {mutate, isError} = useUpdateNote()
    const updatedAt = useRef(0)

    useEffect(() => {
        fetchNoteChangesInRealTime({
            id,
            handleChange: (updatedNote) => {
                if (updatedNote.updatedAt > updatedAt.current) {
                    setNote(updatedNote)
                }
            }
        })
    }, [id])

    if (!note?.id) {
        return <LoadingPage/>
    }

    const updateContent = (evt) => {
        const now = Date.now()
        const newNote = {...note, content: evt.target.value, updatedAt: now}
        setNote(newNote)
        updatedAt.current = now
        mutate({newNote})
    }

    return (
        <StyledContainer className="d-flex flex-column vh-100">
            <Row className="h-100 flex-grow-1" style={{marginTop: '5em'}}>
                <Col xs={12} md={6} className="border-end border-1">
                    <StyledTextarea className="h-100" value={note?.content}
                                    onChange={updateContent}/>
                </Col>
                <Col className="flex-grow-1" xs={12} md={6}>
                    <MarkdownPreview source={note?.content} 
                                     warpperElement={{'data-color-mode': 'light'}}/>
                </Col>
            </Row>
        </StyledContainer>
    )
}




 

 
 
 
 
 
 
 
 
 
 








 
 


















Onderstaande video demonstreert dat de data tussen verschillende gebruikers gedeeld wordt.

Figuur 5: Real-time data

useEffect clean-up

Alhoewel de data gesynchroniseerd wordt, is er nog een groot probleem met de huidige manier van werken. Natuurlijk is de applicatie nog te eenvoudig en wordt er nergens gegarandeerd dat meerdere gebruikers dezelfde data niet op hetzelfde moment kunnen aanpassen, zoals in office 365. Dit valt echter buiten de scope van deze cursus.

Als we een log statement toevoegen aan de handleChange functie, zien we duidelijk een probleem (onderstaande video).

const Editor = () => {
    const {id} = useParams()
    const [note, setNote] = useState({})
    const {mutate, isError} = useUpdateNote()
    const updatedAt = useRef(0)
    
    // Niet relevante code weggelaten. 
    
    useEffect(() => {
        fetchNoteChangesInRealTime({
            id,
            handleChange: (updatedNote) => {
                if (updatedNote.updatedAt > updatedAt.current) {
                    setNote(updatedNote)
                }
                console.log('HandleChange has been called')
            }
        })
    }, [id])
    
    return (
        <StyledContainer className="d-flex flex-column vh-100">
            {/* Weggelaten omdat dit niet relevant is. */}
        </StyledContainer>
    )
}
Figuur 6: Memory leak

Als we de editor afsluiten zien we, telkens de andere gebruiker een wijziging doet, een log statement. Dit is een memory leak, niet gebruikte data blijft nog steeds in het geheugen zitten, de subscription blijft voort bestaan. Als het om één memory leak gaat, is het niet echt een probleem, als we de applicatie langer laten openstaan en meerdere notities openen, kan dit performance problemen geven. Hoe snel dit gebeurt, hangt af van de hoeveelheid RAM op de machine van de gebruiker (en hoeveel RAM er al in gebruikt is).

De useEffect kan een functie teruggeven waarmee clean-up code uitgevoerd kan worden. Deze clean-up code doet normaliter het tegenovergestelde als de rest van de code in de useEffect. In onderstaande code wordt gebruik gemaakt van het subscription object dat teruggeven wordt door de supabase subscribe functie. Dit is slechts een voorbeeld, in andere situaties moet de clean-up functie natuurlijk iets anders doen.

const Editor = () => {
    const {id} = useParams()
    const [note, setNote] = useState({})
    const {mutate, isError} = useUpdateNote()
    const updatedAt = useRef(0)
    
    // Niet relevante code weggelaten. 
    
    useEffect(() => {
        const subscription = fetchNoteChangesInRealTime({
            id,
            handleChange: (updatedNote) => {
                if (updatedNote.updatedAt > updatedAt.current) {
                    setNote(updatedNote)
                }
                console.log('HandleChange has been called')
            }
        })
        return () => subscription.unsubscribe()
    }, [id])
    
    return (
        <StyledContainer className="d-flex flex-column vh-100">
            {/* Weggelaten omdat dit niet relevant is. */}
        </StyledContainer>
    )
}









 








 








Onderstaande video demonstreert dat het memory leak niet langer een probleem is.

Figuur 7: Geen memory leak

Samenvatting & voorbeeldcode

Volledig uitgewerkte lesvoorbeelden met commentaaropen in new window