Siirry sisältöön

a

React Router

Kurssin seitsemännen osan tehtävät poikkeavat jossain määrin muiden osien tehtävistä. Tässä ja seuraavassa luvussa on normaaliin tapaan luvun teoriaan liittyviä tehtäviä.

Tämän ja seuraavan luvun tehtävien lisäksi seitsemäs osa sisältää kertaavan ja soveltavan tehtäväsarjan, jossa laajennetaan osissa 4 ja 5 tehtyä Bloglist-sovellusta.

Sovelluksen navigaatiorakenne

Palataan osan 6 jälkeen jälleen Reduxittoman Reactin pariin.

On erittäin tyypillistä, että web-sovelluksissa on navigaatiopalkki, jonka avulla on mahdollista vaihtaa sovelluksen näkymää. Muistiinpanosovelluksemme voisi sisältää pääsivun

fullstack content

ja omat sivunsa muistiinpanojen ja käyttäjien tietojen näyttämiseen:

fullstack content

Vanhan koulukunnan web-sovelluksessa sovelluksen näyttämän sivun vaihto tapahtui siten, että selain teki palvelimelle uuden HTTP GET ‑pyynnön ja renderöi sitten palvelimen palauttaman, uutta näkymää vastaavan HTML-koodin.

Single page appeissa taas ollaan todellisuudessa koko ajan samalla sivulla, ja selaimessa suoritettava JavaScript-koodi luo illuusion eri "sivuista". Jos näkymää vaihdettaessa tehdään HTTP-kutsuja, niiden avulla haetaan ainoastaan JSON-muotoista dataa, jota uuden näkymän näyttäminen ehkä edellyttää.

Navigaatiopalkki ja useita näkymiä sisältävä sovellus on helppo toteuttaa Reactilla.

Seuraavassa on eräs tapa:

import React, { useState }  from 'react'
import ReactDOM from 'react-dom/client'

const Home = () => (
  <div> <h2>TKTL notes app</h2> </div>
)

const Notes = () => (
  <div> <h2>Notes</h2> </div>
)

const Users = () => (
  <div> <h2>Users</h2> </div>
)

const App = () => {
  const [page, setPage] = useState('home')

 const  toPage = (page) => (event) => {
    event.preventDefault()
    setPage(page)
  }

  const content = () => {
    if (page === 'home') {
      return <Home />
    } else if (page === 'notes') {
      return <Notes />
    } else if (page === 'users') {
      return <Users />
    }
  }

  const padding = {
    padding: 5
  }

  return (
    <div>
      <div>
        <a href="" onClick={toPage('home')} style={padding}>
          home
        </a>
        <a href="" onClick={toPage('notes')} style={padding}>
          notes
        </a>
        <a href="" onClick={toPage('users')} style={padding}>
          users
        </a>
      </div>

      {content()}
    </div>
  )
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />)

Jokainen näkymä on siis toteutettu omana komponenttinaan, ja sovelluksen tilassa page pidetään tieto siitä, mitä näkymää vastaava komponentti menupalkin alla näytetään.

Menetelmä ei kuitenkaan ole optimaalinen. Kuten kuvista näkyy, sivuston osoite pysyy samana vaikka välillä ollaankin eri näkymässä. Jokaisella näkymällä tulisi kuitenkin olla oma osoitteensa, jotta esim. kirjanmerkkien tekeminen olisi mahdollista. Myöskään selaimen back-painike ei toimi sovelluksessamme loogisesti, eli back ei vie edelliseksi katsottuun sovelluksen näkymään vaan jonnekin ihan muualle. Jos sovellus kasvaisi suuremmaksi ja siihen haluttaisiin vaikkapa jokaiselle käyttäjälle sekä muistiinpanolle oma yksittäinen näkymänsä, itse koodattu reititys eli sivuston navigaationhallinta menisi turhan monimutkaiseksi.

React Router

Reactissa on onneksi olemassa kirjasto React Router, joka tarjoaa erinomaisen ratkaisun React-sovelluksen navigaation hallintaan.

Muutetaan yllä oleva sovellus käyttämään React Routeria. Asennetaan React Router:

npm install react-router-dom

React Routerin tarjoama reititys saadaan käyttöön muuttamalla sovellusta seuraavasti:

import {
  BrowserRouter as Router,
  Routes, Route, Link
} from 'react-router-dom'

const App = () => {
  const padding = {
    padding: 5
  }

  return (
    <Router>
      <div>
        <Link style={padding} to="/">home</Link>
        <Link style={padding} to="/notes">notes</Link>
        <Link style={padding} to="/users">users</Link>
      </div>

      <Routes>
        <Route path="/notes" element={<Notes />} />
        <Route path="/users" element={<Users />} />
        <Route path="/" element={<Home />} />
      </Routes>

      <div>
        <i>Note app, Department of Computer Science 2023</i>
      </div>
    </Router>
  )
}

Reititys eli komponenttien ehdollinen, selaimen URL:iin perustuva renderöinti otetaan käyttöön sijoittamalla komponentteja Router-komponentin lapsiksi eli Router-tagien sisälle.

Huomaa, että vaikka komponenttiin viitataan nimellä Router, kyseessä on BrowserRouter, sillä importtaus tapahtuu siten, että importattava olio uudelleennimetään:

import {
  BrowserRouter as Router,  Routes, Route, Link
} from 'react-router-dom'

Manuaalin mukaan

BrowserRouter is a Router that uses the HTML5 history API (pushState, replaceState and the popstate event) to keep your UI in sync with the URL.

Normaalisti selain lataa uuden sivun osoiterivillä olevan URL:n muuttuessa. HTML5 history API:n avulla BrowserRouter kuitenkin mahdollistaa sen, että selaimen osoiterivillä olevaa URL:ia voidaan käyttää React-sovelluksen sisäiseen "reitittämiseen", eli vaikka osoiterivillä oleva URL muuttuu, sivun sisältöä manipuloidaan ainoastaan JavaScriptillä, eikä selain lataa uutta sisältöä palvelimelta. Selaimen toiminta back- ja forward-toimintojen ja kirjanmerkkien tekemisen suhteen on kuitenkin intuitiivista eli toimii kuten perinteisillä verkkosivuilla.

Routerin sisälle määritellään selaimen osoiteriviä muokkaavia linkkejä komponentin Link avulla. Esim.

<Link to="/notes">notes</Link>

luo sovellukseen linkin, jonka teksti on notes ja jonka klikkaaminen vaihtaa selaimen osoiteriville URL:ksi /notes.

Selaimen URL:iin perustuen renderöitävät komponentit määritellään komponentin Route avulla. Esim.

<Route path="/notes" element={<Notes />} />

määrittelee, että jos selaimen osoiteena on /notes, renderöidään komponentti Notes.

URL:iin perustuen renderöitävät komponentit on sijoitettu Routes-komponentin lapsiksi:

<Routes>
  <Route path="/notes" element={<Notes />} />
  <Route path="/users" element={<Users />} />
  <Route path="/" element={<Home />} />
</Routes>

Routes saa aikaan sen, että renderöitävä komponentti on se, jonka path vastaa osoiterivin polkua.

Parametrisoitu route

Tarkastellaan sitten hieman modifioitua versiota edellisestä esimerkistä. Esimerkin koodi kokonaisuudessaan on täällä.

Sovellus sisältää nyt viisi eri näkymää, joiden näkyvyyttä kontrolloidaan routerin avulla. Edellisestä esimerkistä tuttujen komponenttien Home, Notes ja Users lisäksi mukana on kirjautumisnäkymää vastaava Login ja yksittäisen muistiinpanon näkymää vastaava Note.

Home ja Users ovat kuten aiemmassa esimerkissä. Notes on hieman monimutkaisempi. Se renderöi propseina saamansa muistiinpanojen listan siten, että jokaisen muistiinpanon nimi on klikattavissa:

fullstack content

Nimen klikattavuus on toteutettu komponentilla Link ja esim. muistiinpanon, jonka id on 3 nimen klikkaaminen aiheuttaa selaimen osoitteen arvon päivittymisen muotoon notes/3:

const Notes = ({notes}) => (
  <div>
    <h2>Notes</h2>
    <ul>
      {notes.map(note =>
        <li key={note.id}>
          <Link to={`/notes/${note.id}`}>{note.content}</Link>        </li>
      )}
    </ul>
  </div>
)

Parametrisoitu URL määritellään komponentissa App olevaan reititykseen seuraavasti:

<Router>
  // ...

  <Routes>
    <Route path="/notes/:id" element={<Note notes={notes} />} />    <Route path="/notes" element={<Notes notes={notes} />} />   
    <Route path="/users" element={user ? <Users /> : <Navigate replace to="/login" />} />
    <Route path="/login" element={<Login onLogin={login} />} />
    <Route path="/" element={<Home />} />      
  </Routes>
</Router>

Yksittäisen muistiinpanon näkymän renderöivä route siis määritellään "Expressin tyyliin" merkkaamalla reitin parametrina oleva osa merkinnällä :id näin:

<Route path="/notes/:id" element={<Note notes={notes} />} />

Kun selain siirtyy muistiinpanon yksilöivään osoitteeseen, esim. /notes/3, renderöidään komponentti Note:

import {
  // ...
  useParams} from 'react-router-dom'

const Note = ({ notes }) => {
  const id = useParams().id  const note = notes.find(n => n.id === Number(id))
  return (
    <div>
      <h2>{note.content}</h2>
      <div>{note.user}</div>
      <div><strong>{note.important ? 'important' : ''}</strong></div>
    </div>
  )
}

Komponentti Note saa parametrikseen kaikki muistiinpanot propsina notes ja se pääsee URL:n yksilöivään osaan eli näytettävän muistiinpanon id:hen käsiksi React Routerin funktion useParams avulla.

useNavigate

Sovellukseen on myös toteutettu yksinkertainen kirjautumistoiminto. Jos sovellukseen ollaan kirjautuneena, talletetaan tieto kirjautuneesta käyttäjästä komponentin App tilaan user.

Mahdollisuus Login-näkymään navigointiin renderöidään menuun ehdollisesti:

<Router>
  <div>
    <Link style={padding} to="/">home</Link>
    <Link style={padding} to="/notes">notes</Link>
    <Link style={padding} to="/users">users</Link>
    {user      ? <em>{user} logged in</em>      : <Link style={padding} to="/login">login</Link>    }  </div>

  // ...
</Router>

Jos käyttäjä on kirjautunut, renderöidäänkin linkin login sijaan kirjautuneen käyttäjän käyttäjätunnus:

fullstack content

Kirjautumisesta huolehtivan komponentin koodi on seuraava:

import {
  // ...
  useNavigate} from 'react-router-dom'

const Login = (props) => {
  const navigate = useNavigate()
  const onSubmit = (event) => {
    event.preventDefault()
    props.onLogin('mluukkai')
    navigate('/')  }

  return (
    <div>
      <h2>login</h2>
      <form onSubmit={onSubmit}>
        <div>
          username: <input />
        </div>
        <div>
          password: <input type='password' />
        </div>
        <button type="submit">login</button>
      </form>
    </div>
  )
}

Mielenkiintoista komponentissa on React Routerin funktion useNavigate käyttö. Funktion avulla on mahdollista selaimen osoiterivin muokkaaminen ohjelmallisesti.

Kirjautumisen yhteydessä suoritettava komento navigate('/') saa aikaan sen, että selaimen osoiteriville tulee osoitteeksi / ja sovellus renderöi osoitetta vastaavan komponentin Home.

Käyttämämme React Router ‑kirjaston funktiot useParams ja useNavigate ovat molemmat hook-funktiota samaan tapaan kuin esim. moneen kertaan käyttämämme useState ja useEffect. Kuten muistamme osasta 1, hook-funktioiden käyttöön liittyy tiettyjä sääntöjä.

Uudelleenohjaus

Näkymän Users routeen liittyy vielä eräs mielenkiintoinen detalji:

<Route path="/users" element={user ? <Users /> : <Navigate replace to="/login" />} />

Jos käyttäjä ei ole kirjautuneena, ei renderöidäkään näkymää Users vaan sen sijaan uudelleenohjataan käyttäjä komponentin Navigate avulla kirjautumisnäkymään:

<Navigate replace to="/login" />

Todellisessa sovelluksessa olisi kenties parempi olla kokonaan näyttämättä navigaatiovalikossa kirjautumista edellyttäviä näkymiä jos käyttäjä ei ole kirjautunut sovellukseen.

Seuraavassa vielä komponentin App koodi kokonaisuudessaan:

const App = () => {
  const [notes, setNotes] = useState([
    // ...
  ])

  const [user, setUser] = useState(null) 

  const login = (user) => {
    setUser(user)
  }

  const padding = {
    padding: 5
  }

  return (
    <div>
    <Router>
      <div>
        <Link style={padding} to="/">home</Link>
        <Link style={padding} to="/notes">notes</Link>
        <Link style={padding} to="/users">users</Link>
        {user
          ? <em>{user} logged in</em>
          : <Link style={padding} to="/login">login</Link>
        }
      </div>

      <Routes>
        <Route path="/notes/:id" element={<Note notes={notes} />} />  
        <Route path="/notes" element={<Notes notes={notes} />} />   
        <Route path="/users" element={user ? <Users /> : <Navigate replace to="/login" />} />
        <Route path="/login" element={<Login onLogin={login} />} />
        <Route path="/" element={<Home />} />      
      </Routes>
    </Router>      
      <div>
        <br />
        <em>Note app, Department of Computer Science 2023</em>
      </div>
    </div>
  )
}

Komponentti renderöi myös kokonaan komponentin Router ulkopuolella olevan web-sovelluksille tyypillisen footer-elementin eli sivuston pohjalla olevan osan, joka on näkyvillä riippumatta siitä, mikä komponentti sovelluksen reititetyssä osassa näytetään.

Parametrisoitu route revisited

Sovelluksessa on eräs hieman ikävä seikka. Komponentti Note saa propseina kaikki muistiinpanot, vaikka se näyttää niistä ainoastaan sen, jonka id vastaa URL:n parametrisoitua osaa:

const Note = ({ notes }) => { 
  const id = useParams().id
  const note = notes.find(n => n.id === Number(id))
  // ...
}

Olisiko sovellusta mahdollista muuttaa siten, että Note saisi propsina ainoastaan näytettävän muistiinpanon:

const Note = ({ note }) => {
  return (
    <div>
      <h2>{note.content}</h2>
      <div>{note.user}</div>
      <div><strong>{note.important ? 'important' : ''}</strong></div>
    </div>
  )
}

Eräs tapa muuttaa sovellusta olisi selvittää näytettävän muistiinpanon id komponentissa App React Routerin hook-funktion useMatch avulla.

useMatch-hookin käyttö ei ole mahdollista samassa komponentissa, joka määrittelee sovelluksen reititettävän osan. Siirretäänkin Router-komponenttien käyttö komponentin App ulkopuolelle:

ReactDOM.createRoot(document.getElementById('root')).render(
  <Router>    <App />
  </Router>)

Komponentti App muuttuu seuraavasti:

import {
  // ...
  useMatch} from 'react-router-dom'

const App = () => {
  // ...

  const match = useMatch('/notes/:id')  const note = match     ? notes.find(note => note.id === Number(match.params.id))    : null
  return (
    <div>
      <div>
        <Link style={padding} to="/">home</Link>
        // ...
      </div>

      <Routes>
        <Route path="/notes/:id" element={<Note note={note} />} />        <Route path="/notes" element={<Notes notes={notes} />} />   
        <Route path="/users" element={user ? <Users /> : <Navigate replace to="/login" />} />
        <Route path="/login" element={<Login onLogin={login} />} />
        <Route path="/" element={<Home />} />      
      </Routes>   

      <div>
        <em>Note app, Department of Computer Science 2023</em>
      </div>
    </div>
  )
}    

Joka kerta kun komponentti renderöidään eli käytännössä myös aina kun sovelluksen osoiterivillä oleva URL vaihtuu, suoritetaan komento

const match = useMatch('/notes/:id')

Jos URL on muotoa /notes/:id eli vastaa yksittäisen muistiinpanon URL:ia, saa muuttuja match arvokseen olion, jonka polun parametroitu osa eli muistiinpanon id voidaan selvittää. Näin saadaan haettua renderöitävä muistiinpano:

const note = match 
  ? notes.find(note => note.id === Number(match.params.id))
  : null

Lopullinen koodi on kokonaisuudessaan täällä.