Les 3: Single Page Applications
Les 3: Single Page Applications
Tot nu toe hebben we enkel React applicaties ontwikkeld met één pagina, dit is natuurlijk niet voldoende voor een realistische website. De meeste websites bestaan uit meerdere pagina's. Deze les bekijken we hoe we navigatie kunnen toevoegen aan een React applicatie. Daarnaast integreren we een CSS-framework in de webapplicaties.
React Router installeren
Een React project, aangemaakt via pnpm create vite, bevat de nodige bibliotheken voor routing nog niet. Om routing te integreren in een React app zijn twee nieuwe bibliotheken nodig.
De eerste bibliotheek is react-router, deze implementeert de core routing functionaliteiten, ongeacht het platform waarop de React app draait. Vervolgens is ook react-router-dom nodig. Deze bibliotheek implementeert routing specifiek voor een webapplicatie. Er bestaat een alternatief pakket react-router-native dat gebruikt kan worden om mobile applications te ontwikkelen met React Native.
Het is niet nodig om zowel react-router als react-router-dom te installeren, deze laatste bibliotheek installeert de eerste automatisch als een dependency.
pnpm add react-router-dom
React Bootstrap installeren
Bootstrap is een eenvoudig CSS-framework voor het ontwikkelen van responsieve websites, dat al bekend is uit andere vakken. Het is mogelijk om de standaard Bootstrap versie te gebruiken in een React project, maar dit is niet echt ideaal. De componenten die via pure CSS-code gebouwd worden zijn geen probleem, maar Bootstrap maakt voor verschillende componenten gebruik van JavaScript-code. Deze JavaScript is niet geschreven voor React en kan problemen geven. Daarom voorziet React Bootstrap een volledige implementatie van Bootstrap via React componenten.
pnpm add react-bootstrap
React Bootstrap steunt nog steeds op de standaard Bootstrap CSS-regels, de nodige stylesheets kunnen via pnpm geïnstalleerd worden.
pnpm add bootstrap
Vervolgens moet de Bootstrap CSS geïmporteerd worden in main.tsx.
import 'bootstrap/dist/css/bootstrap.min.css'
Navigatie met React Router
We bouwen een applicatie die 4 pagina's bevat, Home, Foo, Bar, en Class. Voor we routing kunnen implementeren moet het mogelijk zijn om naar deze pagina's te navigeren. Hiervoor creëren we een eenvoudige navigatiebalk.
import styled from 'styled-components'
import {FunctionComponent} from 'react'
const NavUL = styled.ul`
list-style: none;
`
const NavLi = styled.li`
display: inline-block;
margin: 1em;
`
const NavBarNoBootstrap: FunctionComponent = () => {
return (
<NavUL>
<NavLi><a href={'/'}>Home</a></NavLi>
<NavLi><a href={'/foo'}>Foo</a></NavLi>
<NavLi><a href={'/bar'}>Bar</a></NavLi>
<NavLi><a href={'/class'}>Class</a></NavLi>
</NavUL>
)
}
export default NavBarNoBootstrap
root.render(
<StrictMode>
<NavBarNoBootstrap/>
</StrictMode>
)
BrowserRouter
Momenteel hebben de links in de navigatiebalk nog geen effect, alles wat deze links doen is de pagina herladen. Via BrowserRouter, Routes en Route, 3 componenten die aangeboden worden door react-router-dom, kunnen we specifiëren welke componenten getoond moeten worden voor elke route.
De BrowserRouter component moet rond de volledige applicatie staat, of toch rond alle delen die invloed hebben op, of beïnvloed worden door, de routing. Een footer, die op elke pagina hetzelfde is en geen links bevat naar andere pagina's in de React applicatie, kan eventueel buiten de BrowserRouter component geplaatst worden. De navbar moet binnen deze component staan omdat deze links bevat en dus invloed heeft op de routing.
import {BrowserRouter} from 'react-router-dom'
root.render(
<StrictMode>
<BrowserRouter>
<NavBarNoBootstrap/>
</BrowserRouter>
</StrictMode>
)
Routes & Route
Begrip: Route & Routes
De Routes component bevat één of meerdere Route componenten, via de Route componenten kan aangegeven worden welke componenten getoond moeten worden voor welke route. Een Route component gebruikt de property path om de route te definiëren.
Deze componenten worden aangeboden door react-router.
import {Routes, Route} from 'react-router-dom'
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/'} element={<HomeComponent/>}/>
<Route path={'/about'} element={<AboutComponent/>}/>
<Route path={'/pricing'} element={<PricingComponent/>}/>
</Routes>
)
}
import {Route, Routes} from 'react-router-dom'
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/foo'}/>
</Routes>
)
}
Deze route wordt dus aangesproken wanneer de route '/foo' ingegeven wordt in de adresbalk. De tweede belangrijke property is element deze geeft aan welke component geladen moet worden als de opgegeven route in de adresbalk ingevuld wordt. Onderstaande code toont dus de component Foo als de route '/foo' ingegeven wordt in de adresbalk.
We gebruiken dezelfde syntax om ook de andere routes toe te voegen.
Natuurlijk moet de Routing component zelf ook nog opgeroepen worden, dit doen we vlak onder de navbar.
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/foo'} element={<Foo/>}/>
<Route path={'/bar'} element={<Bar/>}/>
<Route path={'/'} element={<Home/>}/>
<Route path={'/class'} element={<Class/>}/>
</Routes>
)
}
root.render(
<StrictMode>
<BrowserRouter>
<Container className="mt-4">
<NavBarNoBootstrap/>
<Routing/>
</Container>
</BrowserRouter>
</StrictMode>
)
In bovenstaande code gebruiken we de React Bootstrap Container component. Deze component wordt, net zoals alle andere Bootstrap componenten, individueel geïmporteerd. Dit zorgt voor een kleinere bundle, hoe kleiner de bundle, hoe sneller de website. Daarnaast gebruiken we de Bootstrap klasse mt-4 om een top-marge toe te voegen.
404 Pagina
Natuurlijk heeft elke website nood aan 404 pagina die getoond wordt in het geval een gebruiker een niet bestaande URL probeert te bezoeken. Om zo'n pad te selecteren maken we gebruik van het wildcard (*) karakter. Onderstaande route komt overeen met alle mogelijke routes. We voegen ook een link toe aan de NavBarNoBootstrap component om de 404 pagina te kunnen testen.
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/foo'} element={<Foo/>}/>
<Route path={'/bar'} element={<Bar/>}/>
<Route path={'/'} element={<Home/>}/>
<Route path={'/class'} element={<Class/>}/>
<Route path={'*'} element={<PageNotFound/>}/>
</Routes>
)
}
const NavBarNoBootstrap: FunctionComponent = () => {
return (
<NavUL>
<NavLi><a href={'/'}>Home</a></NavLi>
<NavLi><a href={'/foo'}>Foo</a></NavLi>
<NavLi><a href={'/bar'}>Bar</a></NavLi>
<NavLi><a href={'/class'}>Class</a></NavLi>
<NavLi><a href={'/thisLinkProducesA404Error'}>Error page</a></NavLi>
</NavUL>
)
}
De PageNotFound component is gegeven in de startbestanden (de useCountdown hook mag je voorlopig nog negeren, we bekijken in een volgende les hoe je deze zelf kan schrijven). Als je de 404 pagina opent, zie je voorlopig een countdown van 5 naar 0, maar als deze afgelopen is, gebeurt er nog niets.
Navigate
Om de PageNotFound pagina te laten werken, kunnen we gebruik maken van de Navigate component.
import {Navigate} from 'react-router-dom'
const PageNotFound: FunctionComponent = () => {
const countdown = useCountdown(5)
if (countdown === 0) {
return <Navigate to={'/'}/>
}
return (
<div>
<Video404 src={gif} autoPlay={true} loop={true}/>
<CounterContainer>
Redirecting to home in {countdown} seconds
</CounterContainer>
</div>
)
}
SPA Links
De routing werkt nu, alle pagina's kunnen bezocht worden. Het duurt wel te lang voor we een nieuwe pagina zien, als je het network tab opent in de developer tools van je favoriete browser, zie je dat de volledige website bij elke klik op een navigatie link opnieuw gedownload wordt van de server. Onderstaande video demonstreert dit.
Het probleem is het gebruik van anchor (<a>) tags in de NavBarNoBootstrap component. Een anchor tag is niet geschikt voor gebruik in SPA's omdat dit tag de gebruiker naar een nieuwe pagina stuurt. Een single page application bestaat uit één pagina, de content van de pagina wordt door JavaScript opgevuld. Dit betekent dat we geen nieuwe pagina willen openen, maar enkel de chunks die de inhoud van de nieuwe pagina bevatten willen downloaden, dit moet natuurlijk door react/react-router-dom afgehandeld worden.
Begrip: SPA Links
SPA links zijn links die gebruikt kunnen worden om te navigeren binnen een SPA zonder de pagina te moeten herladen.
React Router bevat een Link en NavLink component, beide componenten passen de URL in de adresbalk aan, maar doen dit zonder de pagina te herladen. Het verschil tussen de twee componenten is de opmaak. Aan een NavLink component kan via de property style of className een functie meegegeven worden die de opmaak aanpast als de link actief is. Verder is er geen verschil tussen de twee componenten. Elk van deze componenten heeft een to property die gebruikt kan worden om het pad door te geven waarnaar genavigeerd moet worden als op de link geklikt wordt.
import {Link, NavLink} from 'react-router-dom';
const textLinkExample = <Link to="The path to navigate to">Link text on the website</Link>
const navLinkExample1 = (
<NavLink to="The path to navigate to"
className={({isActive}) => isActive ? 'activeClass' : 'standardClass'}>
Link text on the website
</NavLink>
)
const navLinkExample2 = (
<NavLink to="The path to navigate to"
activeStyle={'CSSProperties object with styling'}>
Link naam op website
</NavLink>
)
De NavBarNoBootstrap component wordt dan
import {NavLink} from 'react-router-dom'
const NavBarNoBootstrap: FunctionComponent = () => {
const activeStyle: CSSProperties = {
color: '#49DE73',
}
const chooseStyle = ({isActive}: { isActive: boolean }): CSSProperties => {
return isActive ? activeStyle : {}
}
return (
<NavUL>
<NavLi>
<NavLink to={'/'} style={chooseStyle}>Home</NavLink>
</NavLi>
<NavLi>
<NavLink to={'/foo'} style={chooseStyle}>Foo</NavLink>
</NavLi>
<NavLi>
<NavLink to={'/bar'} style={chooseStyle}>Bar</NavLink>
</NavLi>
<NavLi>
<NavLink to={'/class'} style={chooseStyle}>Class</NavLink>
</NavLi>
<NavLi>
<NavLink to={'/thisLinkProducesA404Error'} style={chooseStyle}>Error page</NavLink>
</NavLi>
</NavUL>
)
}
Nu is de navigatie aanzienlijk sneller, de pagina moet niet herladen en de gebruikerservaring (UX) is beter. Onderstaande video demonstreert dit, enkel de eerste keer dat de pagina geladen wordt, zie je veel beweging in het network tab.
Navigatie met parameters
In de startbestanden is studentApi.ts te vinden, deze file exporteert twee methodes waarmee één of meerdere studenten en hun score voor een bepaald vak opgehaald kunnen worden.
import students from '../data/students.ts'
import Student from '../models/student.ts'
export const getAllStudents = (): Student[] => {
return students.map(s => ({...s}))
}
export const getStudentById = (id: number): Student | undefined => {
// Onderstaand statement is gelijk aan this.students.filter((s) => s.id === id)[0]
// met het verschil dat je hier geen IndexOutOfBounds exception kan krijgen.
// Als er geen overeenkomst gevonden is, wordt undefined teruggegeven.
return students.find((s) => s.id === id)
}
import Student from '../models/student.ts'
const students: Student[] = [
{id: 565, name: 'Annelies Gevers', grade: 'A-'},
{id: 11, name: 'Ben Pauwels', grade: 'A+'},
{id: 91, name: 'Elien Stevens', grade: 'F'},
{id: 23, name: 'David Van Mol', grade: 'D'},
{id: 4002, name: 'Paul Verstraeten', grade: 'F'},
{id: 8, name: 'Sandra Wouters', grade: 'D-'},
]
export default students
interface Student {
id: number
name: string
grade: string
}
export default Student
Elk van de studenten heeft een id, via dit id kunnen we een detail-view bouwen voor één bepaalde student. De Class component bevat al een overzicht van alle studenten, enkel de link naar de detailpagina moet nog toegevoegd worden.
Om de link toe te voegen hebben we twee opties, we kunnen een absoluut of relatief pad gebruiken.
Begrip: Relatieve en absolute paden in React Router
Een absoluut pad begint met een forward-slash (/) en bevat het volledige pad, vanaf de root.
Een relatief pad begint niet met een forward-slash, maar met het volgende deel van het pad. Stel de link is gedefinieerd in een component die zich op het pad /foo/bar bevindt, dan kunnen we in deze component een link definiëren als baz. Omdat dit pad relatief is (het bevat vooraan geen slash), zal het opgevuld worden ten opzichte van het huidige pad en wordt een gebruiker na het drukken op deze link dus naar /foo/bar/baz gestuurd.
const Class: FunctionComponent = () => {
const students = getAllStudents()
const studentItem = (s: Student): ReactElement => (
<ListGroupItem key={s.id}>
<Link to={`${s.id}`}>
{s.name}
</Link>
</ListGroupItem>
)
return (
<Card>
<Card.Header>Class</Card.Header>
<ListGroup>
{students.map(s => studentItem(s))}
</ListGroup>
<Card.Footer className="text-muted">
<Link to={'/'}>Back</Link>
</Card.Footer>
</Card>
)
}
Momenteel leidt de link nog naar de 404 pagina omdat we nog nergens gedefinieerd hebben dat er detail-route bestaat. Door in de Route component, waarin de /class route gedefinieerd, wordt een extra Route component te plaatsen kunnen we een detail-route aanmaken. De parameter, het id van de student, wordt via een dubbelpunt aangegeven.
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/foo'} element={<Foo/>}/>
<Route path={'/bar'} element={<Bar/>}/>
<Route path={'/'} element={<Home/>}/>
<Route path={'/class'} element={<Class/>}>
<Route path={':id'} element={<Student/>}/>
</Route>
<Route path={'*'} element={<PageNotFound/>}/>
</Routes>
)
}
Als we nu op een detail-link klikken, zien we de URL wel wijzigen, maar wel de detail-pagina nog niet.
Begrip: Outlet
De Outlet component wordt gebruikt om geneste routes weer te geven. Zonder deze component wordt enkel de parent route getoond en worden kinderen genegeerd.
import {FunctionComponent} from 'react'
import {Routes, Route, Outlet} from 'react-router-dom'
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/foo'} element={<Foo/>}>
<Route path={'/bar'} element={<Baz/>}/>
<Route path={':param1'} element={<Qux/>}/>
</Route>
</Routes>
)
}
const Foo: FunctionComponent = () => {
return (
<>
<h1>Foo</h1>
{/**
* De code hierboven worden altijd gerenderd,
* omdat deze in de Foo (parent) component zit.
* De Baz en Qux componenten worden nooit getoond,
* tenzij er ergens een <Outlet/> gebruikt wordt.
*/}
<Outlet/>
</>
)
}
import {Link, Outlet} from 'react-router-dom'
const Class: FunctionComponent = () => {
const students = getAllStudents()
const studentItem = (s: Student): ReactElement => (
// Niet relevant en leeg gelaten in dit fragment
)
return (
<>
<Card>
{/* Niet relevant en weggelaten in dit fragment */}
</Card>
<Outlet/>
</>
)
}

Het is duidelijk dat dit niet is wat we willen bereiken. De Outlet component staat, in dit geval, op de foute plaats. Als de Class component een statisch gedeelte had, dat gelijk was voor alle kinderen, zou Outlet hier wel gebruikt kunnen worden.
In dit geval, willen we geen statische delen tonen, maar een volledig nieuwe component voor de paden /class en /class:id. Om dit te bereiken passen we de Routing component nogmaals aan. We voegen een kind toe aan de /class route zonder een path, maar met de index property, vervolgens koppelen we hier de Class component aan. De index property duidt de default route aan, als geen van de kinderen matcht met het ingegeven pad (in de URL-balk). Het element dat gerenderd wordt door de Class component wordt tenslotte ingesteld op de Outlet component.
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/foo'} element={<Foo/>}/>
<Route path={'/bar'} element={<Bar/>}/>
<Route path={'/'} element={<Home/>}/>
<Route path={'/class'} element={<Outlet/>}>
<Route index element={<Class/>}/>
<Route path={':id'} element={<Student/>}/>
</Route>
<Route path={'*'} element={<PageNotFound/>}/>
</Routes>
)
}
useParams
Momenteel toont de Students component nog steeds een foutmelding, dit is te verwachten aangezien we de navigatieparameter nog niet uitgelezen hebben. Dit kan via de useParams hook.
Begrip: useParams
De useParams hook geeft een object terug waarin elke parameter voor de actieve route beschikbaar is onder dezelfde naam als in de Route component die de parameter definieert. Naast de actieve route zijn ook alle parameters van parent routes beschikbaar.
Navigatieparameters worden via de URL doorgegeven en zijn dus altijd strings, als je een number doorgeeft, moeten de parameter eerste geconverteerd worden..
import {FunctionComponent} from 'react'
import {Routes, Route, useParams} from 'react-router-dom'
const Routing: FunctionComponent = () => {
return (
<Routes>
<Route path={'/users'} element={<Outlet/>}>
<Route index element={<Users/>}/>
<Route path={':userId'} element={<Outlet/>}>
<Route index element={<UserDetails/>}/>
<Route path={':activityId'} element={<UserActivityDetails/>}/>
</Route>
</Route>
</Routes>
)
}
const UserActivityDetails: FunctionComponent = () => {
const {userId, activityId} = useParams()
return (
<>
...
</>
)
}
import {useParams} from 'react-router-dom'
const Student: FunctionComponent = () => {
const {id} = useParams()
const student = getStudentById(Number(id))
if (!student) {
return <div>Student could not be found</div>
}
return (
<Card>
<Card.Header>{student?.name}</Card.Header>
<Card.Body>
<Card.Text>Id: {student?.id}</Card.Text>
<Card.Text>Grade: {student?.grade}</Card.Text>
</Card.Body>
<Card.Footer>
<div>Back</div>
</Card.Footer>
</Card>
)
}
useNavigate
We maken tenslotte gebruik van de useNavigate hook om terug te gaan naar de vorige pagina.
import {useNavigate, useParams} from 'react-router-dom'
const Student: FunctionComponent = () => {
const {id} = useParams()
const student = getStudentById(Number(id))
const navigate = useNavigate()
if (!student) {
return <div>Student could not be found</div>
}
return (
<Card>
<Card.Header>{student?.name}</Card.Header>
<Card.Body>
<Card.Text>Id: {student?.id}</Card.Text>
<Card.Text>Grade: {student?.grade}</Card.Text>
</Card.Body>
<Card.Footer>
<div onClick={() => navigate(-1)}>Back</div>
</Card.Footer>
</Card>
)
}
Navigatie met Bootstrap NavBar
In de vorige voorbeelden hebben we de NavBarNoBootstrap component gebruikt. Natuurlijk willen we de navigatiebalk ook via React Bootstrap opbouwen. In de documentatie vinden we een voorbeeld dat eenvoudig aan te passen is voor onze website.
import {Container, Nav, Navbar} from 'react-bootstrap'
const NavBarBootstrap: FunctionComponent = () => {
return (
<Navbar bg="dark" expand="lg" variant="dark">
<Container fluid>
<Navbar.Brand href="/">Les 3: SPA</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav"/>
<Navbar.Collapse id="basic-navbar-nav">
<Nav>
<Nav.Link href={'/'}>Home</Nav.Link>
<Nav.Link href={'/foo'}>Foo</Nav.Link>
<Nav.Link href={'/bar'}>Bar</Nav.Link>
<Nav.Link href={'/class'}>Class</Nav.Link>
<Nav.Link href={'/thisLinkProducesA404Error'}>Error page</Nav.Link>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
)
}
root.render(
<StrictMode>
<BrowserRouter>
<NavBarBootstrap/>
<Container className="mt-4">
<NavBarNoBootstrap/>
<Routing/>
</Container>
</BrowserRouter>
</StrictMode>
)
Net zoals toen we hierboven anchor tags gebruikt hebben, wordt tijdens het navigeren met de React Bootstrap navbar, ook alles herladen. We kunnen hier de Nav.Link component echter niet zomaar vervangen met een Link of NavLink component. De Nav.Link component bevat namelijk Bootstrap klassen die niet aanwezig zijn in de react-router componenten. Om toch SPA links te gebruiken, en geen opmaak te verliezen, kunnen we een library installeren die integratie tussen react-router en react-bootstrap voorziet. We installeren ook de bijhorende TypeScript definitions zodat we type checking kunnen gebruiken.
pnpm add react-router-bootstrap
pnpm add --save-dev @types/react-router-bootstrap
import {LinkContainer} from 'react-router-bootstrap';
const NavBarBootstrap: FunctionComponent = () => {
return (
<Navbar bg="dark" expand="lg" variant="dark">
<Container fluid>
<LinkContainer to={'/'}>
<Navbar.Brand>Les 3: SPA</Navbar.Brand>
</LinkContainer>
<Navbar.Toggle aria-controls="basic-navbar-nav"/>
<Navbar.Collapse id="basic-navbar-nav">
<Nav>
<LinkContainer to={'/'}>
<Nav.Link>Home</Nav.Link>
</LinkContainer>
<LinkContainer to={'/foo'}>
<Nav.Link>Foo</Nav.Link>
</LinkContainer>
<LinkContainer to={'/bar'}>
<Nav.Link>Bar</Nav.Link>
</LinkContainer>
<LinkContainer to={'/class'}>
<Nav.Link>Class</Nav.Link>
</LinkContainer>
<LinkContainer to={'/thisLinkProducesA404Error'}>
<Nav.Link>Error page</Nav.Link>
</LinkContainer>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
)
}