Saltar al contenido

a

React-router

Los ejercicios de esta séptima parte del curso difieren un poco de los anteriores. En este capítulo y en el siguiente, como es habitual, hay ejercicios relacionados con la teoría del capítulo.

Además de los ejercicios de este capítulo y del siguiente, hay una serie de ejercicios en los que repasaremos lo que hemos aprendido durante todo el curso, ampliando la aplicación BlogList, en la que trabajamos durante las partes 4 y 5.

Estructura de navegación de la aplicación

Después de la parte 6, volvemos a React sin Redux.

Es muy común que las aplicaciones web tengan una barra de navegación, que permite cambiar la vista de la aplicación.

Nuestra aplicación podría tener una página principal

aplicación de notas con link de navegación a home

y páginas separadas para mostrar información sobre notas y usuarios:

aplicación de notas con link de navegación a notas

En una aplicación web de la vieja escuela, cambiar la página mostrada por la aplicación se lograría mediante el navegador realizando una solicitud HTTP GET al servidor y renderizando el HTML que representa la vista que se devolvió.

En las aplicaciones de una sola página, en realidad, siempre estamos en la misma página. El código Javascript ejecutado por el navegador crea una ilusión de diferentes "páginas". Si se realizan solicitudes HTTP al cambiar de vista, solo son para obtener datos con formato JSON, que la nueva vista podría requerir para que se muestren.

La barra de navegación y una aplicación que contiene múltiples vistas son muy fáciles de implementar usando React.

He aquí una forma:

import { 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 />)

Cada vista se implementa como su propio componente. Almacenamos la información del componente de vista en el estado de la aplicación llamado page. Esta información nos indica qué componente, representando una vista, debería mostrarse debajo de la barra de menú.

Sin embargo, el método no es muy óptimo. Como podemos ver en las imágenes, la dirección se mantiene igual aunque en ocasiones estemos en vistas diferentes. Preferiblemente, cada vista debería tener su propia dirección, por ejemplo, para hacer posible la creación de marcadores. El botón de atrás tampoco funciona como se espera para nuestra aplicación, lo que significa que atrás no te lleva a la vista previamente mostrada de la aplicación, sino a un lugar completamente diferente. Si la aplicación fuera a crecer aún más y quisiéramos, por ejemplo, agregar vistas separadas para cada usuario y nota, entonces este routing (enrutamiento) hecho por nosotros mismos, que significa la gestión de navegación de la aplicación, se volvería excesivamente complicado.

React Router

Afortunadamente, React tiene la librería React Router, que proporciona una excelente solución para administrar la navegación en una aplicación React.

Cambiemos la aplicación anterior para que use React Router. Primero, instalemos React Router con el comando:

npm install react-router-dom

El routing proporcionado por React Router se habilita cambiando la aplicación de la siguiente manera:

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 2024</i>
      </div>
    </Router>
  )
}

Routing, o la representación condicional de componentes basada en la URL en el navegador, se utiliza colocando componentes como hijos del componente Router, es decir, dentro de las etiquetas del Router.

Ten en cuenta que, aunque se hace referencia al componente por el nombre Router, en realidad estamos hablando de BrowserRouter, porque aquí la importación ocurre al cambiar el nombre del objeto importado:

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

Según el manual de la v5:

BrowserRouter es un Router que usa la API de historial HTML5 (pushState, replaceState y el evento popState) para mantener su interfaz de usuario sincronizada con la URL.

Normalmente, el navegador carga una nueva página cuando cambia la URL en la barra de direcciones. Sin embargo, con la ayuda de la API de historial HTML5, BrowserRouter nos permite usar la URL en la barra de direcciones del navegador para el "routing" interno en una aplicación React. Por lo tanto, incluso si cambia la URL en la barra de direcciones, el contenido de la página solo se manipula mediante Javascript y el navegador no cargará contenido nuevo desde el servidor. Usar las acciones de avance y retroceso, así como crear marcadores, sigue siendo lógico como en una página web tradicional.

Dentro del router definimos enlaces que modifican la barra de direcciones con la ayuda del componente Link. Por ejemplo:

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

crea un enlace en la aplicación con el texto notes, que cuando se clica cambia la URL en la barra de direcciones a /notes.

Los componentes renderizados según la URL del navegador se definen con la ayuda del componente Route. Por ejemplo,

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

define, que si la dirección del navegador es /notes, renderizamos el componente Notes.

Envolvemos los componentes que serán renderizados basados en la URL con un componente Routes

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

Las rutas funcionan representando al primer componente cuya path (ruta) coincida con la URL en la barra de direcciones del navegador.

Ruta parametrizada

Examinemos una versión ligeramente modificada del ejemplo anterior. El código completo del ejemplo se puede encontrar aquí.

La aplicación ahora contiene cinco vistas diferentes cuya pantalla está controlada por el router. Además de los componentes del ejemplo anterior (Home, Notes y Users), tenemos Login que representa la vista de inicio de sesión y Note que representa la vista de una sola nota.

Home y Users no han cambiado con respecto al ejercicio anterior. Notes es un poco más complicado. Muestra la lista de notas que se le pasan como props de tal manera que se puede hacer clic en el nombre de cada nota.

notes app, mostrando que las notas se pueden clickear

La capacidad de hacer clic en un nombre se implementa con el componente Link, hacer clic en el nombre de una nota cuyo id es 3 desencadenaría un evento que cambiaría la dirección del navegador a 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>
)

Definimos las URL parametrizadas en el routing del componente App de la siguiente manera:

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

Definimos la ruta renderizando una nota específica "estilo express" marcando el parámetro con dos puntos :id

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

Cuando un navegador navega a la URL de una nota específica, por ejemplo /notes/3, renderizamos el componente 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>
  )
}

El componente Note recibe todas las notas como props notes, y se puede acceder al parámetro URL (el id de la nota que se mostrará) con la función useParams de React Router.

useNavigate

También hemos implementado una función simple de inicio de sesión en nuestra aplicación. Si un usuario ha iniciado sesión, la información sobre un usuario que ha iniciado sesión se guarda en el campo user del estado del componente App.

La opción para navegar a la vista de Login en el menú se renderiza condicionalmente.

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

Entonces, si el usuario ya ha iniciado sesión, en lugar de mostrar el enlace Login, mostramos su nombre de usuario:

app de notas mostrando usuario logueado

El código del componente que controla la funcionalidad de inicio de sesión es el siguiente:

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

Lo interesante de este componente es el uso de la función useNavigate de react-router. Con esta función, se puede modificar la URL del navegador programáticamente.

Con el inicio de sesión, llamamos a navigate('/'), que cambia la URL del navegador a / y la aplicación muestra el componente correspondiente, Home.

Tanto useParams como useNavigate son hooks, al igual que useState y useEffect que ya hemos usado muchas veces. Como recordarás de la parte 1, existen algunas reglas para usar hooks.

Redirigir

Hay otro detalle interesante en la ruta de Users:

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

Si un usuario no ha iniciado sesión, el componente Users no se renderiza. En su lugar, el usuario es redirigido mediante el componente Navigate a la vista de inicio de sesión.

<Navigate replace to="/login" />

En realidad, tal vez sería mejor ni siquiera mostrar enlaces en la barra de navegación que requieran iniciar sesión si el usuario no está conectado a la aplicación.

Aquí está el componente App en su totalidad:

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>      
      <footer>
        <br />
        <em>Note app, Department of Computer Science 2024</em>
      </footer>
    </div>
  )
}

Definimos un elemento común para las aplicaciones web modernas llamado footer, que define a la parte inferior de la pantalla, fuera del Router, para que se muestre independientemente del componente que se muestra en la parte enrutada de la aplicación.

Ruta parametrizada revisitada

Nuestra aplicación tiene un defecto. El componente Note recibe todas las notas, aunque solo muestra aquella cuyo id coincide con el parámetro URL:

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

¿Sería posible modificar la aplicación para que el componente Note reciba solo la nota que debería mostrar?

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

Una forma de hacer esto sería usar el hook useMatch de React Router para encontrar la id de la nota que se mostrará en el componente App.

No es posible utilizar el hook useMatch en el componente que define la parte enrutada de la aplicación. Movamos el uso de los componentes Router fuera de App:

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

El componente App se convierte en:

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 2024</em>
      </div>
    </div>
  )
}  

Cada vez que se renderiza el componente, lo cual sucede cada vez que cambia la URL del navegador, se ejecuta el siguiente comando:

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

Si la URL coincide con /notes/:id, la variable match contendrá un objeto desde el cual podemos acceder a la parte parametrizada de la ruta, el id de la nota que se mostrará, y luego podremos buscar la nota correcta para mostrar.

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

El código completo se puede encontrar aquí.