la35.net blog para alumnos de la ET Nº35

Hello React

Una app para tomar notas, hecha en React y usando el backend de hello-crud. Demo en Heroku. Código en GitHub.

Contenidos

  1. ¿Qué es React?
  2. Creando el proyecto
  3. Concurrently
  4. Estructura del proyecto
  5. Agregando dependencias
  6. Los componentes de la app
  7. Footer
  8. Header
  9. Componentes con estado
  10. ABM de notas
  11. NoteForm
  12. La lista de notas y useEffect
  13. Note.js
  14. Build & deploy

¿Qué es React?

React es un framework de JS para crear aplicaciones webs. Está orientado más que nada a crear SPAs (Single Page Applications). O sea una aplicación web que una vez descargada al navegador no requiere refrescar la página.

Según sus creadores de Facebook, React es

Una biblioteca de JavaScript para crear interfaces de usuario

En React definimos los distintos elementos de una aplicación como componentes, y sus competidores principales son Angular y Vue que utilizan un enfoque distinto pero similar.

Un componente en React está formado por elementos. Un elemento en React tiene esta pinta.

const element = <h1>Hello, world!</h1>;

Esta sintaxis que usa React es una extensión de JavaScript llamada JSX. Los componentes de React son funciones de JavaScript que pueden utilizar expresiones de JSX para definir elementos de la interfaz de usuario.

En general la sintaxis de los elementos en React es la de las etiquetas de HTML, pero JSX no es HTML y hay varias excepciones que vamos a ir viendo. La más conocida, para dar un ejemplo, es la del atributo class en un elemento.

En HTML tenemos

<h1 class="text-center">Hola</h1>

Y en JSX en cambio usamos className porque class es una palabra reservada en JavaScript.

<h1 className="text-center">Hola</h1>

Pero en general lo que funciona en HTML funciona en JSX.

Un componente en React puede escribirse como una función de JS.

const Saludo = (props) => {
  return (
    <h1>Hola, {props.name}</h1>
  );
};

Esta función tiene que devolver un elemento de React, en el ejemplo de arriba un <h1> y puede recibir un objeto como argumento llamado convencionalmente props por propiedades.

La forma de usar las propiedades es similar a los atributos de HTML. Siguiendo con el ejemplo de arriba, el componente Saludo lo podemos usar en un componente que represente a toda la aplicación de la siguiente manera.

const App = (props) => {
  return (
    <div>
      <Saludo name="Juan" />
    </div>
  )
};

El valor de props.name en Saludo en el caso de arriba será 'Juan'.

Existen muchos conceptos más en React que iremos viendo mientras hacemos el proyecto. Para los más curiosos el mejor lugar para empezar a entender como funciona React es la documentación oficial.

Creando el proyecto

En el artículo anterior creamos el backend para una app de tomar notas. Ese va a ser nuestro punto de partida. Creamos una carpeta para este proyecto y copiamos todo lo que teníamos en hello-crud.

$ mkdir hello-react
$ cd hello-react
$ git init
$ npm init -y
$ echo node_modules > .gitignore
$ echo web: npm start > Procfile
$ touch index.js
$ mkdir api
$ mkdir api/routes api/models
$ touch api/routes/note.js api/models/Note.js

Copien el código que ya tenían en los tres archivos de JavaScript: index.js, api/models/Note.js y api/routes/note.js.

Instalamos los paquetes de npm.

$ npm i -D nodemon
$ npm i express morgan mongoose cors

Por último agregamos los scripts al package.json.

"scripts": {
  "dev": "nodemon index.js",
  "start": "node index.js"
}

Antes de empezar con el frontend probamos que todo funcione como antes.

$ npm run dev

Si todo anduvo bien entonces ahora sí podemos crear la aplicación de React. Para eso vamos a usar Create React App que es una herramienta de línea de comandos para crear rápidamente una nueva app de React.

$ npx create-react-app client
$ cd client
$ npm start

Con Create React App nos ahorramos el dolor de cabeza de tener que configurar Webpack y otras cosas necesarias para que todo funcione.

A partir de ahora vamos a trabajar en el directorio client donde va a estar el código relativo al frontend. Si todo anduvo bien deberían ver en localhost:3000 el logo de React girando.

Concurrently

Primero tenemos un problema que resolver, si ejecutamos npm start desde el directorio client solo tenemos el frontend ejecutándose.

Si hacemos npm start o npm run dev desde la carpeta del proyecto en cambio tenemos el backend. Queremos las dos cosas juntas.

Lo ideal sería ejecutar npm run dev desde el directorio principal y tener las dos cosas juntas. Por suerte existe un paquete en npm que resuelve este problema, lo instalamos. Pero lo hacemos desde el directorio raíz del proyecto, es decir parados en hello-react.

[~/hello-react]$ npm i -D concurrently

Para usar Concurrently modificamos el package.json del backend. La sección de scripts en hello-react/package.json tiene que quedar así.

"scripts": {
  "server": "node index.js",
  "client": "npm start --prefix client",
  "start": "node index.js",
  "dev": "concurrently \"npm run server\" \"npm run client\""
}

Entonces con npm run dev ahora tenemos el cliente y el servidor al mismo tiempo. Excepto por un problema, tanto servidor como cliente quieren recibir conexiones en el puerto 3000. Lo arreglamos cambiando el puerto en hello-react/index.js por 4000 o alguna otra cosa.

El último paso para que todo funcione es ir a client/package.json y agregar la siguiente propiedad:

"proxy": "http://localhost:4000"

Esto es necesario para que el cliente pueda realizar peticiones a la API del server. Ahora sí ya estamos listos para empezar con el código del frontend. Veamos un poco más en detalle los archivos que Create React App creó en el directorio client.

Estructura del proyecto

El directorio client se podría considerar un proyecto de NodeJS dentro de nuestro proyecto original que era el server para esta aplicación web. Dentro de client tenemos también un package.json, podemos instalar dependencias para el frontend que irán a client/node_modules.

Después de eliminar algunos archivos innecesarios tenemos lo siguiente dentro de client.

.
├── node_modules
├── .gitignore
├── package.json
├── package-lock.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── manifest.json
│   └── robots.txt
└── src
    ├── App.js
    ├── index.js
    └── serviceWorker.js

En general vamos a editar y crear archivos en src, por source code o código fuente. Menos frecuentemente puede ser que editemos algo en public.

La cosa funciona así, el archivo client/index.js es el punto de entrada del frontend. En general ese archivo lo que hace es montar el componente que representa a toda la app en un div con id="root" en public/index.html. El componente principal de la app está definido generalmente en un archivo llamado App.js que pueden encontrar en el directorio src.

Agregando dependencias

Para esta app vamos a usar Bootstrap y Font Awesome. Además vamos a usar un paquete de npm llamado Axios para realizar peticiones HTTP a la API en el backend.

Para instalar dependencias en React usamos npm pero desde el directorio client.

$ npm i axios bootstrap

Para poder usar las clases de Bootstrap en JSX vamos a client/src/index.js y agregamos el siguiente import.

import 'bootstrap/dist/css/bootstrap.css';

Para usar Font Awesome lo linkeamos directamente desde un CDN en client/public/index.html. En el head agregamos

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">

Los componentes de la app

Para pensar en React tenemos que pensar en qué componentes vamos a dividir la aplicación. La app es un frontend para la API que hicimos en hello-crud. O sea queremos poder ver, crear, editar y eliminar notas.

La siguiente imagen ilustra lo que se me ocurrió a mí.

componentes

Un header y un footer. Un formulario para agregar una nota y un contenedor en el centro para mostrar todas las notas. Todo eso dentro de un componente que representa toda la app.

Necesitamos archivos para cada componente, así que dentro de client/src hacemos

$ touch Footer.js Header.js NoteForm.js NotesList.js Note.js

No hace falta crear App.js porque ya lo creó Create React App. Las notas las vamos a mostrar con dos componentes. NotesList.js para agrupar todas las notas y Note.js es el componente que representa cada nota individual. El componente NoteForm.js es el formulario que me permite agregar una nota nueva.

Vamos viendo el código componente por componente.

import React from 'react';

// footer component
const Footer = () => {
  return (
    <div className="text-center mb-3">
      <hr />
      <h4 className="text-muted">Hello React</h4>
      <a href="https://github.com/santiagotrini/hello-react">
        <i className="fa fa-github fa-3x text-dark"></i>
      </a>
    </div>
  );
};

export default Footer;

El footer es el componente más simple. Solo tiene que importar React en la primera línea y es una función que devuelve JSX. No se olviden de exportar el componente con export default al final del archivo para poder usarlo después en App.js.

Noten el uso de className en vez de class para darle estilo con Bootstrap.

El próximo componente usa props. El header recibe el título como propiedad.

import React from 'react';

const Header = ({ title }) => {
  return (
    <nav className="justify-content-center navbar navbar-expand-lg navbar-dark bg-dark">
      <a className="navbar-brand" href="/#">{title}</a>
    </nav>
  );
};

export default Header;

Para usar props en los elementos de JSX o en general cualquier expresión de JavaScript tenemos que encerrarla en {}. En el ejemplo tenemos {title} como el contenido del elemento <a>.

Para probar lo que tenemos hasta ahora y verlo en el navegador tenemos que modificar el componente App en App.js.

// imports
import React from 'react';
import Header from './Header';
import Footer from './Footer';

const App = () => {
  return (
    <div>
      <Header title='Notas'/>
      <Footer />
    </div>
  );
};

export default App;

Noten que tenemos que encerrar a Header y Footer en un <div> porque un componente de React solo puede devolver un único elemento. Pero ese elemento puede contener a su vez otros elementos.

También se puede ver como importamos los componentes Header y Footer con import al inicio del archivo.

Componentes con estado

Hasta ahora los dos componentes que vimos no tienen estado. El Header tiene props pero las propiedades funcionan como valores que no cambian, se establecen al momento de crear el componente.

Si queremos un componente con valores que puedan cambiar necesitamos componentes con estado. El componente App por ejemplo mantiene una lista (array) con todas las notas de la app.

Para esto hacemos uso del hook useState que nos da React.

// imports
import React, { useState } from 'react';
import Header from './Header';
import Footer from './Footer';

const App = () => {

  // useState hook (las notas de la lista)
  const [notes, setNotes] = useState([]);

  // render JSX
  return (
    <div>
      <Header title='Notas'/>
      <div className="container mt-3">

      </div>
      <Footer />
    </div>
  );
};

// export
export default App;

La línea const [notes, setNotes] = useState([]); es donde usamos el hook y obtenemos dos variables. En notes vamos a tener inicialmente un array vacío. Y en setNotes tenemos una función que nos permite cambiar el valor de notes. Con la variable notes ya podemos empezar a escribir las funciones para realizar el CRUD en React.

ABM de notas

Seguimos en App.js. Importamos axios para realizar las requests a la API y agregamos tres funciones para crear, actualizar y borrar una nota del array notes.

// imports
import React, { useState } from 'react';
import axios from 'axios';
import Header from './Header';
import Footer from './Footer';

const App = () => {

  // useState hook (las notas de la lista)
  const [notes, setNotes] = useState([]);

  // funciones del CRUD
  // crear nota
  const addNote = note => {
    axios.post('/api/notes', note)
      .then(res => {
        const newNotes = [res.data, ...notes];
        setNotes(newNotes);
      });
  };

  // update note
  const updateNote = (id, title, text) => {
    const updatedNote = {
      title: title,
      text: text
    };
    axios.put('/api/notes/' + id, updatedNote)
      .then(res => {
        const newNotes = notes.map(note =>
          note.id === id ? updatedNote : note
        );
        setNotes(newNotes);
      });
  };

  // delete note
  const removeNote = (id) => {
    axios.delete('/api/notes/' + id)
      .then(res => {
        const newNotes = notes.filter(note => note._id !== id);
        setNotes(newNotes);
    });
  };

  // render JSX
  return (
    <div>
      <Header title='Notas'/>
      <div className="container mt-3">
        <NoteForm
          addNote={addNote}
        />
      </div>
      <Footer />
    </div>
  );
};

// export
export default App;

El uso de Axios no requiere demasiada explicación. La sintaxis es axios.METHOD(URL, DATA) y eso devuelve una promesa, o sea podemos encadenar un .then() con una callback dentro que se va a ejecutar cuando llegue la respuesta del servidor.

En addNote() recibimos como argumento un objeto que representa una nota y después de agregarla a la base de datos a través de la API usamos setNotes() para modificar el array de notas. En esta función usamos el operador ... conocido como spread operator para crear un nuevo array con todos los elementos de notas más la nota recién creada al principio.

En updateNote() recibimos como argumentos el ID, el título y el texto de la nota a modificar. Después de recibir la respuesta del servidor usamos notes.map() para devolver un array idéntico a notes excepto por la posición que modificamos.

En deleteNote() usamos el ID de la nota para hacer la petición a la API y modificamos el array de notas con notes.filter() que devuelve un array con todos los elementos que cumplan con la condición note._id !== id.

En el return de App agregué un <div> que hace de contenedor de Bootstrap y el componente NoteForm. Por ahora va a dar error porque no tenemos nada en NoteForm.js.

NoteForm

En este componente vamos a armar un formulario para agregar notas. Vamos a recibir como propiedad la función addNote desde App.

import React, { useState } from 'react';

const NoteForm = ({ addNote }) => {

  // state hooks para el form
  const [title, setTitle] = useState('');
  const [text, setText] = useState('');

  // handler para el submit
  const handleSubmit = e => {
    e.preventDefault();
    addNote({
      title: title,
      text: text
    });
    // blanquear formulario
    setTitle('');
    setText('');
  };

  // render JSX
  return (
    <form onSubmit={handleSubmit}>
      <div className="form-group">
        <label htmlFor="title">Título</label>
        <input
          id="title"
          className="form-control"
          type='text'
          value={title}
          onChange={e => setTitle(e.target.value)}
        />
      </div>
      <div className="form-group">
        <label htmlFor="text">Texto</label>
        <textarea
          id="text"
          className="form-control"
          value={text}
          rows="4"
          onChange={e => setText(e.target.value)}
        >
        </textarea>
      </div>
      <input
        className="btn btn-primary"
        type="submit"
        value="Guardar"
      />
    </form>
  );
};

export default NoteForm;

En este componente definimos dos hooks de estado, uno para cada input del formulario que tiene un <input type="text"> y un <textarea> para el título y el texto de la nota. Usamos el evento onChange en cada input para setear el estado y usamos una función llamada handleSubmit como acción cuando enviamos el formulario.

Lo que hace handleSubmit es llamar a addNote con los datos del formulario. Si bien addNote era una función de App acá la tenemos disponible porque la pasamos como prop. Antes de que termine handleSubmit blanqueamos el formulario con setTitle('') y setText('').

La lista de notas y useEffect

Para armar la lista de notas vamos a usar dos componentes. El primero representa la lista con todas las notas, el segundo una nota individual. Para traer los datos de las notas de la base de datos tenemos que hacer una petición a la API con Axios apenas cargue la aplicación.

Para este tipo de requests podemos usar el hook useEffect en React. Este hook se ejecuta cuando un componente termina de renderizarse. La función que le pasamos a useEffect la vamos a usar para realizar la petición en el componente App y actualizar su estado, es decir, el array que guarda las notas. Ahora App.js quedaría así.

// imports
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Header from './Header';
import NotesList from './NotesList';
import NoteForm from './NoteForm';
import Footer from './Footer';

const App = () => {

  // useState hook (las notas de la lista)
  const [notes, setNotes] = useState([]);

  // useEffect hook (para fetchear la data al cargar)
  useEffect(() => {
    axios.get('/api/notes')
      .then(res => {
        setNotes(res.data.notes);
      });
  }, []);

  // CRUD functions
  // crear nota
  const addNote = note => {
    axios.post('/api/notes', note)
      .then(res => {
        const newNotes = [res.data, ...notes];
        setNotes(newNotes);
      });
  };

  // actualizar nota
  const updateNote = (id, title, text) => {
    const updatedNote = {
      title: title,
      text: text
    };
    axios.put('/api/notes/' + id, updatedNote)
      .then(res => {
        const newNotes = notes.map(note =>
          note.id === id ? updatedNote : note
        );
        setNotes(newNotes);
      });
  };

  // eliminar nota
  const removeNote = (id) => {
    axios.delete('/api/notes/' + id)
      .then(res => {
        const newNotes = notes.filter(note => note._id !== id);
        setNotes(newNotes);
    });
  };

  // render JSX
  return (
    <div>
      <Header title='Notas'/>
      <div className="container mt-3">
        <NoteForm
          addNote={addNote}
        />
        <hr />
        <NotesList
          notes={notes}
          removeNote={removeNote}
          updateNote={updateNote}
        />
      </div>
      <Footer />
    </div>
  );
};

export default App;

Ya con esto App.js está terminado. Noten que useEffect lleva dos argumentos. El primero es una callback que se ejecuta cuando el componente termina de actualizar la UI, el segundo es un array con variables. En ese array podemos poner variables, y cuando esas variables cambian el hook vuelve a dispararse. Para evitar que el hook se ejecute infinitamente le pasamos un array vacío.

También actualizamos el JSX en el return, ahora ponemos un <hr> después del formulario y el componente NotesList. NotesList recibe props de App, el array de notas y las funciones para eliminar y modificar una nota. Todavía no tenemos el código de NotesList.js así que es normal que dé error en este punto.

El componente NotesList sería algo así.

import React from 'react';
import Note from './Note';

const NotesList = ({ notes, removeNote, updateNote }) => {

  // render JSX
  return (
    <div className="card-columns">
      {notes.map((note) => (
        <Note
          id={note._id}
          key={note._id}
          initialTitle={note.title}
          initialText={note.text}
          removeNote={removeNote}
          updateNote={updateNote}
        />
      ))}
    </div>
  );
};

export default NotesList;

NotesList es un <div> que utiliza el componente Note internamente, por eso lo importa en la segunda línea. Usamos notes.map() para iterar sobre el array de notas y crear un componente Note por cada nota. Cada componente Note recibe props de NotesList: el ID, título y texto, así como las funciones removeNote y updateNote.

Cuando hacemos listas de componentes en React la librería nos pide que pasemos una propiedad llamada key que sea única para cada componente en la lista. Podemos usar el ID de cada nota para esto.

Note.js

Bueno, solo nos falta el componente Note. Copio el código y luego lo explico.

import React, { useState } from 'react';

const Note = ({ id, initialTitle, initialText, removeNote, updateNote }) => {

  // note title state
  const [title, setTitle] = useState(initialTitle);
  // note text state
  const [text, setText] = useState(initialText);
  // editable state
  const [editable, setEditable] = useState(false);

  // handlers
  // save handler
  const handleSave = () => {
    updateNote(id, title, text);
    setEditable(!editable);
  };

  // CSS override de bootstrap
  const inputStyle = {
    backgroundColor: 'transparent',
    border: 'none',
    fontSize: 1.25+'rem',
    marginBottom: 0.75+'rem'
  };
  const textareaStyle = {
    backgroundColor: 'transparent',
    border: 'none'
  };

  // render JSX
  return (
    <div className="card">
      <div className="card-body">

        <input
          style={inputStyle}
          spellCheck={false}
          disabled={!editable}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />

        <textarea
          rows={5}
          style={textareaStyle}
          spellCheck={false}
          disabled={!editable}
          value={text}
          onChange={(e) => setText(e.target.value)}
        >
        </textarea>
        <br />

        <button className="btn" hidden={editable} onClick={() => setEditable(!editable)}>
          <i className="text-secondary fa fa-pencil fa-lg"></i>
        </button>

        <button className="btn" hidden={!editable} onClick={handleSave}>
          <i className="text-secondary fa fa-save fa-lg"></i>
        </button>

        <button className="btn" onClick={() => removeNote(id)}>
          <i className="text-danger fa fa-trash fa-lg"></i>
        </button>

      </div>
    </div>
  );
};

export default Note;

Cada nota es una card de Bootstrap, un <div className="card">. Cada una de estas tarjetas contiene inputs para el título y el texto y dos botones: editar y eliminar. Al presionar el botón de editar podemos cambiar el título y el texto de la nota y vemos un botón de guardar.

Cada una de estas notas tienen estado, así que usamos useState para definir tres variables de estado. El título, el texto y si la nota es editable o no. El estado de “ser editable” lo cambiamos al usar el botón de editar y guardar.

Cuando la nota es editable usamos setText y setTitle con el listener onChange para cambiar el estado a medida que tipeamos. Podemos usar el atributo disabled para que los inputs estén desactivados y no sean editables hasta presionar el botón de editar.

Las funciones asociadas a cada botón se disparan usando el listener onClick. Cuándo la función tiene una sola línea podemos escribirla directamente ahí como en onClick={() => remoteNote(id)}. Si la función fuera más complicada podemos escribirla fuera del return y pasar el nombre como en el caso de onClick={handleSave}.

Usamos íconos de Font Awesome para los botones (los tags <i>) y también usamos el atributo style en los inputs para sobreescribir los estilos por defecto de Bootstrap, para que el <input> y el <textarea> no se vean como salen generalmente en un formulario. Noten que para escribir CSS usando el atributo style tenemos que escribir las propiedades y sus valores como si fueran objetos de JS y en camelCase en vez de kebab-case como en CSS.

Por ejemplo, si queremos cambiar el color de fondo, en CSS:

p {
  background-color: red;
}

En React

<p style={{ backgroundColor: 'red' }}>Lorem ipsum</p>

Y con esto debería estar terminado el frontend en React. Faltaría compilar y hacer el deploy.

Build & deploy

Antes de hacer el deploy de la app tenemos que compilarla. Hasta ahora la app que estabamos viendo mientras desarrollamos en http://localhost:3000 era una versión de desarrolo. Para compilar la app de React y convertirla en static assets usamos desde el directorio client el script build.

$ npm run build

Esto produce una versión lista para hacer el deploy en el directorio client/build. Podemos usar el index.js de nuestro backend para servir la app. Pero primero creamos un directorio public en nuestro proyecto y movemos todo el contenido de hello-react/client/build a hello-react/public.

Ahora sí, en hello-react/index.js agregamos

// imports
// path es un modulo base de NodeJS, no hay que instalarlo con npm
const path = require('path');
// mas imports ...

// codigo ...

// despues de conectarse a la base de datos
app.use(express.static('public'));
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

Y con esto le decimos a nuestra app de Express que envíe el index.html para la ruta / de nuestro server. Todos los assets estáticos de la app están en public, por eso usamos express.static('public').

Subir esto a Heroku es exactamente igual que lo que hicimos con hello-database.

  1. Suben el código a GitHub
  2. Crean la app en Heroku
  3. Crean una base de datos en MongoDB Atlas
  4. Setean MONGODB_URI en las config vars de Heroku
  5. Conectan el repo de GitHub a su app de Heroku y listo

Y listo con esto terminamos y tenemos nuestra primer web app con el stack MERN.