Saltar al contenido

e

Agregar estilos a la aplicación React

La apariencia de nuestra aplicación actual es bastante modesta. En el ejercicio 0.2, la tarea era pasar por el tutorial CSS de Mozilla.

Antes de pasar a la siguiente parte, echemos un vistazo a cómo podemos agregar estilos a una aplicación React. Hay varias formas diferentes de hacer esto y veremos los otros métodos más adelante. Al principio, agregaremos CSS a nuestra aplicación a la vieja usanza; en un solo archivo sin usar un preprocesador CSS (aunque esto no es del todo cierto como veremos más adelante).

Agreguemos un nuevo archivo index.css bajo el directorio src y luego agrégalo a la aplicación importándolo en el archivo main.jsx:

import './index.css'

Agreguemos la siguiente regla CSS al archivo index.css:

h1 {
  color: green;
}

Las reglas CSS se componen de selectores y declaraciones. El selector define a qué elementos se debe aplicar la regla. El selector de arriba es h1, que coincidirá con todas las etiquetas de encabezado h1 en nuestra aplicación.

La declaración establece la propiedad color con en el valor green.

Una regla CSS puede contener un número arbitrario de propiedades. Modifiquemos la regla anterior para convertir el texto en cursiva, definiendo el estilo de fuente como italic:

h1 {
  color: green;
  font-style: italic;}

Hay muchas formas de hacer coincidir elementos usando diferentes tipos de selectores CSS.

Si quisiéramos apuntar, digamos, a cada una de las notas con nuestros estilos, podríamos usar el selector li, ya que todas las notas están envueltas dentro de las etiquetas li:

const Note = ({ note, toggleImportance }) => {
  const label = note.important 
    ? 'make not important' 
    : 'make important';

  return (
    <li>
      {note.content} 
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Agreguemos la siguiente regla a nuestra hoja de estilo (ya que mi conocimiento de diseño web elegante es cercano a cero, los estilos no tienen mucho sentido):

li {
  color: grey;
  padding-top: 3px;
  font-size: 15px;
}

El uso de tipos de elementos para definir reglas CSS es un poco problemático. Si nuestra aplicación contuviera otras etiquetas li, también se les aplicaría la misma regla de estilo.

Si queremos aplicar nuestro estilo específicamente a las notas, entonces es mejor usar selectores de clase.

En HTML normal, las clases se definen como el valor del atributo class:

<li class="note">some text...</li>

En React tenemos para usar el atributo className en lugar del atributo class. Con esto en mente, hagamos los siguientes cambios en nuestro componente Note:

const Note = ({ note, toggleImportance }) => {
  const label = note.important 
    ? 'make not important' 
    : 'make important';

  return (
    <li className='note'>      {note.content} 
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Los selectores de clase se definen con la sintaxis .classname:

.note {
  color: grey;
  padding-top: 5px;
  font-size: 15px;
}

Si ahora agrega otros elementos li a la aplicación, no se verán afectados por la regla de estilo anterior.

Mensaje de error mejorado

Anteriormente implementamos el mensaje de error que se mostraba cuando el usuario intentaba cambiar la importancia de una nota eliminada con el método alert. Implementemos el mensaje de error como su propio componente React.

El componente es bastante simple:

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }

  return (
    <div className="error">
      {message}
    </div>
  )
}

Si el valor del prop message es null, entonces no se muestra nada en la pantalla y, en otros casos, el mensaje se representa dentro de un elemento div.

Agreguemos un nuevo estado llamado errorMessage al componente App. Inicialicemos con algún mensaje de error para que podamos probar inmediatamente nuestro componente:

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState('some error happened...')
  // ...

  return (
    <div>
      <h1>Notes</h1>
      <Notification message={errorMessage} />      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all' }
        </button>
      </div>      
      // ...
    </div>
  )
}

Entonces agreguemos una regla de estilo que se adapte a un mensaje de error:

.error {
  color: red;
  background: lightgrey;
  font-size: 20px;
  border-style: solid;
  border-radius: 5px;
  padding: 10px;
  margin-bottom: 10px;
}

Ahora estamos listos para agregar la lógica para mostrar el mensaje de error. Cambiemos la función toggleImportanceOf de la siguiente manera:

  const toggleImportanceOf = id => {
    const note = notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    noteService
      .update(changedNote).then(returnedNote => {
        setNotes(notes.map(note => note.id !== id ? note : returnedNote))
      })
      .catch(error => {
        setErrorMessage(          `Note '${note.content}' was already removed from server`        )        setTimeout(() => {          setErrorMessage(null)        }, 5000)        setNotes(notes.filter(n => n.id !== id))
      })
  }

Cuando ocurre el error, agregamos un mensaje de error descriptivo al estado errorMessage. Al mismo tiempo, iniciamos un temporizador que establecerá el estado de errorMessage en null después de cinco segundos.

El resultado se ve así:

error eliminando nota en la aplicación

El código para el estado actual de nuestra aplicación se puede encontrar en la rama part2-7 en GitHub.

Estilos en línea

React también hace posible escribir estilos directamente en el código como los llamados estilos en línea.

La idea detrás de la definición de estilos en línea es extremadamente simple. Cualquier componente o elemento de React puede recibir un conjunto de propiedades CSS como un objeto JavaScript a través del atributo style.

Las reglas de CSS se definen de forma ligeramente diferente en JavaScript que en los archivos CSS normales. Digamos que queremos darle a algún elemento el color verde y la fuente en cursiva que tiene un tamaño de 16 píxeles. En CSS, se vería así:

{
  color: green;
  font-style: italic;
  font-size: 16px;
}

Pero como un objeto de estilo en línea de React se vería así:

 {
  color: 'green',
  fontStyle: 'italic',
  fontSize: 16
}

Cada propiedad CSS se define como una propiedad separada del objeto JavaScript. Los valores numéricos de los píxeles se pueden definir simplemente como números enteros. Una de las principales diferencias en comparación con el CSS normal es que las propiedades CSS con guiones (kebab case) están escritas en camelCase.

A continuación, podríamos agregar un "bloque inferior" a nuestra aplicación creando un componente Footer y definir los siguientes estilos en línea para él:

const Footer = () => {  const footerStyle = {    color: 'green',    fontStyle: 'italic',    fontSize: 16  }  return (    <div style={footerStyle}>      <br />      <em>Note app, Department of Computer Science, University of Helsinki 2024</em>    </div>  )}
const App = () => {
  // ...

  return (
    <div>
      <h1>Notes</h1>

      <Notification message={errorMessage} />

      // ...  

      <Footer />    </div>
  )
}

Los estilos en línea tienen ciertas limitaciones. Por ejemplo, las llamadas pseudoclases no se pueden usar directamente.

Los estilos en línea y algunas de las otras formas de agregar estilos a los componentes de React van completamente en contra de las viejas convenciones. Tradicionalmente, se ha considerado la mejor práctica para separar completamente CSS del contenido (HTML) y la funcionalidad (JavaScript). Según esta vieja escuela de pensamiento, el objetivo era escribir CSS, HTML y JavaScript en sus archivos separados.

La filosofía de React es, de hecho, el polo opuesto a esto. Dado que la separación de CSS, HTML y JavaScript en archivos separados no pareció escalar bien en aplicaciones más grandes, React basa la división de la aplicación en las líneas de sus entidades funcionales lógicas.

Las unidades estructurales que componen las entidades funcionales de la aplicación son componentes de React. Un componente de React define el HTML para estructurar el contenido, las funciones de JavaScript para determinar la funcionalidad y también el estilo del componente; todo en un lugar. Esto es para crear componentes individuales que sean lo más independientes y reutilizables como sea posible.

El código de la versión final de nuestra aplicación se puede encontrar en la rama part2-8 en GitHub.

Algunas observaciones importantes

Al final de esta parte, hay algunos ejercicios más desafiantes. En este momento, puedes saltarte los ejercicios si son demasiado difíciles; volveremos a los mismos temas más adelante. De todas formas, vale la pena lee el material.

Hemos hecho algo en nuestra aplicación que enmascara una fuente muy típica de errores.

Establecimos el estado notes con un valor inicial de un array vacío:

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

  // ...
}

Este es un valor inicial bastante natural ya que las notas son un conjunto, es decir, hay muchas notas que el estado almacenará.

Si el estado solo estuviera guardando "una cosa", un valor inicial más adecuado sería null, indicando que no hay nada en el estado al principio. Veamos qué sucede si usamos este valor inicial:

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

La aplicación se rompe:

consola TypeError no se pueden leer propiedades de null a través de map desde App

El mensaje de error proporciona la razón y la ubicación del error. El código que causó el problema es el siguiente:

  // notesToShow obtiene el valor de notes
  const notesToShow = showAll
    ? notes
    : notes.filter(note => note.important)

  // ...

  {notesToShow.map(note =>    <Note key={note.id} note={note} />
  )}

El mensaje de error es

Cannot read properties of null (reading 'map')

La variable notesToShow se asigna primero con el valor del estado notes y luego el código intenta llamar al método map en un objeto que no existe, es decir, en null.

¿Cuál es la razón de eso?

El hook de efecto utiliza la función setNotes para establecer que notes tenga las notas que el backend está devolviendo:

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {
        setNotes(initialNotes)      })
  }, [])

Sin embargo, el problema es que el efecto se ejecuta solo después de la primera renderización. Y debido a que notes tiene el valor inicial de null:

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

en la primera renderización se ejecuta el siguiente código:

notesToShow = notes

// ...

notesToShow.map(note => ...)

y esto hace que la aplicación explote porque no podemos llamar al método map del valor null.

Cuando establecemos notes para que sea inicialmente un array vacío, no hay error, ya que se permite llamar a map en un array vacío.

Así que, la inicialización del estado "enmascaró" el problema causado por el hecho de que los datos aún no se han obtenido del backend.

Otra forma de evitar el problema es utilizar el renderizado condicional y devolver null si el estado del componente no está correctamente inicializado:

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

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {
        setNotes(initialNotes)
      })
  }, [])

  // no renderizar nada si notes aún es null
  if (!notes) {     return null   }
  // ...
} 

Entonces, en la primera renderización, no se renderiza nada. Cuando las notas llegan desde el backend, el efecto utiliza la función setNotes para establecer el valor del estado notes. Esto provoca que el componente se vuelva a renderizar y, en la segunda renderización, las notas se muestran en la pantalla.

El método basado en el renderizado condicional es adecuado en casos en los que es imposible definir el estado para que la renderización inicial sea posible.

La otra cosa que aún necesitamos analizar más de cerca es el segundo parámetro del useEffect:

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {
        setNotes(initialNotes)  
      })
  }, [])

El segundo parámetro de useEffect se utiliza para especificar con qué frecuencia se ejecuta el efecto. El principio es que el efecto siempre se ejecuta después de la primera renderización del componente y cuando cambia el valor del segundo parámetro.

Si el segundo parámetro es un array vacío [], su contenido nunca cambia y el efecto solo se ejecuta después de la primera renderización del componente. Esto es exactamente lo que queremos cuando estamos inicializando el estado de la aplicación desde el servidor.

Sin embargo, hay situaciones en las que queremos realizar el efecto en otros momentos, por ejemplo, cuando el estado del componente cambia de una manera particular.

Considera la siguiente aplicación simple para consultar tasas de cambio de divisas desde la API de tasas de cambio:

import { useState, useEffect } from 'react'
import axios from 'axios'

const App = () => {
  const [value, setValue] = useState('')
  const [rates, setRates] = useState({})
  const [currency, setCurrency] = useState(null)

  useEffect(() => {
    console.log('effect run, currency is now', currency)

    // omitir si la moneda no está definida
    if (currency) {
      console.log('fetching exchange rates...')
      axios
        .get(`https://open.er-api.com/v6/latest/${currency}`)
        .then(response => {
          setRates(response.data.rates)
        })
    }
  }, [currency])

  const handleChange = (event) => {
    setValue(event.target.value)
  }

  const onSearch = (event) => {
    event.preventDefault()
    setCurrency(value)
  }

  return (
    <div>
      <form onSubmit={onSearch}>
        currency: <input value={value} onChange={handleChange} />
        <button type="submit">exchange rate</button>
      </form>
      <pre>
        {JSON.stringify(rates, null, 2)}
      </pre>
    </div>
  )
}

export default App

La interfaz de usuario de la aplicación tiene un formulario, en el input del cual se escribe el nombre de la moneda deseada. Si la moneda existe, la aplicación renderiza las tasas de cambio de la moneda a otras monedas:

navegador mostrando tasas de cambio de divisas con eur escrito y consola que dice fetching exchange rates

La aplicación establece el nombre de la moneda ingresado en el formulario al estado currency en el momento en que se presiona el botón.

Cuando currency obtiene un nuevo valor, la aplicación obtiene sus tasas de cambio desde la API en la función del efecto:

const App = () => {
  // ...
  const [currency, setCurrency] = useState(null)

  useEffect(() => {
    console.log('effect run, currency is now', currency)

    // omitir si la moneda no está definida
    if (currency) {
      console.log('fetching exchange rates...')
      axios
        .get(`https://open.er-api.com/v6/latest/${currency}`)
        .then(response => {
          setRates(response.data.rates)
        })
    }
  }, [currency])  // ...
}

El hook useEffect ahora tiene [currency] como segundo parámetro. Por lo tanto, la función del efecto se ejecuta después de la primera renderización y siempre después de la tabla, ya que su segundo parámetro [currency] cambia. Es decir, cuando el estado currency obtiene un nuevo valor, el contenido de la tabla cambia y se ejecuta la función del efecto.

El efecto tiene la siguiente condición:

if (currency) { 
  // se obtienen las tasas de cambio
}

lo que evita solicitar las tasas de cambio justo después de la primera renderización cuando la variable currency aún tiene el valor inicial, es decir, un valor null.

Entonces, si el usuario escribe, por ejemplo, eur en el campo de búsqueda, la aplicación utiliza Axios para realizar una solicitud HTTP GET a la dirección https://open.er-api.com/v6/latest/eur y almacena la respuesta en el estado rates.

Luego, cuando el usuario ingresa otro valor en el campo de búsqueda, por ejemplo, usd, la función del efecto se ejecuta nuevamente y se solicitan las tasas de cambio de la nueva moneda desde la API.

La forma presentada aquí para realizar solicitudes a la API podría parecer un poco incómoda. Esta aplicación en particular podría haberse hecho completamente sin usar useEffect, realizando las solicitudes a la API directamente en la función del controlador de envío del formulario:

  const onSearch = (event) => {
    event.preventDefault()
    axios
      .get(`https://open.er-api.com/v6/latest/${value}`)
      .then(response => {
        setRates(response.data.rates)
      })
  }

Sin embargo, hay situaciones donde esa técnica no funcionaría. Por ejemplo, podrías encontrarte con una de esas situaciones en el ejercicio 2.20 donde el uso de useEffect podría ofrecer una solución. Ten en cuenta que esto depende en gran medida del enfoque que hayas seleccionado; por ejemplo, la solución modelo no utiliza este truco.