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 websovelluksessa sovelluksen näyttämän sivun vaihto tapahtui siten että selain teki palvelimelle uuden HTTP GET -pyynnön ja renderöi sitten palvelimen palauttaman 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 erittäin helppo toteuttaa Reactilla.

Seuraavassa on eräs tapa:

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

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.render(<App />, document.getElementById('root'))

Eli jokainen näkymä on toteutettu omana komponenttinaan ja sovelluksen tilassa page pidetään tieto siitä, minkä 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. bookmarkien tekeminen olisi mahdollista. Sovelluksessamme ei myöskään selaimen back-painike toimi loogisesti, eli back ei vie edelliseksi katsottuun sovelluksen näkymään vaan jonnekin ihan muualle. Jos sovellus kasvaisi suuremmaksi ja sinne haluttaisiin esim. jokaiselle käyttäjälle sekä muistiinpanolle oma yksittäinen näkymänsä, itse koodattu reititys eli sivuston navigaationhallinta menisi turhan monimutkaiseksi.

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 komennolla

npm install --save react-router-dom

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

import {
  BrowserRouter as Router,
  Switch, 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>

      <Switch>
        <Route path="/notes">
          <Notes />
        </Route>
        <Route path="/users">
          <Users />
        </Route>
        <Route path="/">
          <Home />
        </Route>
      </Switch>

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

Reititys, eli komponenttien ehdollinen, selaimen urliin 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,  Switch, 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 urlin muuttuessa. HTML5 history API:n avulla BrowserRouter kuitenkin mahdollistaa sen, että selaimen osoiterivillä olevaa urlia voidaan käyttää React-sovelluksen sisäiseen "reitittämiseen", eli vaikka osoiterivillä oleva url muuttuu, sivun sisältöä manipuloidaan ainoastaan Javascriptillä ja selain ei lataa uutta sisältöä palvelimelta. Selaimen toiminta back- ja forward-toimintojen ja bookmarkien tekemisen suhteen on kuitenkin loogista, eli toimii kuten perinteisillä web-sivuilla.

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 urliksi /notes.

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

<Route path="/notes">
  <Notes />
</Route>

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

Urliin perustuen renderöitävät komponentit on sijoitettu Switch-komponentin lapsiksi

<Switch>
  <Route path="/notes">
    <Notes />
  </Route>
  <Route path="/users">
    <Users />
  </Route>
  <Route path="/">
    <Home />
  </Route>
</Switch>

Switch saa aikaan sen, että renderöitävä komponentti on ensimmäinen, jonka path vastaa osoiterivin polkua.

Huomaa, että komponenttien järjestys on tärkeä. Jos laittaisimme ensimmäiseksi komponentin Home, jonka polku on path="/", ei mitään muuta komponenttia koskaan renderöitäisi, sillä "olematon" polku on minkä tahansa polun alkuosa:

<Switch>
  <Route path="/">    <Home />  </Route>  
  <Route path="/notes">
    <Notes />
  </Route>
  // ...
</Switch>

Parametroitu 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>
  <div>
    <div>
      <Link style={padding} to="/">home</Link>
      <Link style={padding} to="/notes">notes</Link>
      <Link style={padding} to="/users">users</Link>
    </div>

    <Switch>
      <Route path="/notes/:id">        <Note notes={notes} />      </Route>      <Route path="/notes">
        <Notes notes={notes} />
      </Route>
      <Route path="/">
        <Home />
      </Route>
    </Switch>

</Router>

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

<Route path="/notes/:id">

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 ? 'tärkeä' : ''}</strong></div>
    </div>
  )
}

Komponentti Note saa parametrikseen kaikki muistiinpanot propsina notes ja se pääsee urlin yksilöivään osaan, eli näytettävän muistiinpanon id:hen käsiksi react-routerin funktion useParams avulla.

useHistory

Sovellukseen on myös toteutettu erittäin 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>

eli 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 seuraavassa

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

const Login = (props) => {
  const history = useHistory()
  const onSubmit = (event) => {
    event.preventDefault()
    props.onLogin('mluukkai')
    history.push('/')  }

  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>
  )
}

Mielenkiinoista komponentissa on react-routerin funktion useHistory käyttö. Funktion avulla komponentti pääsee käsiksi history-olioon, joka taas mahdollistaa mm. selaimen osoiterivin muokkaamisen ohjelmallisesti.

Kirjautumisen yhteydessä kutsutaan history-olion metodia push. Komento history.push('/') 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 useHistory 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ä. Create-react-app on konfiguroitu varoittamaan, jos hookien säännöt rikkoutuvat, esim. jos hook-funktiota yritetään kutsua ehtolauseen sisältä.

redirect

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

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

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

<Redirect 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>

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

Komponentti renderöi myös kokonaan Router:in 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.

Parametroitu 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 urlin parametroitua osaa:

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

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

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

Eräs tapa muuttaa sovellusta olisi selvittää näytettävän muistiinpanon id komponentissa App react-routerin hook-funktion useRouteMatch avulla.

useRouteMatch-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.render(
  <Router>    <App />
  </Router>,  document.getElementById('root')
)

Komponentti App muuttuu seuraavasti:

import {
  // ...
  useRouteMatch} from "react-router-dom"

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

  const match = useRouteMatch('/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>

      <Switch>
        <Route path="/notes/:id">
          <Note note={note} />        </Route>
        <Route path="/notes">
          <Notes notes={notes} />
        </Route>
         // ...
      </Switch>

      <div>
        <em>Note app, Department of Computer Science 2020</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 = useRouteMatch('/notes/:id')

Jos url on muotoa /notes/:id eli vastaa yksittäisen muistiinpanon urlia, saa muuttuja match arvokseen olion, jonka polun parametroitu osa, eli muistiinpanon id voidaan selvittää, ja 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ä.