5. Mutating data & effects
Tijdens deze les bekijken we hoe we server-side state kunnen aanpassen via HTTP Request met TanStack Query. We bekijken verschillende manieren waarop data aangepast kan worden, ten eerste bespreken we hoe we de query-cache kunnen invalideren zodat data opnieuw opgehaald wordt nadat de aanpassingen succesvol toegepast zijn. Verder bekijken we hoe we optimistische kunnen gebruiken en deze kunnen terugdraaien als het optimisme ongegrond blijkt.
Naast het aanpassen van server-side data bespreken we een eenvoudige use-case van de useEffect hook.
Deze concepten worden geïllustreerd door een applicatie waarmee we door een fictief bestandsysteem kunnen navigeren en files en folders kunnen aanmaken.
Supabase
Het lesvoorbeeld gebruikt Supabase, een backend-as-a-service (BaaS) waarmee authentication, authorization, databases, blob storage en edge functions geïmplementeerd kunnen worden.
We bespreken de communicatie met deze BaaS niet, er worden functies aangeboden in de startbestanden die enkel opgeroepen moeten worden. Supabase wordt behandeld is de cursus Mobile development.
De back-end is opgezet door het docententeam en je hoeft hier dus zelf geen configuratie voor uit te voeren. Voor de oefeningen is dit eveneens het geval.
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 en kunnen niet aangepast of bekeken worden door andere gebruikers.
Het inlogscherm in de startbestanden toont, na succesvol inloggen of registreren, een melding dat je geredirect wordt. Er gebeurt echter niets, dit is geen bug, maar doelbewust gedaan. Het probleem wordt onderaan deze les besproken en opgelost.
Startbestanden
Updates
Naast de useSuspenseQuery hook die vorige les besproken is, biedt TanStack Query ook de useMutation hook aan.
Begrip: useMutation
De useMutation hook (uit TanStack Query) wordt gebruikt om data aan te passen op de server en bevat verschillende mogelijkheden om functies uit te voeren als de aanpassingen succesvol afgerond zijn, mislukken of juist beginnen.
De hook neemt een object als parameter dat één verplichte property (mutationFn) heeft en verschillende optionele properties (onMutate, onSuccess, onError, onSettled, ...). We bespreken in deze cursus enkel de meest courante properties.
In volgend codefragment wordt gebruik gemaakt van de data, error, variables en context parameters die volgende betekenis hebben:
- data: Hetgeen dat teruggegeven wordt door de mutationFn, i.e. de aangepaste data, het resultaat van een POST, PUT, PATCH of DELETE request. Het type, TData moet dus overeenkomen met de returnwaarde van de mutationFn.
- error: Een eventuele error die opgegooid wordt door de mutationFn in het geval dat deze niet succesvol afgerond kon worden. _ variables: Een object met parameters die aan de mutationFn doorgegeven worden, TVariables moet dus overeenkomen met de eerste (en enige) parameter van de mutationFn. Gebruik dus altijd een object als parameter voor de mutationFn en nooit een primitieve variabele zoals een string, number, boolean of array. _ context: Data die van de onMutate functie doorgegeven wordt naar de onSuccess, onError en onSettled functies.
const {
isError,
mutate,
data,
isSuccess,
isIdle
}: UseMutationResult<TData, TError, TVariables, TContext> = useMutation({
mutationFn: (variables: TVariables) => {
// Wordt uitgevoerd als de mutate property in de returnvalue opgeroepen wordt.
// Via deze functie wordt de data server-side aangepast.
// POST, PUT, DELETE of PATCH geen GET!
},
onMutate: (variables: TVariables): TContext => {
// Wordt opgeroepen vlak voordat de mutation functie opgeroepen is.
// Wordt meestal gebruik om optimistische updates uit te voeren.
},
onSuccess: (data: TData, variables: TVariables, context: TContext) => {
// Wordt uitgevoerd als de mutationFn zonder problemen afgerond is.
},
onError: (error: TError, variables: TVariables, context: TContext) => {
// Wordt uitgevoerd als de mutationFn met errors afgerond is.
},
onSettled: (data: TData | undefined, error: TError | undefined, variables: TVariables, context: TContext) => {
// Wordt uitgevoerd als de onSuccess en onError functies afgehandeld zijn, of
// nadat de mutation afgerond is in het geval dat onSucess en onError niet gedefinieerd zijn.
}
})De voorbeeldapplicatie bevat een bestandssysteem waardoor genavigeerd kan worden. Een ingelogde gebruiker kan een nieuwe directory aanmaken die onzichtbaar is voor andere gebruikers. De NewFolder component, waarmee dit geïmplementeerd wordt, is reeds aanwezig in de startbestanden, enkel de communicatie met de BaaS moet nog aangepast worden.
We beginnen met een hook te schrijven die een nieuwe folder toevoegt.
import {useMutation} from '@tanstack/react-query'
export const useCreateDirectory = (): UseMutationResult<IFileSystemItem, Error, CreateDirectoryParams, void> => {
return useMutation({
mutationFn: createDirectory,
})
}
interface CreateDirectoryParams {
name: string
parentId: number | null
}
const createDirectory = async ({name, parentId}: CreateDirectoryParams): Promise<IFileSystemItem> => {
// Gegeven code, buiten de scope van de cursus.
// Kan eender welk POST, PUT of PATCH request zijn.
}Vervolgens kunnen we de useCreateDirectory hook oproepen in de NewFolder component.
De useMutation hook geeft, onder anderen, ook de mutate en mutateAsync properties terug, deze functies kunnen beiden gebruikt worden om de mutatiefunctie uit te voeren die doorgegeven is aan de useMutation hook.
const NewFolder: FunctionComponent<NewFolderProps> = ({parentId}) => {
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false)
const [name, setName] = useState('')
const [showErrorMessage, setShowErrorMessage] = useState(true)
const {isError, mutate: createDirectory} = useCreateDirectory()
const closeHandler = () => {...}
const createFolder = () => {
setShowErrorMessage(true)
createDirectory({name, parentId})
closeHandler()
}
return (
<>
{/* Inhoud verborgen aangezien dit niet relevant is. */}
<Dialog open={showNewFolderModal} onOpenChange={isOpen => !isOpen && closeHandler()}>
<DialogContent>
<DialogHeader>
<DialogTitle>New folder</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-col gap-4">
{/* Inhoud verborgen aangezien dit niet relevant is. */}
<div className="flex gap-4 justify-end">
<Button variant="destructive" onClick={closeHandler}>
Cancel
</Button>
<Button disabled={name === ''} onClick={createFolder}>
Create folder
</Button>
</div>
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={isError && showErrorMessage} onOpenChange={isOpen => !isOpen && closeHandler()}>
{/* Inhoud verborgen aangezien dit niet relevant is. */}
</Dialog>
</>
)
}Onderstaande video demonstreert de huidige werking van de NewFolder component, het is duidelijk dat dit niet correct is. De nieuwe map zou moeten verschijnen zonder dat de pagina herladen moet worden.
Afwachtende updates
Om de nieuwe folder te tonen zonder de pagina te herladen moeten we de data opnieuw ophalen via de useSuspenseQuery hook die eerder gebruikt werd om de directories in te laden.
Begrip: Queries invalideren
Om data die opgehaald is door de useSuspenseQuery hook te verversen nadat deze aangepast is, moet de huidige data eerst geïnvalideerd worden.
Dit kan door een combinatie van de invalidateQueries uit de QueryClient, de onSuccess property van de useMutation hook en de useQueryClient hook waarmee de queryClient uit de dichtstbijzijnde QueryClientProvider opgehaald kan worden.
De invalidateQueries methode wordt gebruikt om één of meerdere queries te invalideren, vervolgens wordt de data opnieuw opgehaald van de server. Voor een volledige lijst van mogelijke parameters voor invalidateQueries verwijzen we door naar de documentatie.
import {useQueryClient, useSuspenseQuery, useMutation} from '@tanstack/react-query'
const useGetFoo = () => {
return useSuspenseQuery({
queryKey: ['foo'],
queryFn: getFoo
})
}
const useUpdateFoo = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateFoo,
onSucces: () => queryClient.invalidateQueries({queryKey: ['foo']})
})
}De data parameter die hieronder aan de onSuccess functie wordt meegegeven is de returnwaarde van de mutationFn. Aangezien de createDirectory functie de aangepaste rij teruggeeft kunnen we deze gebruiken om het id van de parent folder uit te lezen.
Merk op dat de queryKey die we meegeven aan de invalidateQueries methode dezelfde is als diegene die we gebruikt hebben in de useGetDirectories hook.
export const useCreateDirectory = (): UseMutationResult<IFileSystemItem, Error, CreateDirectoryParams, void> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createDirectory,
onSuccess: async data => {
await queryClient.invalidateQueries({queryKey: ['directories', data.parentId]})
},
})
}
// Reeds aanwezig in de startbestanden.
export const useGetDirectories = (parentId: string | null): UseQueryResult<IFileSystemItem[], Error> => {
return useQuery({
queryKey: ['directories', parentId],
queryFn: () => getDirectories({parentId, getFiles: false}),
})
}Efficiëntere updates
Alhoewel de code hierboven duidelijk werkt, is de invalidateQueries methode niet de beste keuze. Als gevolg van deze methode moeten alle directories opnieuw opgehaald worden, ook diegene die niet aangepast zijn. Dit is inefficiënt omdat er een extra network request uitgevoerd moet worden om alle directories opnieuw op te halen.
We weten dat de createDirectory methode de nieuwe map teruggeeft en dat deze nieuwe map doorgegeven wordt als parameter aan de onSuccess functie. Via de setQueryData functie kunnen we dit efficiënter oplossen.
Begrip: setQueryData
Via de setQueryData van de QueryClient kan data die geacht is door TanStack Query aangepast worden.
De functie heeft twee parameters, de query key en de nieuwe data. Net als bij de useState hook kan de tweede parameter een functie zijn die de oude data als argument heeft en de nieuwe data teruggeeft of kan de nieuwe waarde rechtstreeks meegegeven worden.
Alhoewel hieronder het type string gebruikt wordt als generische parameter, kan dit natuurlijk elk type zijn.
const useSetQueryExample = () => {
const queryClient = useQueryClient()
// Via vervanging
queryClient.setQueryData<string>(['foo'], 'bar')
// Via update functie
queryClient.setQueryData<string[]>(['foo'], (oldData) => oldData.map(x => x + 'bar'))
}We kunnen de useGetDirectories hook dus herschrijven naar een efficiëntere versie. Merk op dat we een controle moeten uitvoeren op de old parameter, het is mogelijk dat deze undefined is, of toch volgens de typings van TanStack Query. In de praktijk zou dit nooit mogen voorvallen tenzij de createDirectory functie zeer lang duurt (meer dan gcTime milliseconden).
export const useCreateDirectory = (): UseMutationResult<IFileSystemItem, Error, CreateDirectoryParams, void> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createDirectory,
onSuccess: data => {
queryClient.setQueryData<IFileSystemItem[]>(['directories', data.parentId], old =>
old ? [...old, data] : [data],
)
},
})
}Info
Alhoewel de setQueryData methode in veel gevallen beter is dan de invalidateQueries methode, betekent dit niet dat de invalidateQueries methode nooit gebruikt mag worden.
Bij het uitloggen van een gebruiker is het bijvoorbeeld nuttig om alle data die gelinkt is aan de gebruiker te invalideren zodat een gebruiker bij het wisselen tussen accounts geen data van de vorige gebruiker te zien krijgt.
Optimistische updates
In bovenstaande video 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. Bij zo'n update wordt de data in de cache aangepast voordat het request naar de server gestuurd wordt. We gaan er dus (optimistisch) van uit dan alles goed zal gaan.
We implementeren hieronder, stap per stap, een optimistische update voor het aanmaken van bestanden in het bestandssysteem.
Waarschuwing
Volg de stappen die hieronder staan niet blindelings voor elke soort optimistische update! Een object in de query-cache moet anders benaderd worden dan een array, een update werkt anders dan een create die weer anders is dan een delete.
Hieronder demonstreren we de algemene structuur van een optimistische update, deze blijft geldig voor de andere mogelijke operaties, maar er zijn kleine verschillen. Zorg dus dat je de structuur begrijpt en niet gewoon kopieert.
We beginnen met de onMutate functie te implementeren. Deze functie wordt uitgevoerd op het moment dat de mutationFn opgeroepen wordt en krijgt dezelfde parameter 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 toegang te krijgen tot de QueryClient. Vervolgens gebruiken we de cancelQueries methode, waaraan we de queryKey meegeven van de queries die geannuleerd moeten worden.
export const useCreateFile = (): UseMutationResult<IFileSystemItem, Error, CreateFileParams, void> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onMutate: async newFile => {
const queryKey = ['files', newFile.parentId]
await queryClient.cancelQueries({queryKey})
},
})
}Vervolgens maken we een kopie van de huidige data. Als er iets mis gaat en we de optimistische update moeten terugdraaien, hebben we deze kopie nodig.
Merk op dat we opnieuw moeten controleren of de data bestaat, net zoals bij de setQueryData methode. In een degelijk geprogrammeerde app zou het opnieuw niet mogen voorkomen dat deze data undefined is.
export const useCreateFile = (): UseMutationResult<IFileSystemItem, Error, CreateFileParams, void> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onMutate: async newFile => {
const queryKey = ['files', newFile.parentId]
await queryClient.cancelQueries({queryKey})
const oldData = queryClient.getQueryData<IFileSystemItem[]>(queryKey) ?? []
},
})
}Nu we een kopie van de oude data hebben, kunnen we de gecachete data overschrijven. Hierbij behouden we de array met alle oude files en voegen de nieuwe file hieraan toe, dit doen we opnieuw via de setQueryData methode.
Als generische parameter geven we Partial<IFileSystemItem>[] mee aan de setQueryData functie, op die manier moeten we niet elke property van een IFileSystemItem object invullen. Dit is natuurlijk gevaarlijker dan als we een geldige waarde meegeven voor elke property, maar het is niet altijd mogelijk om een goede invulling te vinden voor elke property zonder dat de server hierbij betrokken is.
Voor het id gebruiken we een willekeurig id met de 'optimistic-' prefix, vervolgens kunnen we in de UI bepaalde acties blokkeren tot de nieuwe file effectief aangemaakt is en een id heeft zonder dit prefix. Via de prefix kunnen we ook bepalen of we een loading indicator moeten tonen of niet.
export const useCreateFile = (): UseMutationResult<IFileSystemItem, Error, CreateFileParams, void> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onMutate: async newFile => {
const queryKey = ['files', newFile.parentId]
await queryClient.cancelQueries({queryKey})
const oldData = queryClient.getQueryData<IFileSystemItem[]>(queryKey) ?? []
queryClient.setQueryData<Partial<IFileSystemItem>[]>(queryKey, [
...(oldData ?? []),
{...newFile, id: 'optimistic-' + window.crypto.randomUUID(), isFile: true},
])
},
})
}Tenslotte geven we de data die in de cache zat voor de optimistische update en de gebruikte queryKey terug uit de onMutate functie. Zo kan deze data doorgegeven worden aan de onError, onSettled en onSuccess functies.
Omdat de structuur van deze data steeds dezelfde zal zijn voor alle optimistische updates (in onze applicaties), kunnen we een generische interface gebruiken om deze data te beschrijven. Merk op dat we deze nieuwe interface als laatste argument meegeven aan de UseMutationResult interface.
interface OnMutateResult<T> {
oldData: T | undefined
queryKey: QueryKey
}
export const useCreateFile = (): UseMutationResult<
IFileSystemItem,
Error,
CreateFileParams,
OnMutateResult<IFileSystemItem[]>
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onMutate: async newFile => {
const queryKey = ['files', newFile.parentId]
await queryClient.cancelQueries({queryKey})
const oldData = queryClient.getQueryData<IFileSystemItem[]>(queryKey) ?? []
queryClient.setQueryData<Partial<IFileSystemItem>[]>(queryKey, [
...(oldData ?? []),
{...newFile, id: 'optimistic-' + window.crypto.randomUUID()},
])
return {oldData, queryKey}
},
})
}Optimistische update terugdraaien
Om een update ongedaan te maken na een foutmelding is weinig code vereist. We maken gebruik van de onError functie die uitgevoerd wordt als de mutationFn stop met een error.
De onError functie heeft drie parameters. De eerste parameters is de error die door de mutationFn opgegooid is, merk op dat we hier de naam _ gebruiken. Hiermee geven we aan dat TypeScript en ESLint deze variable mogen negeren en geen foutmeldingen moeten geven omdat deze niet gebruikt wordt.
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.
Merk op dat we voor de laatste parameter een controle hebben moeten uitvoeren, dit komt omdat TanStack query de types gedefinieerd heeft als:
onError: (err: TError, variables: TVariables, onMutateResult?: TOnMutateResult) => Promise<ErrorMessage> | unknown.
Het is duidelijk dat onMutateResult optioneel is binnen deze definitie en dat TypeScript dus errors zal geven als we geen controle toevoegen.
In de onError functie gebruiken we de setQueryData functie opnieuw, dit keer om de oude data terug te plaatsen in de cache.
export const useCreateFile = (): UseMutationResult<
IFileSystemItem,
Error,
CreateFileParams,
OnMutateResult<IFileSystemItem[]>
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onMutate: async newFile => {
const queryKey = ['files', newFile.parentId]
await queryClient.cancelQueries({queryKey})
const oldData = queryClient.getQueryData<IFileSystemItem[]>(queryKey) ?? []
queryClient.setQueryData<Partial<IFileSystemItem>[]>(queryKey, [
...(oldData ?? []),
{...newFile, id: 'optimistic-' + window.crypto.randomUUID()},
])
return {oldData, queryKey}
},
onError: (_, __, onMutateResult) => {
if (onMutateResult) {
queryClient.setQueryData(onMutateResult.queryKey, onMutateResult.oldData)
}
},
})
}Mutatie afhandelen
Als laatste stap schrijven we de onSuccess functie die wordt uitgevoerd na een succesvolle mutatie. In deze functie gebruiken we de data die teruggeven wordt door de mutationFn en eventueel de oude data om de optimistische data te vervangen met de eigenlijke data uit de database.
Info
Deze laatste stap is natuurlijk niet nodig bij een delete operatie, in dat geval is de optimistische update voldoende omdat er nieuwe data aangemaakt is.
export const useCreateFile = (): UseMutationResult<
IFileSystemItem,
Error,
CreateFileParams,
OnMutateResult<IFileSystemItem[]>
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onMutate: async newFile => {
const queryKey = ['files', newFile.parentId]
await queryClient.cancelQueries({queryKey})
const oldData = queryClient.getQueryData<IFileSystemItem[]>(queryKey) ?? []
queryClient.setQueryData<Partial<IFileSystemItem>[]>(queryKey, [
...(oldData ?? []),
{...newFile, id: 'optimistic-' + window.crypto.randomUUID()},
])
return {oldData, queryKey}
},
onError: (_, __, onMutateResult) => {
if (onMutateResult) {
queryClient.setQueryData(onMutateResult.queryKey, onMutateResult.oldData)
}
},
onSuccess: (newFile, _, onMutateResult) => {
if (onMutateResult) {
queryClient.setQueryData<IFileSystemItem[]>(onMutateResult.queryKey, [...onMutateResult.oldData, newFile])
}
},
})
}Tenslotte gebruiken we de nieuwe useCreateFile hook in de NewFile component.
const NewFile: FunctionComponent<NewFileProps> = ({parentId}) => {
const [showNewNoteModal, setShowNewNoteModal] = useState<boolean>(false)
const [showErrorMessage, setShowErrorMessage] = useState<boolean>(true)
const [name, setName] = useState('')
const {isError, mutate: createFile} = useCreateFile()
const closeHandler = () => {
setName('')
setShowNewNoteModal(false)
}
const _createFile = () => {
setShowNewNoteModal(false)
createFile({name, parentId})
setShowErrorMessage(true)
setName('')
}
return (
<>
{/* Inhoud verborgen aangezien dit niet relevant is. */}
<Dialog open={showNewNoteModal} onOpenChange={isOpen => !isOpen && closeHandler}>
<DialogContent>
<DialogHeader>
<DialogTitle>New file</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-col gap-4">
{/* Inhoud verborgen aangezien dit niet relevant is. */}
<div className="flex gap-4 justify-end">
<Button variant="destructive" onClick={closeHandler}>
Cancel
</Button>
<Button disabled={name === ''} onClick={_createFile}>
Create folder
</Button>
</div>
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={isError && showErrorMessage} onOpenChange={isOpen => !isOpen && closeHandler()}>
{/* Inhoud verborgen aangezien dit niet relevant is. */}
</Dialog>
</>
)
}Effects
Hieronder bespreken we een toepassing van de useEffect hook. Er zijn veel meer toepassingen, in de cursus Mobile development worden enkele andere use-cases besproken.
De useEffect hook kan gebruikt worden om een bepaalde actie uit te voeren nadat de component gerenderd is. De actie die uitgevoerd wordt na het renderen is, in eerste instantie, bedoeld om te synchroniseren met een extern systeem. De term extern systeem bevat hier alles wat niet rechtstreeks door React beheerd wordt. Enkele voorbeelden zijn real-time data connecties, loggen van bezochte pagina's in een externe database, het aanspreken van browser API's (keydown, resize, ...), een third-party library die geen React componenten aanbiedt, ...
Intervallen en timeouts
Het gebeurt regelmatig dat we een actie elke x seconden willen herhalen of dat we enkele seconden willen wachten voordat bepaalde code uitgevoerd wordt. Bijvoorbeeld om een succesboodschap na enkele seconden te laten verdwijnen of om de gebruiker na een paar seconden door te sturen naar een andere pagina.
Het is geen goed idee om setInterval of setTimeout rechtstreeks op te roepen in een React component. Dit kan memory leaks, oneindige lussen en andere lastig op te lossen en moeilijk te detecteren bugs veroorzaken. Als het nodig is om setInterval of setTimeout te gebruiken in een React component is het meestal de beste keuze om de useEffect hook te gebruiken. Hier zijn natuurlijk uitzonderingen op, bijvoorbeeld de useDebouncedState hook die we in les 4 geschreven hebben. Op andere momenten is het noodzakelijk om de useEffect hook te gebruiken, zoals toen we in les 3 gebruik gemaakt hebben van de useCountdown hook om de gebruiker na enkele seconden te redirecten.
Als het interval of de countdown gebonden is aan een event (key, mouse, ...), dan kan het meestal zonder de useEffect hook, maar als het interval of de countdown gebonden zijn aan het renderen van de pagina, dan moet useEffect gebruikt worden.
useRedirectAfterCountdown
Momenteel word je na het registreren of inloggen in de applicatie nog niet geredirect naar de juiste pagina. Als oplossing schrijven we een nieuwe hook useRedirectAfterCountdown waarmee we na 3 seconden doorverwezen worden naar een andere pagina en natuurlijk maken we hiervoor gebruik van useEffect.
We kunnen deze hook als volgt schrijven en gebruiken, merk op dat elke functie en variabele die we gebruiken in de dependencies array (tweede parameter) geplaatst moet worden. Deze array beschrijft elke variabele die gebruikt wordt in het effect, als een van deze variabelen wijzigt, dan wordt het effect opnieuw uitgevoerd. In onderstaande code wordt het effect dus uitgevoerd op volgende momenten:
- De component die de hook gebruikt is de eerste keer gerenderd.
- De enabled variabele verandert van waarde.
- De navigate functie verandert van waarde.
- De timeout variabele verandert van waarde[1].
- De destination variabele verandert van waarde.
interface UseRedirectAfterCountdownParams {
destination: string
timeout?: number
enabled: boolean
}
const useRedirectAfterCountdown = ({destination, timeout, enabled}: UseRedirectAfterCountdownParams): void => {
timeout ??= 2000
const navigate = useNavigate()
useEffect(() => {
if (enabled) {
setTimeout(() => void navigate(destination), timeout)
}
}, [enabled, navigate, timeout, destination])
}const Login: FunctionComponent = () => {
// Niet relvante hooks wegelaten.
const {
mutate: signIn,
isPending: signingIn,
isError: isSignInError,
isSuccess: signedUp,
error: signInError,
} = useSignIn()
const {
mutate: signUp,
isPending: signingUp,
isError: isSignUpError,
isSuccess: signedIn,
error: signUpError,
} = useSignUp()
useRedirectAfterCountdown({
destination: '/filesystem',
enabled: signedUp || signedIn,
})
// Niet relevante code weggelaten.
return (
<div className="flex flex-col md:w-[75vw] lg:w-[50vw] h-[100vh] mx-auto">
{/* Niet relevante code weggelaten */}
</div>
)
}useEffect clean-up
Zoals in bovenstaande video te zien is, wordt de gebruiker nu automatisch geredirect na het inloggen, maar enkel als de gebruiker zelf geen andere link aanklikt. In de tweede inlogpoging is te zien de gebruiker geredirect wordt naar de /filesystem pagina nadat er al op de homepage gedrukt is.
Om dit probleem op te lossen is het nodig een clean-up functie toe te voegen aan de useEffect hook. Als de functie in het eerste argument van de useEffect hook een andere functie teruggeeft, dan wordt deze opgeroepen op het moment dat de component niet langer mounted (zichtbaar) is of als de component opnieuw gerenderd wordt. De perfecte plaats voor eventuele clean-up dus.
const useRedirectAfterCountdown = ({destination, timeout, enabled}: UseRedirectAfterCountdownParams): void => {
timeout ??= 2000
const navigate = useNavigate()
useEffect(() => {
if (enabled) {
const timeoutId = setTimeout(() => void navigate(destination), timeout)
return () => clearTimeout(timeoutId)
}
}, [enabled, navigate, timeout, destination])
}Na deze aanpassing is het probleem opgelost, als de gebruiker wacht wordt deze geredirect, als de gebruiker zelf op een link klikt wordt de redirect geannuleerd.
Voorbeeldcode
Uitgewerkt lesvoorbeeld met commentaar
Deze functie mag eigenlijk uit de array verwijderd worden omdat deze nooit zal veranderen, het is echter niet altijd even eenvoudig om te bepalen wanneer we zo'n functie hebben en wanneer niet. Daarom kiezen we ervoor om alle functies altijd toe te voegen aan de dependency array. ↩︎