Agenda de Contactos en ReactJS con Redux y Firebase (Parte 1)

Agenda de Contactos en ReactJS con Redux y Firebase (Parte 1)

Un tutorial paso a paso para crear una aplicación web de gestión de contactos con las tecnologías más populares del desarrollo frontend

Introducción

A lo largo de este tutorial, aprenderás a utilizar ReactJS para crear una interfaz de usuario dinámica y atractiva que permita agregar, editar y eliminar contactos de manera eficiente. Utilizaremos Redux para gestionar el estado de nuestra aplicación de manera ordenada y predecible, y Firebase para almacenar y sincronizar datos en tiempo real de forma segura en la nube.

Consideraciones

Para aprovechar al máximo este tutorial se recomienda tener una experiencia básica con ReactJS y una comprensión básica de los conceptos de Redux, como los 'actions', 'reducers', 'store', etc.

Proyecto

El proyecto consiste en una agenda de contactos, donde el usuario podrá añadir nuevos contactos, actualizarlos y borrarlos. Usaremos Redux toolkit para la gestión del estado de la aplicación de manera organizada y eficaz. Conectaremos la aplicación con Firebase para una persistencia de datos segura.

Inicialización del proyecto y creación de los componentes

Creamos un nuevo proyecto ReactJS con Vite, para ello corremos el siguiente comando:

npm create vite@latest react-redux-firebase -- --template react

Llamé al proyecto react-redux-firebase , entra al directorio y abre el fichero index.html. Vamos a añadir la cdn de bootstrap para los estilos:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Ahora vamos a crea el componente src/components/NewContactForm.jsx y añade el siguiente código:


export const NewContactForm = () => {
    return (
        <>
            <form action="">
                <div className="row mb-3">
                    <div className="col">
                        <input type="text" name="firstname" className="form-control" placeholder="First name" aria-label="First name"/>
                    </div>
                    <div className="col">
                        <input type="text" name="lastname" className="form-control" placeholder="Last name" aria-label="Last name"/>
                    </div>
                </div>
                <div className="row mb-3">
                    <div className="col">
                        <input type="text" name="phone" className="form-control" placeholder="Phone number" aria-label="Phone number"/>
                    </div>
                    <div className="col">
                        <input type="text" name="email" className="form-control" placeholder="E-mail" aria-label="E-mail"/>
                    </div>
                </div>
                <div className="row">
                    <div className="col text-end">
                        <button className="btn btn-primary">GUARDAR</button>
                    </div>
                </div>
            </form>
        </>
    )
}

Tenemos un formulario con cuatro campos: firstname, lastname, phone y email.

Continuamos con el componente src/components/ContactList.jsx :

import { Contact } from "./Contact"

const data = [{
    id: 1,
    firstname: 'Mark',
    lastname: 'Otto',
    phone: '3323233',
    email: 'info@email.com.ar'
},{
    id: 2,
    firstname: 'Jacob',
    lastname: 'Thornton',
    phone: '3323233',
    email: 'info@email.com.ar'
},{
    id: 3,
    firstname: 'Larry',
    lastname: 'Bird',
    phone: '3323233',
    email: 'info@email.com.ar'
}]

export const ContactList = () => {

    return (
        <table className="table">
            <thead>
                <tr>
                    <th scope="col">#</th>
                    <th scope="col">Firstname</th>
                    <th scope="col">Lastname</th>
                    <th scope="col">Phone number</th>
                    <th scope="col">E-mail</th>
                    <th scope="col">Acciones</th>
                </tr>
            </thead>
            <tbody>
                { data.map(contact => (
                    <Contact key={contact.id} data={contact} />
                )) }
            </tbody>
        </table>
    )
}

Este componente consiste en una tabla en el cuál mostramos los datos de los contactos. Por el momento estamos utilizando un array data con datos de ejemplo para recorrerlo y mostrarlos en la tabla.

{ data.map(contact => (
     <Contact key={contact.id} data={contact} />
)) }

El código arriba recorre el array y pasa los contactos al componente Contact.jsx que pasamos a crearlo a continuación.

Crea un componente src/components/Contact.jsx y agrega el siguiente código.

import React, { useState } from "react";

export const Contact = ({ data }) => {
  const [edit, setEdit] = useState(false);

  const [contact, setContact] = useState({
    id: data.id,
    firstname: data.firstname,
    lastname: data.lastname,
    email: data.email,
    phone: data.phone
  });

  const toggleEdit = () => {
    setEdit(!edit);
  };

  let viewMode = {}
  let editMode = {}

  if(edit){
    viewMode.display = "none";
  } else {
    editMode.display = "none";
  }

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setContact({ ...contact, [name]: value });
  };

  const handleSave = () => {
    // Aquí puedes realizar la lógica para guardar los cambios
    console.log("Contacto actualizado:", contact);
    toggleEdit(); // Cambiar el modo de edición
  };

  return (
      <>
        <tr style={viewMode}>
          <th scope="row">{contact.id}</th>
          <td>{contact.firstname}</td>
          <td>{contact.lastname}</td>
          <td>{contact.phone}</td>
          <td>{contact.email}</td>
          <td>
            <button className="btn btn-sm btn-success" onClick={toggleEdit}>
              EDITAR
            </button>
            <button className="btn btn-sm btn-danger">BORRAR</button>
          </td>
        </tr>

        <tr style={editMode}>
          <th scope="row">{contact.id}</th>
          <td>
            <input
              type="text"
              name="firstname"
              className="form-control form-control-sm"
              value={contact.firstname}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <input
              type="text"
              name="lastname"
              className="form-control form-control-sm"
              value={contact.lastname}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <input
              type="text"
              name="email"
              className="form-control form-control-sm"
              value={contact.email}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <input
              type="text"
              name="phone"
              className="form-control form-control-sm"
              value={contact.phone}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <button className="btn btn-sm btn-danger" onClick={toggleEdit}>
              CANCELAR
            </button>
            <button className="btn btn-sm btn-primary" onClick={handleSave}>
              GUARDAR
            </button>
          </td>
        </tr>
      </>
  );
};

Se define el componente Contact como una función que toma un prop llamado data, que contiene la información del contacto.

Se utilizan dos estados de componente, edit y contact, ambos gestionados mediante el hook useState. edit controla si el contacto se está en modo edición o no, mientras que contact almacena los datos del contacto.

La función toggleEdit cambia el valor del estado edit, alternando entre los modos de visualización y edición cuando se hace clic en el botón "EDITAR" o "CANCELAR".

Los objetos viewMode y editMode se utilizan para controlar la visibilidad de las filas de la tabla dependiendo del modo (visualización o edición). Cuando se está en modo edición (edit es true), se establece display: "none" en viewMode, y viceversa.

La función handleInputChange se utiliza para manejar los cambios en los campos de entrada (inputs) del formulario de edición. Actualiza el estado contact cuando los usuarios escriben en los campos de entrada.

handleSave es una función que se ejecuta cuando se hace clic en el botón "GUARDAR" en el modo de edición. En este ejemplo, se muestra un mensaje en la consola con los datos actualizados y se cambia el modo a visualización llamando a toggleEdit().

Finalmente, el componente Contact devuelve dos filas de una tabla. La primera fila muestra los datos del contacto en modo de visualización, mientras que la segunda fila muestra los mismos datos en campos de entrada en modo de edición. Sólo se muestra una fila dependiendo del modo (visualización o edición).

Para terminar, abre el componente src/App.jsx y agreguemos los componentes NewContactForm.jsx y ContactList.jsx :

import { ContactList } from "./components/ContactList"
import { NewContactForm } from "./components/NewContactForm"

function App() {

  return (
    <>
      <main className='container-fluid'>
        <div className='row'>
          <div className='col-12 col-lg-8 mx-auto'>
            <h1 className='display-2 text-center'>AGENDA</h1>
            <hr />
          </div>
        </div>
        <div className='row'>
          <div className='col-12 col-lg-8 mx-auto'>
            <h2 className="display-6 text-center">Nuevo contacto</h2>
            <NewContactForm />
            <hr />
          </div>
        </div>
        <div className='row'>
          <div className='col-12 col-xl-8 mx-auto'>
            <h2 className="display-6 text-center">Contactos</h2>
            <ContactList />
            <hr />
          </div>
        </div>
      </main>
    </>
  )
}

export default App

Para correr el servidor de desarrollo ejecuta el siguiente comando:

npm run dev

En este punto deberíamos tener finalizado la interfaz de nuestra aplicación:

Si hacés click en el botón "EDITAR" podés pasar al modo "edición", si hacés click en el botón "CANCELAR" volvés al modo "visualización". Si modificás alguno de los datos, al presionar el botón "GUARDAR", podés ver por consola los datos actualizados.

A continuación seguimos con la instalación de Redux Toolkit y la configuración del store para nuestra app.

Instalación y configuración de Redux Toolkit

Para añadir el store a nuestra aplicación debemos instalar los siguientes paquetes:

npm install @reduxjs/toolkit react-redux

Ahora creamos un fichero src/store/store.js y configuramos el store:

import { configureStore } from "@reduxjs/toolkit"

export default configureStore({
    reducer: {}
})

Con esto ya tienes configurado el store, añadiremos los 'reducers' más adelante cuando pasemos a crear el slice para los contactos, ahora debemos conectarlo a la aplicación, abre el fichero src/main.jsx :

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import { Provider } from 'react-redux'
import store from './store/store.js'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
)

Mediante el Provider que nos proporciona react-redux pasamos el store a nuestra aplicación. Ahora ya estamos conectados, seguimos con la creación del slice .

Creación del slice contactsSlice

Creamos un fichero src/features/contacts/contactsSlice.js y agregamos el siguiente código:

import { createEntityAdapter, createSlice } from "@reduxjs/toolkit"

const contactsAdapter = createEntityAdapter()

const initialState = contactsAdapter.getInitialState()

export const contactsSlice = createSlice({
    name: "contacts",
    initialState,
    reducers: {
        addContact: {
            reducer: contactsAdapter.addOne,
            prepare(firstname, lastname, phone, email){
                return {
                    payload: {
                        id: Date.now(),
                        firstname,
                        lastname,
                        phone,
                        email,
                    }
                }
            }
        }
    },
})

export default contactsSlice.reducer

export const { addContact } = contactsSlice.actions

export const { selectAll: selectContacts } = contactsAdapter.getSelectors(
    (state) => state.contacts
)

El código comienza importando createEntityAdapter y createSlice de @reduxjs/toolkit.

Se crea un adaptador de entidad llamado contactsAdapter utilizando la función createEntityAdapter. Este adaptador se utiliza para gestionar la entidad de contactos y proporciona funciones reducers predefinidas para agregar, actualizar, eliminar y seleccionar contactos.

Se obtiene el estado inicial del adaptador de entidad utilizando contactsAdapter.getInitialState() y lo guardamos en initialState .

Se utiliza createSlice para crear un slice llamado contactsSlice. Un slice en Redux Toolkit es una forma de definir un conjunto de reducers y actions relacionados con una parte específica del estado de la aplicación.

  • name: Se especifica el nombre del slice como "contacts".

  • initialState: Se utiliza el estado inicial obtenido del adaptador de entidad.

  • reducers: Aquí se define una acción llamada "addContact". Esta acción tiene un reducer que apunta a contactsAdapter.addOne. El reducer se encargará de agregar un nuevo contacto al estado.

  • prepare: Esta es una función que se ejecuta antes de que el reducer realice su trabajo. Prepara los datos que se pasarán al reducer. En este caso, se espera recibir firstname, lastname, phone, y email, y se crea un objeto de payload que incluye estos datos junto con un id generado usando Date.now().

Se exporta el reducer de contactsSlice como default. Esto permite importar este reducer en otros lugares de la aplicación.

Se exporta la acción "addContact" directamente de contactsSlice.actions.

Se utiliza contactsAdapter.getSelectors para crear un selector llamado selectContacts. Este selector se utiliza para obtener todos los contactos del estado de Redux.

Ahora que tenemos los 'reducers' definidos en contactsSlice podemos añadirlo al store. Abrimos el fichero src/store/store.js e importamos los 'reducers' como sigue:

import { configureStore } from "@reduxjs/toolkit"
import contactsReducer from "../features/contacts/contactsSlice"

export default configureStore({
    reducer: {
        contacts: contactsReducer
    }
})

Ahora la configuración del store está completa, continuamos con los 'actions'.

Utilización del 'action' addContact

Ahora vamos a utilizar el 'actions' addContact para añadir un nuevo contacto al store, abrimos el fichero src/components/NewContactForm.jsx y lo modificamos para añadir esta funcionalidad.

import { useRef } from "react"
import { useDispatch } from "react-redux"
import { addContact } from "../features/contacts/contactsSlice"

export const NewContactForm = () => {
    const dispatch = useDispatch()

    const inputFirstname = useRef(null)
    const inputLastname = useRef(null)
    const inputPhone = useRef(null)
    const inputEmail = useRef(null)

    const handleSave = () => {
        const firstname = inputFirstname.current.value
        const lastname = inputLastname.current.value
        const phone = inputPhone.current.value
        const email = inputEmail.current.value

        dispatch(addContact(firstname, lastname, phone, email))

        inputFirstname.current.value = ""
        inputLastname.current.value = ""
        inputPhone.current.value = ""
        inputEmail.current.value = ""
    }

    return (
        <>
            <form action="">
                <div className="row mb-3">
                    <div className="col">
                        <input type="text"
                                name="firstname"
                                className="form-control"
                                placeholder="First name"
                                aria-label="First name"
                                ref={inputFirstname}
                        />
                    </div>
                    <div className="col">
                        <input type="text"
                                name="lastname"
                                className="form-control"
                                placeholder="Last name"
                                aria-label="Last name"
                                ref={inputLastname}
                        />
                    </div>
                </div>
                <div className="row mb-3">
                    <div className="col">
                        <input type="text"
                                name="phone"
                                className="form-control"
                                placeholder="Phone number"
                                aria-label="Phone number"
                                ref={inputPhone}
                        />
                    </div>
                    <div className="col">
                        <input type="text"
                                name="email"
                                className="form-control"
                                placeholder="E-mail"
                                aria-label="E-mail"
                                ref={inputEmail}
                        />
                    </div>
                </div>
                <div className="row">
                    <div className="col text-end">
                        <button type="button" className="btn btn-primary" onClick={handleSave}>GUARDAR</button>
                    </div>
                </div>
            </form>
        </>
    )
}

Se importan las dependencias necesarias para el componente, incluyendo useRef de React para crear referencias a los elementos de entrada del formulario, useDispatch de react-redux para obtener la función dispatch de Redux, y addContact de ../features/contacts/contactsSlice para utilizar la acción que agrega un nuevo contacto al estado de Redux.

Se crean cuatro referencias (inputFirstname, inputLastname, inputPhone, inputEmail) utilizando useRef. Estas referencias se utilizarán para acceder a los valores de los campos de entrada del formulario y borrarlos después de guardar un contacto.

Se define la función handleSave, que se ejecuta cuando se hace clic en el botón "GUARDAR". En esta función:

  • Se obtienen los valores de los campos de entrada del formulario utilizando las referencias creadas (inputFirstname.current.value, inputLastname.current.value, etc.).

  • Se utiliza dispatch para llamar a la acción addContact importada desde contactsSlice. Esta acción agrega un nuevo contacto al estado de Redux utilizando los valores obtenidos de los campos de entrada.

  • Después de agregar el contacto, se borran los valores de los campos de entrada estableciendo inputFirstname.current.value, inputLastname.current.value, etc. a una cadena vacía, lo que vacía los campos del formulario.

Cada campo de entrada utiliza las referencias creadas (ref={inputFirstname}, ref={inputLastname}, etc.) para acceder a sus valores.

Obtenemos los datos desde el store

Abrimos el compoente src/components/ContactList.jsx y realizamos las siguientes modificaciones para recuperar los datos desde el store y mostrarlos en la tabla.

import { useSelector } from "react-redux"
import { Contact } from "./Contact"
import { selectContacts } from "../features/contacts/contactsSlice"

export const ContactList = () => {
    const data = useSelector(state => selectContacts(state))

    return (
        <table className="table">
            <thead>
                <tr>
                    <th scope="col">#</th>
                    <th scope="col">Firstname</th>
                    <th scope="col">Lastname</th>
                    <th scope="col">Phone number</th>
                    <th scope="col">E-mail</th>
                    <th scope="col">Acciones</th>
                </tr>
            </thead>
            <tbody>
                { data.map(contact => (
                    <Contact key={contact.id} data={contact} />
                )) }
            </tbody>
        </table>
    )
}

Se importan las dependencias necesarias, incluyendo useSelector de react-redux para seleccionar datos del estado de Redux y selectContacts de "../features/contacts/contactsSlice" para obtener la lista de contactos del estado de Redux.

Borramos el array data con los datos de ejemplo. Usamos useSelector para seleccionar los datos necesarios del estado de Redux. Por medio de selectContacts que definimos en el contactsSlice.js obtenemos la lista de contactos desde el estado y lo guardamos en la variable data. Finalmente por medio de map recorremos el array data y pasamos cada contacto al componente Contacto para mostrarlos en pantalla.

Como al principio no tenemos datos en el store, la tabla se mostrará vacía. Cuando añadamos un nuevo contacto por medio del formulario, el componente 'ContactList' detectará el cambio en el store y se actualizará mostrando los datos del nuevo contacto.

Por medio de la extensión de Redux DevTools de Chrome podemos ver como se guardan en el store los datos ingresados en el formulario.

A continuación seguimos con la funciones de Editar y Borrar contacto.

Borrar y Actualizar un contacto

Abrimos el fichero src/features/contacts/contactsSlice.js y añadimos el siguiente código:

export const contactsSlice = createSlice({
    name: "contacts",
    initialState,
    reducers: {
        addContact: {
            reducer: contactsAdapter.addOne,
            prepare(firstname, lastname, phone, email){
                return {
                    payload: {
                        id: Date.now(),
                        firstname,
                        lastname,
                        phone,
                        email,
                    }
                }
            }
        },
        removeContact: contactsAdapter.removeOne,
        updateContact: contactsAdapter.updateOne,
    },
})

//...

export const { addContact, removeContact, updateContact } = contactsSlice.actions

Agregamos dos líneas, removeContact: contactsAdapter.removeOne y updateContact: contactsAdapter.updateOne para borrar y actualizar un contacto respectivamente.

Abrimos el componente src/components/Contact.jsx y realizamos las siguientes modificaciones:

import React, { useState } from "react"
import { removeContact, updateContact } from "../features/contacts/contactsSlice"
import { useDispatch } from "react-redux"

export const Contact = ({ data }) => {
  const [edit, setEdit] = useState(false)
  const dispatch = useDispatch();

  const [contact, setContact] = useState({
    id: data.id,
    firstname: data.firstname,
    lastname: data.lastname,
    email: data.email,
    phone: data.phone
  });

  const toggleEdit = () => {
    setEdit(!edit);
  };

  let viewMode = {}
  let editMode = {}

  if(edit){
    viewMode.display = "none";
  } else {
    editMode.display = "none";
  }

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setContact({ ...contact, [name]: value });
  };

  const handleSave = () => {
    dispatch(updateContact({ id:contact.id, changes: contact}))
    toggleEdit();
  }

  const handleRemove = () => {
    dispatch(removeContact(contact.id));
  }

  return (
      <>
        <tr style={viewMode}>
          <th scope="row">{contact.id}</th>
          <td>{contact.firstname}</td>
          <td>{contact.lastname}</td>
          <td>{contact.phone}</td>
          <td>{contact.email}</td>
          <td>
            <button className="btn btn-sm btn-success" onClick={toggleEdit}>
              EDITAR
            </button>
            <button className="btn btn-sm btn-danger" onClick={handleRemove}>BORRAR</button>
          </td>
        </tr>

        <tr style={editMode}>
          <th scope="row">{contact.id}</th>
          <td>
            <input
              type="text"
              name="firstname"
              className="form-control form-control-sm"
              value={contact.firstname}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <input
              type="text"
              name="lastname"
              className="form-control form-control-sm"
              value={contact.lastname}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <input
              type="text"
              name="email"
              className="form-control form-control-sm"
              value={contact.email}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <input
              type="text"
              name="phone"
              className="form-control form-control-sm"
              value={contact.phone}
              onChange={handleInputChange}
            />
          </td>
          <td>
            <button className="btn btn-sm btn-danger" onClick={toggleEdit}>
              CANCELAR
            </button>
            <button className="btn btn-sm btn-primary" onClick={handleSave}>
              GUARDAR
            </button>
          </td>
        </tr>
      </>
  );
};

Se importan las dependencias necesarias, removeContact y updateContact de "../features/contacts/contactsSlice" para utilizar las acciones relacionadas con contactos definidas en el contactsSlice.js, y useDispatch de react-redux para obtener la función dispatch de Redux.

Modificamos la función handleSave para que al hacer clic en el botón "GUARDAR" utilice dispatch para llamar a la acción updateContact, que actualiza los datos del contacto en el estado global de Redux con los cambios realizados en el estado contact. Luego, se llama a toggleEdit para cambiar el modo de edición de vuelta a la de visualización. El 'action' updateContact recibe un objeto con dos campos: id que hace referencia al registro que vamos a actualizar y changes con los datos actualizados.

Creamos la función handleRemove. Esta función se llama cuando se hace clic en el botón "BORRAR". Utiliza dispatch para llamar a la acción removeContact, que elimina el contacto del estado global de Redux utilizando su id.

Finalmente vínculamos estas funciones con sus correspondientes botones, "GUARDAR" y "BORRAR", para que al hacer click, ejecute la función correspondiente.

Conclusión

¡Hemos completado la primera parte de este tutorial! En esta etapa, hemos construido con éxito la interfaz frontal de nuestra aplicación. En la segunda parte, exploraremos cómo conectar nuestra aplicación con Firebase y realizaremos las adaptaciones necesarias en el slice para habilitar las peticiones asincrónicas a través de redux-thunk de Redux Toolkit.

Espero verte en la siguiente entrega. ¡Saludos y hasta pronto! 👋😊