Les 4: Context & Fetching data
Les 4: Context & Fetching data
Tijdens deze les bekijken we hoe we data kunnen ophalen via HTTP request in een React applicatie en hoe we deze kunnen verwerken bij een succesvol request, maar ook hoe we mislukte requests gracieus kunnen afhandelen. Daarnaast bespreken we hoe we prop-drilling kunnen tegengaan door middel van context.
We illustreren deze concepten aan de hand van een applicatie waarmee het weerbericht voor een willekeurige location bekeken kan worden.
Info
Voor deze les zijn geen CodeSandbox voorbeelden voorzien omdat we gebruik maken van verschillende gelimiteerde APIs en het niet mogelijk is om de code te hosten op CodeSandbox zonder dat API keys ook publiek staan.
Als je de voorbeeldcode wil uitvoeren, zal je zelf de nodige API keys moeten aanmaken, waar je dit doet, wordt verder in deze tekst besproken.
Context
Tot nu toe hebben we informatie steeds doorheen een volledige componentenboom doorgegeven. Als we, zoals in onderstaand diagram, in component B een property hebben die pas in component H nodig is, dan hebben we deze property tot nu toe door alle niveaus moeten doorgeven.
Dit is niet ideaal, de componenten D, F en G gebruiken deze property niet maar de property moet toch meegegeven worden aan deze componenten. De context API biedt hier een oplossing voor. Via context kunnen we de property X uit het voorgaande diagram eenvoudig consumeren in component H zonder dat de property doorgegeven moet worden.
Enkele goede use cases voor context zijn theming, internationalisatie, user management, routing of state management.
Voor theming en internationalisatie heb je het huidige thema, of de huidige taal enkel onderaan de boom nodig, daar waar er iets visueel getoond wordt of waar er tekst geprint wordt. Tussenliggende componenten die bijvoorbeeld over arrays itereren en wat andere componenten oproepen, maar zelf geen HTML bevatten, hebben geen nood aan de gekozen opties.
Voor routing moet de huidige URL beschikbaar zijn in alle componenten, deze info wordt, in veel gevallen, intern via context bewaard, de geïnteresseerde lezer kan de source code van React Router raadplegen.
Global state management kan voor kleine apps opgelost worden via context, voor grotere en/of complexere apps is dit meestal geen goed idee omdat een wijziging in de context alle kinderen zal re-renderen, iets wat natuurlijk minder performant is dan enkel die componenten waar een wijziging gebeurd is te re-renderen.
Internationalisatie via context
We breiden de navbar in de startbestanden uit met een menu waarmee we de weergavetaal van de website kunnen aanpassen, omdat dit slechts over een demonstratie gaat, maken we geen gebruik van een internationalisation (i18n) library, maar implementeren we de gewenste functionaliteit zelf. In de startbestanden is languages.js voorzien waarin de beschikbare talen geëxporteerd worden, daarnaast bevat home.json Engelse en Nederlandse vertalingen van de tekst die op de website moet verschijnen.
export const languages = [
{i18n: 'en', name: 'English', flag: '🇺🇸'},
{i18n: 'nl', name: 'Nederlands', flag: '🇳🇱'},
]
{
"en": {
"geocodeLoading": "Retrieving coordinates",
"selectCityLabel": "Show weather for",
"forecastTitle": "Weather Forecast",
"invalidLocationError": "Please enter a valid location before attempting to view the weather.",
"weatherError": "We couldn't retrieve the weather for you :(. Refresh the page and try again, or contact us if the error persists."
},
"nl": {
"geocodeLoading": "Coördinaten aan het ophalen",
"selectCityLabel": "Toon weer voor",
"forecastTitle": "Weerbericht",
"invalidLocationError": "Geef a.u.b. een geldige locatie in voordat je het weer probeert te bekijken.",
"weatherError": "We konden het weer niet voor u ophalen :). ververs de pagina en probeer opnieuw, of contacteer ons als het probleem zich blijft vertonen."
}
}
Context aanmaken
Begrip Context
Context is een manier om prop-drilling te voorkomen, een property kan diep in de componentenboom gebruikt worden, zonder dat de properties door de volledige boom doorgegeven moeten worden.
Om context te definiëren gebruik je de createContext functie, deze functie heeft één argument, de defaultvalue. Deze standaard waarde zal gebruikt worden als de context geconsumeerd wordt en er geen ouder is voor de component waarin dit gebeurt die een specifieke waarde toegekend heeft aan de context.
import {createContext} from 'react'
const SomeContext = createContext(defaultValue)
We creëren een context die standaard op de eerste taal in de languages array staat en waar de setter niets doet (omdat we nog geen state hebben om te updaten).
import {createContext} from 'react'
import {languages} from '../i18n/languages.js'
const LanguageContext = createContext({
selectedLanguage: languages[0],
setSelectedLanguage: (language) => {
console.warn(`No LanguageProvider found, de default implementation, which doesn't do anything, is used.`)
}
})
export default LanguageContext
Providers
De standaardwaarde, die we hierboven gedefinieerd hebben, is niet echt zinvol om rechtstreeks te consumeren. De geselecteerde taal is vastgezet op Engels en de functie om de taal te wijzigen is nog niet geïmplementeerd. We moeten een provider toevoegen om wijzigingen door te kunnen voeren.
Context Provider
Voor elke context moet er één of meer provider voorzien worden. De provider wordt rond alle componenten geplaatst die gebruik maken van de context. Een provider bied een waarde aan voor een context, voor al zijn kinderen. Als er meerdere providers in de bovenliggende componentenboom geplaatst zijn, dan wordt de dichtstbijzijnde gebruikt
import {createContext} from 'react'
const SomeContext = createContext(defaultValue)
const SomeComponent = () => {
return (
<SomeContext.Provider value={foo}>
{/* Children can use the provided value */}
</SomeContext.Provider>
)
}
Het is natuurlijk niet voldoende om enkel een provider toe te voegen, we willen de geselecteerde taal ook kunnen aanpassen, hiervoor is state nodig.
import {useState} from 'react'
import NavBarBootstrap from './navBarBootstrap.jsx'
import {Container} from 'react-bootstrap'
import Routing from './routing.jsx'
import {languages} from './i18n/languages.js'
import LanguageContext from './context/languageContext.js'
function App() {
const [selectedLanguage, setSelectedLanguage] = useState(languages[0])
return (
<LanguageContext.Provider value={{selectedLanguage, setSelectedLanguage}}>
<NavBarBootstrap/>
<Container fluid>
<Routing/>
</Container>
</LanguageContext.Provider>
)
}
export default App
useContext
Begrip: useContext
De useContext hook kan gebruikt worden om een bestaande context te consumeren. De waarde die teruggegeven wordt door deze hook is die van de dichtstbijzijnde provider, als er geen provider is wordt de default waarde gebruikt.
const SomeComponent = () => {
const contextValue = useContext(SomeContext)
return <>{/* ... */}</>
}
Door middel van de useContext hook kunnen we de waarde die via de provider in de App component aangeboden wordt gebruiken in de NavBarBootstrap component. We passen het dropdown menu aan zodat de huidige taal weergegeven wordt en zodat het dropdown menu gebruikt kan worden om de actieve taal te wijzigen.
import {useContext} from 'react'
const NavBarBootstrap = () => {
const {selectedLanguage, setSelectedLanguage} = useContext(LanguageContext)
const dropdownItem = (l) => (
<NavDropdown.Item key={l.i18n} onClick={() => setSelectedLanguage(l)}>
{l.flag} {l.name}
</NavDropdown.Item>
)
const title = `${selectedLanguage.flag} ${selectedLanguage.name}`
return (
<Navbar bg="dark" expand="lg" variant="dark">
<Container fluid>
<LinkContainer to={'/'}>
<Navbar.Brand>Data Fetching</Navbar.Brand>
</LinkContainer>
<Navbar.Toggle aria-controls="basic-navbar-nav"/>
<Navbar.Collapse className="justify-content-end me-2">
<Nav>
<NavDropdown title={title} menuVariant="dark" align="end">
{languages.map(l => dropdownItem(l))}
</NavDropdown>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
)
}
Nu we de taal kunnen wijzigen en deze wijzigingen bewaard worden, kunnen we de context tenslotte gebruiken in de Home component. We importeren op lijn 1 het JSON-bestand dat de vertalingen bevat, vervolgens gebruiken we de useContext hook om de LanguageContext te lezen. We hebben enkel de geselecteerde taal nodig, de setter is in deze component overbodig, we laten die dan ook weg uit de deconstruction syntax op lijn 5. Tenslotte halen we, op basis van de geselecteerde taal, de juiste tekst op (lijn 10 & 14)
import internationalText from '../../i18n/home.json'
import LanguageContext from '../../context/languageContext.js'
const Home = () => {
const {selectedLanguage} = useContext(LanguageContext)
const text = internationalText[selectedLanguage.i18n]
return (
<>
<h2>{text['forecastTitle']}</h2>
<Form>
<Form.Group className="mb-3">
<Form.Label>{text['selectCityLabel']}:</Form.Label>
<Form.Control type="text"/>
</Form.Group>
</Form>
</>
)
}
Onderstaande video toont hoe de taal zich aanpast als de contextwaarde gewijzigd wordt. Verder illustreert deze ook het gevaar van context, als we de waarde aanpassen, worden alle kinderen van de provider gewijzigd. Gebruik context dus enkel voor dingen die weinig gewijzigd worden. In het uitzonderlijke geval dat je veel globale state hebt, gebruik je beter een global state management library zoals Recoil, we verwijzen de geïnteresseerde lezer door naar een les uit een oude versie van deze website. Omwille van redenen die verder in deze en de volgende les duidelijk worden, behandelen we deze leerstof dit jaar niet.
Nieuwe hooks schrijven
Zelf een hook schrijven is niet bijzonder moeilijk, een hook is tenslotte niets anders dan een functie. De conventie is dat deze functie moet beginnen met use. Door code af te zonderen in een aparte hook maken we het mogelijk om deze code in verschillende componenten te gebruiken. Binnen een hook kunnen we alle gekende hooks ook gebruiken, dezelfde regels gelden als in een component.
useLanguage
Hierboven hebben we de gekozen taal opgehaald via de useContext hook, vervolgens hebben we dan een JSON-bestand geïmporteerd en hieruit de tekst in de gekozen taal opgehaald. Voor één component is dit geen probleem, maar natuurlijk wordt deze code in verschillende componenten opgeroepen. In de plaats van deze code telkens te kopiëren, zonderen we deze af in een hook die onmiddellijk het object met zinnen in de gekozen taal teruggeeft, daarnaast geeft de hook ook het ISO 639-1 van de taal (nl of en) terug omdat we dit verder zullen gebruiken om de taal van het weerbericht aan te passen. We kunnen deze hook dan gebruiken in de Home component.
import {useContext} from 'react'
import LanguageContext from '../context/languageContext.js'
import internationalText from '../i18n/home.json'
const useLanguage = () => {
const {selectedLanguage} = useContext(LanguageContext)
return {
text: internationalText[selectedLanguage.i18n],
i18n: selectedLanguage.i18n
}
}
export default useLanguage
import useLanguage from '../../hooks/useLanguage.js'
const Home = () => {
const {text} = useLanguage()
return (
<>
<h2>{text['forecastTitle']}</h2>
<Form>
<Form.Group className="mb-3">
<Form.Label>{text['selectCityLabel']}:</Form.Label>
<Form.Control type="text" value={selectedCity} onChange={setSelectedCity}/>
</Form.Group>
</Form>
<Suspense fallback={<Loading spinnerText={text['geocodeLoading']}/>}>
<Weather city={selectedCity} allowFetch={!isEditing}/>
</Suspense>
</>
)
}
useIsEditing
De startbestanden bevatten reeds een formulier dat we zullen gebruiken om de locatie in te geven waarvoor we het weerbericht willen zien. Het weerbericht wordt natuurlijk niet lokaal bewaard, maar wordt via een HTTP request gedownload van een API.
Een gebruiker moet de mogelijkheid hebben om de locatie te wijzigen, maar als developer willen we niet dat er bij elke wijziging een verzoek naar de server gestuurd wordt. Ten eerste heeft een verzoek geen zin als de gebruiker enkel 'G' heeft ingegeven in de plaats van de volledige zoekterm 'Geel' of 'Geel Belgium', ten tweede willen het aantal requests naar de API-server beperken, want elk verzoek kost geld en vertraagd de app daarbovenop omdat we meerdere requests versturen terwijl we enkel in het laatste request geïnteresseerd zijn maar alle voorgaande requests wel moeten afhandelen of annuleren. Om deze problemen te vermijden schrijven we een hook die bijhoudt of een gebruiker nog aan het typen is of niet.
De hook krijgt 2 parameters, defaultValue en debounceTime, omdat we voor deze parameters een defaultwaarde willen toevoegen, definiëren we deze in een object. Op die manier kunnen we de defaultwaarde van debounceTime overschrijven, zonder dat we ook de defaultValue parameter moeten aanpassen. Iets wat niet mogelijk is met klassieke optionele parameters, als je daar de parameter wilt aanpassen, moeten alle voorgaande optionele parameters ook een waarde krijgen.
Definitie en gebruik met klassieke optionele parameters vs object parameters
const useIsEditing = (defaultValue = '', debounceTime = 500) => {
}
// Om de debounceTime aan te passen is het
// nodig ook de defaultValue aan te passe
useIsEditing('', 250)
// De defaultValue kan aangepast worden zonder
// dat de debounceTime ook vermeld moet worden
useIsEditing('Geel Belgium')
const useIsEditing = ({defaultValue = '', debounceTime = 500} = {}) => {
}
// De debounceTime kan individueel aangepast worden,
// zonder de defaultValue mee te hoeven geven.
useIsEditing({debounceTime: 250})
// De defaultValue kan ook individueel aangepast worden.
useIsEditing({defaultValue: 'Geel Belgium'})
// Of beiden kunnen tegelijkertijd aangepast worden.
useIsEditing({defaultValue: 'Geel Belgium', debounceTime: 250})
Binnen de hook moeten we bijhouden welke waarde er momenteel in het formulier zit, en moeten we bijhouden of de gebruiker de tekst nog aan het bewerken is. Tenslotte geven we al deze zaken terug als resultaat van de functie.
const useIsEditing = ({defaultValue = '', debounceTime = 500} = {}) => {
const [value, setValue] = useState(defaultValue)
const [isEditing, setIsEditing] = useState(false)
return [
value,
setValue,
isEditing
]
}
De hook doet natuurlijk nog niet bijzonder veel, de isEditing variabele is altijd false en de state had evengoed niet in de hook kunnen staan. Om de hook nuttig te maken, moet de isEditing variabele op true gezet worden als de setValue functie opgeroepen worden en na debounceTime milliseconden terug op false
Tenslotte is het ook nodig om de timeout die isEditing terug op false zet te annuleren als de setValue methode opnieuw opgeroepen wordt. Dit blijft zich herhalen totdat de setValue methode debounceTime milliseconden niet is opgeroepen. We implementeren al deze functionaliteit door in de plaats van de setValue functie een nieuwe updateValue functie terug te geven.
Deze nieuwe hook kan vervolgens gebruikt worden in de Home component om te detecteren of de gebruiker het formulierelement aan het bewerken is of niet.
const useIsEditing = ({defaultValue = '', debounceTime = 500} = {}) => {
const [value, setValue] = useState(defaultValue)
const [isEditing, setIsEditing] = useState(false)
let timeoutId = null
const timeoutFinishedHandler = () => {
setIsEditing(false)
timeoutId = null
}
const updateValue = (evt) => {
// Als er al een timeout ingesteld was,
// moet deze geannuleerd worden.
if (timeoutId) {
clearTimeout(timeoutId)
}
setValue(evt.target.value)
// Stel isEditing op true in, en zet het nadat
// debounceTime ms verstreken zijn terug op false.
setIsEditing(true)
timeoutId = setTimeout(timeoutFinishedHandler, debounceTime)
}
return [
value,
updateValue,
isEditing
]
}
const Home = () => {
const {text} = useLanguage()
const [selectedCity, setSelectedCity, isEditing] = useIsEditing({defaultValue: 'Geel Belgium'})
return (
<>
<h2>{text['forecastTitle']}</h2>
<Form>
<Form.Group className="mb-3">
<Form.Label>{text['selectCityLabel']}:</Form.Label>
<Form.Control type="text"
value={selectedCity}
onChange={setSelectedCity}/>
</Form.Group>
</Form>
</>
)
}
useRef als persistente variabele
Bovenstaande code lijkt op het eerste zicht misschien te werken, maar als we de Home component uitbreiden met een tekst die aangeeft of de gebruiker aan het editen is of niet, wordt snel duidelijk dat de boolean isEditing constant wijzigt tussen true en false, ook als het formulier nog gewijzigd wordt.
Het probleem is de timeoutId variabele in de hook, dit is een lokale variabele die beperkt blijft tot de scope van de isEditing hook. De variabele wordt aangemaakt als de functie start en wordt weer verwijderd als de functie afgehandeld is. Deze variabele in state plaatsen is geen goede keuze omdat de variabele geen rechtstreekse invloed heeft op het UI en bijgevolg tegen het doel van state ingaat. De useRef hook bied een oplossing.
Begrip: useRef als persistente variabele
Via de useRef hook kan je een variabele persistent maken doorheen renders, i.e. tussen verschillende keren dat een functiecomponent opgeroepen wordt. De huidige waarde zit steeds in de current property van het object dat teruggegeven wordt door de useRef hook.
const SomeComponent = () => {
const persistentVariable = useRef(defaultValue)
const foo = () => {
if (persistentVariable.current === bar) {
// Do something
// Update the persistent variable
persistentVariable.current = 'baz';
}
}
return <p onClick={foo}>Some JSX code</p>
}
We kunnen via de useRef hook vervolgens het timeoutId bijhouden zodat dit tussen verschillende oproepen van de isEditing hook zijn waarde niet verliest.
const useIsEditing = ({defaultValue = '', debounceTime = 500} = {}) => {
const [value, setValue] = useState(defaultValue)
const [isEditing, setIsEditing] = useState(false)
const timeoutId = useRef(null)
const timeoutFinishedHandler = () => {
setIsEditing(false)
timeoutId.current = null
}
const updateValue = (evt) => {
// Als er al een timeout ingesteld was, moet deze geannuleerd worden.
if (timeoutId.current) {
clearTimeout(timeoutId.current)
}
setValue(evt.target.value)
// Stel isEditing op true in, en zet het nadat debounceTime ms verstreken zijn terug op false.
setIsEditing(true)
timeoutId.current = setTimeout(timeoutFinishedHandler, debounceTime)
}
return [
value,
updateValue,
isEditing
]
}
Onderstaande video demonstreert dat de hook nu een correcte waarde voor isEditing teruggeeft.
API keys
Tijdens deze les bouwen we een applicatie waarmee we de weersvoorspelling voor de komende 7 dagen kunnen bekijken. Om deze applicatie te bouwen maken we gebruik van twee APIs
- De Bing maps API wordt gebruikt om, gegeven een plaatsnaam, de coördinaten voor deze plaats op te halen. De API biedt een gratis key aan voor 125.000 transacties per kalenderjaar. Je kan een API key aanvragen via bingmapsportal.com.
- De Open Weather API wordt gebruikt om het weerbericht op te halen. Deze API biedt 1000 gratis calls per dag, je kan een key aanvragen op https://home.openweathermap.org/users/sign_up.
Environment variables
Om het onszelf gemakkelijk te maken, om alle API keys op één centrale locatie te bewaren en omdat het best-practice is, maken we gebruik van een .env file. Deze files worden niet standaard ondersteund door elk React project, onze projecten zijn aangemaakt via Vite en deze tool biedt ondersteuning voor .env files. Binnen dit bestand maken we voor elke API key een variabele, een environment variable begint met de prefix VITE_. Let op, je .env file moet in de root van je project staan.
VITE_OPEN_WEATHER_API_KEY=jouw-api-key-hier
VITE_BING_API_KEY=jouw-api-key-hier
In onze React applicaties kunnen we de variabelen vervolgens uitlezen als volgt.
const API_KEY = import.meta.env.VITE_BING_API_KEY
const API_KEY = import.meta.env.VITE_OPEN_WEATHER_API_KEY
Gevoelige API keys
Alhoewel wij dit bestand momenteel gebruiken om een API key te bewaren is dit niet veilig. Enkel API keys die bedoeld zijn om publiek beschikbaar te zijn (i.e. in een website), kunnen hier bewaard worden. De .env file wordt tenslotte mee in de production build van de applicatie gezet.
Aangezien de keys die wij hier gebruiken een vrij kleine limiet hebben, zou het beter zijn om een eigen API service op te zetten waarin we caching implementeren en eventueel zelfs de pagina's die gebruik maken van de API data op de server renderen en als HTML terugsturen. Dit kan bijvoorbeeld met een tools als Next.js of Remix.
Axios
Om te communiceren met de API, maken we gebruik van Axios. Axios is een promise based HTTP-client voor de browser en Node.js.
Axios bied enkele voordelen ten opzichte van de Fetch API. Axios serialiseerd en deserialiseerd JSON data bijvoorbeeld automatisch. Daarnaast is het via Axios eenvoudiger om zaken zoals authentication, timeouts, maximum response grootte, ... aan te passen. Tenslotte zal de promise die door Axios teruggegeven wordt een error teruggeven als het request een status code heeft die niet in het interval ligt, fetch geeft enkel een error terug als het request mislukt is omwille van netwerkproblemen. We gaan er in het vervolg van de tekst vanuit dat je bekend bent met promises en async/await als dit niet het geval is, verwijzen we door naar de appendix.
pnpm add axios
API Aanspreken
Om de URL niet in elk request volledig te moeten invoeren, definiëren we eerst de algemene basisURL voor elk request. Aangezien we in deze les maar naar één endpoint een request sturen, heeft dit weinig zin, als je meerdere requests naar eenzelfde server moet doen, wordt het nut snel zichtbaar.
const API_KEY = import.meta.env.VITE_BING_API_KEY
const client = axios.create({
baseURL: 'https://dev.virtualearth.net/REST/v1/Locations'
})
const API_KEY = import.meta.env.VITE_OPEN_WEATHER_API_KEY
const client = axios.create({
baseURL: 'https://api.openweathermap.org/data/2.5'
})
Vervolgens gebruiken we deze client om een functie te schrijven waarmee we de data kunnen ophalen. Om de coördinaten van een bepaalde locatie op te halen gebruiken we de Bing Maps API, volgens de documentatie is onderstaande URL een geldige query. Deze URL heeft één parameter, namelijk de API key, daarnaast maakt de query, i.e. de locatie waarvoor we de coördinaten willen ophalen, deel uit van de URL. In de startbestanden zijn reeds twee methodes voorzien waarmee de coördinaten uit het response object gehaald kunnen worden.
http://dev.virtualearth.net/REST/v1/Locations/{locationQuery}?&key={BingMapsKey}
De Open Weather Map API is iets complexer, naast de API key moeten we de lengtegraad, breedtegraad, taal en eenheden meegeven. De taal parameter komt overeen met het i18n attribuut vanuit de languages array.
https://api.openweathermap.org/data/2.5/onecall?lat={lat}&lon={lon}&appid={API key}&units={units}&lang={lang}
De code op lijn 8 in onderstaande functies voegt wat vertraging toe zodat we verder in een les suspense kunnen illustreren. De lijn zorgt ervoor dat de functie 500ms wacht voordat het HTTP-request verstuurd wordt.
const API_KEY = import.meta.env.VITE_BING_API_KEY
const client = axios.create({
baseURL: 'https://dev.virtualearth.net/REST/v1/Locations'
})
const fetchCoordinates = async (query) => {
await new Promise((resolve) => setTimeout(() => resolve(), 500))
return client.get(`${query}`, {
params: {
key: API_KEY
}
})
}
const API_KEY = import.meta.env.VITE_OPEN_WEATHER_API_KEY
const client = axios.create({
baseURL: 'https://api.openweathermap.org/data/2.5'
})
const getWeather = async (coordinates, lang) => {
await new Promise((resolve) => setTimeout(() => resolve(), 500))
const [latitude, longitude] = coordinates
return client.get('/onecall', {
params: {
lat: latitude,
lon: longitude,
appid: API_KEY,
units: 'metric',
lang
}
})
}
TanStack Query
TanStack Query vroeger gekend als React Query, is een library die het heel eenvoudig maakt om op een goede manier om te gaan met server data.
pnpm add @tanstack/react-query
De library houd een cache bij van alle requests, ook als de component die de query verstuurd heeft niet langer mounted (zichtbaar) is. Omwille van deze cache vervangt TanStack query, in de meeste gevallen, global-state libraries zoals Redux, MobX of Recoil, dit is dan ook de reden dat deze in de huidige versie van de cursus niet besproken worden. We verwijzen de geïnteresseerde lezer opnieuw door naar de les over Recoil die in 2021-2022 gepubliceerd is op deze website.
Naast het cache gedrag, is het via TanStack Query eenvoudig om data elke X minuten of seconden te verversen, om aan te geven hoelang de cache geldig is, om een fetch request automatisch terug uit te voeren als het tabblad met de website geselecteerd wordt en nog veel meer.
Het is mogelijk om in React netwerk requests te schrijven zonder gebruik te maken van TanStack Query, of een soortgelijke tool. De meeste tutorials die je hierover online vindt, maken gebruik van de useEffect hook. Since het voorjaar van 2022 is hier echter veel kritiek op gekomen vanuit het React team, onderstaande video gaat dieper in op de problemen met het gebruik van useEffect om data fetching te doen. De mening van het React team is momenteel dat je ofwel een framework zoals Next.js of Remix moet gebruiken of een fetching library zoals TanStack Query of SWR. Op 13/10/2022 is er een RFC gepubliceerd voor de use hook die first-class support bied voor promises en asynchrone functies in React, op het moment van schrijven is deze functie nog niet beschikbaar in de laatste stable release van React.
Stale while revalidate
TanStack Query werkt volgens het stale-while-revalidate principe. Dit betekent dat zodra het antwoord op een request binnen komt, er een timer begint te lopen, die aangeeft hoelang de data nog geldig is. De default waarde is 0 seconden, data wordt dus als stale (verouderd) beschouwd vanaf dat deze opgehaald is. Zodra een gebruiker terug focust op de pagina, of op een andere manier naar nieuwe data vraagt, wordt de data uit de cache gebruikt om de pagina zo snel mogelijk te tonen, maar er wordt ook onmiddellijk een nieuw verzoek gestuurd om de data te refreshen. Standaard blijft data 5 minuten in de cache zitten.
TanStack Query configureren
Om TanStack Query te configureren moeten de componenten die gebruik maken van de library omringd worden door een provider die een queryClient instantie aanbied. Deze provider maakt intern gebruik van context, dus we kunnen meerdere providers gebruiken in één applicatie als dit nodig blijkt, bijvoorbeeld omdat de data op bepaalde onderdelen veel sneller ververst moet worden dan op andere onderdelen van de applicatie.
We gebruiken grotendeels de basisconfiguratie en passen slechts 3 parameters aan. De suspense optie wordt op true gezet, zo kunnen we gebruik maken van een eenvoudige, declaratieve manier om loading componenten weer te geven. Als we suspense op true zetten, wordt automatisch ook de useErrorBoundary option op true gezet, omdat de API's die hiervoor nodig zijn niet gebruikt kunnen worden zonder class components of zonder een extra library te installeren, zetten we dit op false. Tenslotte zetten we de refetchOnWindowFocus optie uit in development modus. Als deze optie op true staat wordt elk request opnieuw uitgevoerd als we wisselen tussen een IDE en de browser of tussen de browser en de dev tools. In production is dit geen probleem, in development kan dit veel overbodige en dure requests veroorzaken.
// Niet relevante code & imports weggelaten.
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
useErrorBoundary: false,
refetchOnWindowFocus: import.meta.env.PROD,
}
}
})
root.render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App/>
</QueryClientProvider>
</BrowserRouter>
</StrictMode>
)
Suspense
Suspense is een relatief nieuw onderdeel van React dat gebruikt kan worden om declaratief aan te geven wat er getoond moet worden als de kinderen van de Suspense component nog aan het laden zijn.
Begrip: Suspense
Suspense component kan gebruikt worden om een fallback element te tonen terwijl data aan het laden is. Dit fallback element is dan meestal een spinner, loading animation, of iets soortgelijks. Zodra de data geladen is, wordt deze getoond. Let op, deze component is enkel beschikbaar als je een library gebruikt die hier ondersteuning voor bied. Van zodra de use hook beschikbaar is in een stabiele React versie, zou je suspense ook moeten kunnen gebruiken zonder extra libraries.
import {Suspense} from 'react'
const SomeComponent = () => {
return (
<Suspense fallback={SomeFallbackComponent}>
<SomeComponentThatLoadsDataThroughALibraryThatSupportsSuspense/>
</Suspense>
)
}
We gebruiken suspense vervolgens in de Home component en plaatsen de Suspense component rond de Weather component die al in de startbestanden aanwezig is. We geven aan deze laatste component een property allowFetch mee, die aangeeft of de gebruiker het formulier nog aan het bewerken is of niet, in het eerste geval gaan we geen nieuwe data ophalen, in het tweede geval wel. Daarnaast geven we ook de locatie waarvoor gezocht moet worden mee.
const Home = () => {
const {text} = useLanguage()
const [selectedCity, setSelectedCity, isEditing] = useIsEditing({defaultValue: 'Geel Belgium'})
return (
<>
<h2>{text['forecastTitle']}</h2>
<Form>
<Form.Group className="mb-3">
<Form.Label>{text['selectCityLabel']}:</Form.Label>
<Form.Control type="text" value={selectedCity} onChange={setSelectedCity}/>
</Form.Group>
</Form>
<Suspense fallback={<Loading spinnerText={text['geocodeLoading']}/>}>
{selectedCity !== '' && <Weather city={selectedCity} allowFetch={!isEditing}/>}
</Suspense>
</>
)
}
useQuery
De useQuery hook is zeer uitgebreid en complex, we beschrijven in deze tekst enkel de basisfunctionaliteiten. Ondanks het feit dat deze hook gebruikt kan worden in een normale component, zullen we deze afzonderen in een nieuwe hook. Zo kunnen we eenzelfde query gebruiken in meerdere pagina's of componenten en blijven onze componenten compacter en beter leesbaar.
We beginnen met het ophalen van de coördinaten van de, door de gebruiker, ingegeven plaats. Deze hook krijgt 2 parameters, de eerste parameter geeft de locatie aan waarvoor we de coördinaten willen ophalen, de tweede is optioneel en geeft aan of de query actief mag zijn. Als de gebruiker de locatie nog aan het ingeven is, kunnen we deze bijvoorbeeld op false zetten.
Binnen de nieuwe hook useGetCoordinates roepen we vervolgens de useQuery hook op, deze hook heeft 2 vaste parameters en één optionele:
Een array waarvan de elementen samen de key van de query vormen. Deze key moet uniek zijn, als 2 queries dezelfde key krijgen, worden ze als gelijk beschouwd en wordt eventueel gecachete data gebruikt.
Een functie die data ophaalt, dit kan een fetch, axios, XMLHttpRequest of nog iets anders zijn. Omdat we gebruikmaken van Axios, is wat we terugkrijgen een object waarin een property data beschikbaar is, een volledige lijst van alle properties is in de documentatie te vinden. We schrijven een asynchrone queryFunction die de data onmiddellijk uit het resultaat van de Axios call haalt.
Optionele configuratie, de lijst van mogelijke opties is te groot om hier te bespreken, we verwijzen door naar de documentatie
De useQuery hook geeft een groot object terug, we verwijzen opnieuw naar de documentatie voor de volledige lijst. Wij gebruiken verder in de les de data en isError properties, maar geven om zo flexibel mogelijk te zijn toch een kopie terug van het volledige result object.
Om de hooks zo gebruiksvriendelijk mogelijk te maken, geven we meteen de relevante data terug uit de API-response. Voor de coördinaten is hiervoor een methode voorzien in de startbestanden. Voor het weerbericht hebben we de daily property nodig zoals te lezen is in de Open Weather Map documentatie.
export const useGetCoordinates = (city, enabled = true) => {
const result = useQuery(
['geocode', city],
async () => (await fetchCoordinates(city)).data,
{
enabled: enabled && city !== ''
}
)
return {
...result,
coordinates: getCoordinatesFromResult(result?.data),
}
}
export const useGetSevenDayForecast = (coordinates, enabled = true) => {
const {i18n} = useLanguage()
const result = useQuery(
['weather', coordinates, i18n],
async () => (await getWeather(coordinates, i18n)).data,
{
enabled: enabled,
}
)
return {
...result,
sevenDayForecast: result?.data?.daily,
}
}
We kunnen deze hooks vervolgens gebruiken in de Weather component. Merk op dat we de eerste query pas activeren als de gebruiker de locatie niet meer aan het wijzigen is en de tweede query pas als de eerste succesvol afgerond is. Hiervoor gebruiken we de twee keer de negatie operator (!!), hiermee vormen we de coordinates array om naar een boolean die true zal zijn als coordinates defined is en false als coordinates undefined is.
Tenslotte hernoemen we de isError property die we van beide hooks terugkrijgen, voor de eerste hook naar geocodeError en voor de tweede hook naar weatherError.
const Weather = ({city, allowFetch}) => {
const {text} = useLanguage()
const {coordinates, isError: geocodeError} = useGetCoordinates(city, allowFetch)
const {sevenDayForecast, isError: weatherError} = useGetSevenDayForecast(coordinates, !!coordinates)
if (geocodeError || weatherError) {
return <ErrorFallback/>
}
if (!coordinates) {
return <div>{text["invalidLocationError"]}</div>
}
const weatherItem = (day) => (
<Col key={day.dt} xs={12} md={3} className="d-flex align-items-stretch">
<WeatherItem {...day}/>
</Col>
)
return (
<Row className={'mb-5'}>
{sevenDayForecast.map(weatherItem)}
</Row>
)
}
Verouderde data & caching
Coördinaten wijzigen nooit, het is dus zinloos om, voor eenzelfde locatie, meerdere keren de coördinaten op te halen. Zoals eerder vermeld, beschouwd TanStack Query data als stale (verouderd) zodra deze opgehaald is en wordt deze data dan ook ververst als de gebruiker het tabblad terug in focus brengt. Ook als de component waarin de data opgehaald wordt terug zichtbaar wordt (mounted) nadat deze even niet gerenderd was (bijvoorbeeld door navigatie naar een andere pagina), wordt de data ververst. Om de applicatie zo performant mogelijk te maken kunnen we via de staleTime property aangeven hoelang de data geldig moet blijven, we zetten deze optie op Infinity zodat de data nooit verouderd is.
Naast de staleTime property is het misschien ook een goed idee om de cacheTime optie aan te passen, standaard blijven niet-actieve queries (queries die niet gebruikt worden in een zichtbare (mounted) component), slechts 5 minuten bewaard. We kunnen deze optie ook op Infinity zetten zodat de coördinaten nooit verwijderd worden zolang de applicatie actief is.
export const useGetCoordinates = (city, enabled = true) => {
const result = useQuery(
['geocode', city],
async () => (await fetchCoordinates(city)).data,
{
staleTime: Infinity,
cacheTime: Infinity,
enabled: enabled && city !== ''
}
)
return {
...result,
coordinates: getCoordinatesFromResult(result?.data),
}
}
Refetching
Een weerbericht kan zeer snel wijzigen, daarom gebruiken we de refetchInterval property om het weerbericht elke 5 minuten opnieuw op te halen. De optie verwacht een waarde in milliseconden.
export const useGetSevenDayForecast = (coordinates, enabled = true) => {
const {i18n} = useLanguage()
const result = useQuery(
['weather', coordinates, i18n],
async () => (await getWeather(coordinates, i18n)).data,
{
enabled: enabled,
refetchInterval: 5 * 60 * 1000
}
)
return {
...result,
sevenDayForecast: result?.data?.daily,
}
}
Samenvatting & voorbeeldcode
Volledig uitgewerkte lesvoorbeelden met commentaar
layout: Slide
Een promise is een belofte dat een methode ergens in de toekomst een resultaat zal teruggeven. Het belangrijkste deel van voorgaande zin is "ergens in de toekomst", de methode voert geen onmiddellijke operatie uit. Het is mogelijk dat het een minuut duurt voor het resultaat beschikbaar is. Promises zijn asynchroon. Omdat de operaties even kunnen duren, is het geen goed idee om te wachten tot de operatie afgewerkt is. Dit zou betekenen dat de gebruiker, bijvoorbeeld, een halve minuut niets kan doen. Een zeer slechte user experience dus. Omwille van de asynchrone aard van een promise is het mogelijk voor de gebruikers om andere acties uit te voeren terwijl er gewacht wordt op het resultaat. Dit betekent dus dat tussen twee statements in een asynchrone functie, eventueel andere code uitgevoerd wordt. De flow van het programma is dus niet meer puur iteratief.
Een promise is een belofte dat er iets teruggegeven zal worden in de toekomst, wat dat iets juist kan zijn wordt bepaald door het type van de promise. Een promise heeft steeds de structuur Promise<T> waar T alle mogelijke datatypes kan zijn, een string, boolean, number, void, een custom type, ...
Een promise heeft een methode then die uitgevoerd wordt als de promise succesvol is, i.e. als er zich geen errors hebben voorgedaan tijdens het uitvoeren van de asynchrone methode. Naast de then methode is er ook een catch methode aanwezig om te reageren op error. Onderstaande code zal de tekst "Do something with the result" uitprinten.
const someAsynchronousOperation = async () => {
return "Result"
}
const callSomeAsynchronousOperation = () => {
someAsynchronousOperation()
.then(result => console.log("Do something with the result."))
.catch(error => console.error("An error occurred."));
}
callSomeAsynchronousOperation();
Als we someAsynchronousOperation() methode aanpassen naar onderstaande code zal de tekst "An error occurred" uitgeprint worden.
const someAsynchronousOperation = async () => {
throw new Error();
}
Zoals in bovenstaande methodes te zien is, geeft een asynchrone methode steeds een Promise terug, eventueel een Promise<void> als de methode geen return waarde heeft.
const someAsynchronousOperation = async () => {
throw new Error();
}
const callSomeAsynchronousOperation = async () => {
try {
const result = await someAsynchronousOperation();
console.log('Do something with the result.');
} catch (error) {
console.log("An error in the asynchronous function");
}
}
Promises kunnen aan elkaar gekoppeld worden, elke then methode geeft ook een promise terug
callSomeAsynchronousOperation()
.then(r => {
console.log('In Promise 2');
return;
})
.then(r => {
console.log('In Promise 3');
return;
})
Zoals in bovenstaande methodes te zien is, geeft een asynchrone methode steeds een Promise terug, eventueel een Promise<void> als de methode geen return waarde heeft.
De async en await keywords zijn een wrapper rond promises De callSomeAsynchronousOperation methode kan herschreven worden als een asynchrone methode op volgende manier. Merk op dat we nu een try-catch blok nodig hebben, de promise kan eventueel een error teruggeven en dit moet opgevangen worden.
const callSomeAsynchronousOperation = async () => {
try {
const result = await someAsynchronousOperation();
console.log('Do something with the result.');
} catch (error) {
console.log("An error in the asynchronous function");
}
}
Promises kunnen aan elkaar gekoppeld worden, elke then methode geeft ook een promise terug
callSomeAsynchronousOperation()
.then(r => {
console.log('In Promise 2');
return;
})
.then(r => {
console.log('In Promise 3');
return;
})