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

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

En esta segunda parte del tutorial veremos paso a paso como conectar nuestra aplación redux con Firebase

Introducción

En la primer parte de este tutorial, creamos una agenda de contactos, donde el usuario puede añadir nuevos contactos, actualizarlos y borrarlos.

Utilizamos Redux toolkit para la gestión del estado de la aplicación de manera organizada y eficaz. Ahora conectaremos la aplicación con Firebase para una persistencia de datos segura.

Firebase

Ve a tu cuenta de Firebase y crea un nuevo proyecto, yo lo llamé "agenda-contactos".

Como es un proyecto de ejemplo, puedes deshabilidar la opción de Google Analytics, luego haz click en "Crear proyecto".

Ve a "Cloud Firestore", y luego haz click en "Crear base de datos".

Activa el modo "Prueba", luego te pedirá habilitar la ubicación donde se almacenará los datos de Cloud Firestore. Dale habilitar.

Accederás a tu panel de control. Ahora hay que registrar tu app para el proyecto. Haz clic en el ícono de configuración y elige "Configuración de proyecto".

Ve a la sección "Tus apps", aquí debemos registrar la app para nuestra web.

Una vez que registres el nombre de la app tendrás acceso a la configuración del SDK de firebase para tu proyecto.

Esta configuración es la que debemos pegar en el fichero de configuración de firebase, src/firebase/firebase-config.js . Primero instalamos el SDK de firebase con npm i firebase .

import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  authDomain: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  projectId: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  storageBucket: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  messagingSenderId: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  appId: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

export { db };

Además de inicializar la app, accedemos a la base de datos de Cloud Firestore y lo guardamos en const db , luego lo exportamos. Ya tenemos todo listo para comenzar a trabajar con Cloud Firestore.

createAsyncThunk

Es una función proporcionada por Redux Toolkit que simplifica la creación de acciones asincrónicas en una aplicación Redux. Esta función es muy útil cuando necesitas manejar operaciones asincrónicas, como solicitudes a una API, llamadas a bases de datos, o cualquier operación que tome tiempo para completarse.

Vamos a utilizar esta función para generar los "thunks" para cada una de las peticiones asíncronas entre nuestra aplicación y la base de datos en firestore.

Comencemos creando la función para añadir un nuevo contacto. Abre el fichero src/features/contacts/contactsSlice.js y realiza las siguientes modificaciones.

import { createAsyncThunk, createEntityAdapter, createSlice } from "@reduxjs/toolkit"
import { addDoc, collection } from "firebase/firestore"
import { db } from "../../firebase/firebase-config"

export const addNewContact = createAsyncThunk("contacts/addNewContact", async (contact) => {
    const docRef = await addDoc(collection(db, "contacts"), contact)
    return {...contact, id: docRef.id }
})

Realizamos las siguientes importaciones: createAsyncThunk de Redux Toolkit, addDoc y collection de firebase/firestore y la instancia de la conexión de la base de datos db .

Creamos el "thunk" addNewContact con createAsyncThunk . La cadena es un nombre descriptivo de la acción, luego se define una función que recibe un objeto contacto .

Dentro de la función asincrónica, se utiliza addDoc para agregar un documento a la colección "contacts" en Firebase, pasando el objeto contact como datos del documento.

Finalmente, se devuelve un objeto que combina los datos del contacto y el ID del documento, que se utilizará para actualizar el estado de Redux con la información del nuevo contacto.

Ahora dentro de contactsSlice agrega lo siguiente:

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,
    },
    extraReducers(builder){
        builder.addCase(addNewContact.fulfilled, (state, action) => {
            contactsAdapter.addOne(state, action.payload)
        })
    }
})

Este código configura cómo el estado de Redux debe actualizarse cuando la acción asincrónica addNewContact se completa con éxito.

Utilizamos contactsAdapter.addOne para agregar un nuevo contacto a la lista de contactos en el estado de Redux, utilizando los datos proporcionados en action.payload.

Para utilizar el "thunk", simplemente reemplaza el action addContact, por addNewContact.

Abre el fichero src/components/NewContactForm.jsx haz las siguientes modificaciones.

import { useRef } from "react"
import { useDispatch } from "react-redux"
import { addNewContact } 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

        //Reemplazamos el action addContact por addNewContact
        dispatch(addNewContact({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>
        </>
    )
}

Ten en cuenta que ahora la función addNewContact recibe un objeto contact, por eso pasamos los parámetros entre llaves.

Con esto ya deberías ser capaz de añadir un nuevo contacto a la base de datos Firebase y ver el store de la aplicación actualizado.

Sin embargo, tenemos un problema, cuando actualizamos la página, la tabla de contactos aparece vacía. Esto es por que debemos realizar un petición asíncrona para obtener todos los contactos de la base de datos al iniciar la aplicación. Vamos a resolver esto a continuación.

Obtener todos los contactos

Para recuperar todos los datos debemos crear un nuevo "thunk". Justo debajo de addNewContact añade el siguiente código.

export const getContacts = createAsyncThunk("contacts/getContacts", async () => {
    const contactsSnapshot = await getDocs(collection(db, "contacts"))
    return contactsSnapshot.docs.map((doc) => {
        return {
            id: doc.id,
          ...doc.data()
        }
    })
})

Este código define la acción asincrónica en Redux llamada "contacts/getContacts" que se utiliza para recuperar la lista de contactos desde nuestra base de datos. La función asincrónica se encarga de realizar la consulta y formatear los datos antes de devolverlos como resultado.

Ahora debemos integralo a los extraReducers como sigue:

extraReducers(builder){
        builder.addCase(addNewContact.fulfilled, (state, action) => {
            contactsAdapter.addOne(state, action.payload)
        }),
        builder.addCase(getContacts.fulfilled, (state, action) => {
            contactsAdapter.setAll(state, action.payload)
        })
    }

Este código configura cómo el estado de Redux debe actualizarse cuando la acción asincrónica getContacts se completa con éxito. Utilizamos contactsAdapter.setAll para actualizar la lista de contactos en el estado con los datos proporcionados en action.payload.

Para utilizar este "thunk" abrimos el fichero src/App.jsx y lo incorporamos como sigue:

import { useEffect } from "react"
import { ContactList } from "./components/ContactList"
import { NewContactForm } from "./components/NewContactForm"
import { getContacts } from "./features/contacts/contactsSlice"
import { useDispatch } from "react-redux"

function App() {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(getContacts())
  })

  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

Cuando el componente se monta, se llama a la acción asincrónica getContacts utilizando dispatch. Esto inicia el proceso de carga de la lista de contactos desde la base de datos y actualiza el estado global de la aplicación.

Demora un poco en cargar los datos, pero con esto ya tienes sincronizado el store y la base de datos. A continuación seguimos con la acción de borrar un contacto.

Borrar contacto

Creamos un nuevo "thunk" para eliminar un contacto, justo debajo de getContacts agrega el siguiente código:

export const deleteContact = createAsyncThunk("contacts/deleteContact", async (id) => {
    await deleteDoc(doc(db, "contacts", id))
    return id
})

La función asincrónica se encarga de realizar la operación de eliminación en la base de datos y devuelve el id del contacto eliminado como resultado.

await deleteDoc(doc(db, "contacts", id)) se utiliza para eliminar un documento específico de la colección "contacts" en la base de datos.

Ahora debemos integralo a los extraReducers como sigue:

extraReducers(builder){
        builder.addCase(addNewContact.fulfilled, (state, action) => {
            contactsAdapter.addOne(state, action.payload)
        }),
        builder.addCase(getContacts.fulfilled, (state, action) => {
            contactsAdapter.setAll(state, action.payload)
        }),
        builder.addCase(deleteContact.fulfilled, (state, action) => {
            contactsAdapter.removeOne(state, action.payload)
        })
    }

Este código configura cómo el estado de Redux debe actualizarse cuando la acción asincrónica deleteContact se completa con éxito.

Utiliza contactsAdapter.removeOne para eliminar un contacto específico del estado en función del ID proporcionado en action.payload.

Ahora para utilizar el "thunk" abre el fichero src/components/Contact.jsx y reemplaza el action removeContact por deleteContact.

import React, { useState } from "react"
import { deleteContact, 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 = () => {
    //reemplazamos removeContact por deleteContact
    dispatch(deleteContact(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>
      </>
  );
};

Ahora ya podemos eliminar contactos de la base de datos y actualizar el store de la aplicación.

Pasamos a crear el "thunk" para actualizar un contacto.

Actualización de contactos

Creamos un nuevo "thunk" para actualizar un contacto, justo debajo de deleteContact agrega el siguiente código:

export const editContact = createAsyncThunk("contacts/editContact", async (contact) => {
    await updateDoc(doc(db, "contacts", contact.id), contact.changes)
    return contact
})

La acción se llama "contacts/editContact" y se utiliza para editar un contacto en una base de datos. La función asincrónica toma un objeto contact como argumento, que contiene información sobre el contacto que se va a editar. await updateDoc(doc(db, "contacts", contact.id), contact.changes) se utiliza para actualizar un documento específico en la colección "contacts" en la base de datos. Se actualiza el documento con los cambios especificados en contact.changes. Se retorna un objeto contact, que se utilizará para actualiza el estado de la aplicación.

Ahora debemos integralo a los extraReducers como sigue:

extraReducers(builder){
        builder.addCase(addNewContact.fulfilled, (state, action) => {
            contactsAdapter.addOne(state, action.payload)
        }),
        builder.addCase(getContacts.fulfilled, (state, action) => {
            contactsAdapter.setAll(state, action.payload)
        }),
        builder.addCase(deleteContact.fulfilled, (state, action) => {
            contactsAdapter.removeOne(state, action.payload)
        }),
        builder.addCase(editContact.fulfilled, (state, action) => {
            contactsAdapter.updateOne(state, {
                id: action.payload.id,
                changes: action.payload.changes
            })
        })
    }

Este código configura cómo el estado de Redux debe actualizarse cuando la acción asincrónica editContact se completa con éxito. Utiliza contactsAdapter.updateOne para actualizar un contacto específico en el estado en función del id proporcionado en action.payload.

Ahora para utilizar el "thunk" abre el fichero src/components/Contact.jsx y reemplaza el action updateContact por editContact.

import React, { useState } from "react"
import { deleteContact, editContact } 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 = () => {
    //reemplazamos el action updateContact por editContact
    dispatch(editContact({ id: contact.id, changes: {
      firstname: contact.firstname,
      lastname: contact.lastname,
      email: contact.email,
      phone: contact.phone
    } }))
    toggleEdit();
  }

  const handleRemove = () => {
    dispatch(deleteContact(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>
      </>
  );
};

Con esto tenemos todo listo para poder editar un contacto.

Conclusión

Hemos explorado cómo gestionar el estado de la aplicación de manera eficiente con Redux Toolkit y cómo aprovechar Firebase para la persistencia de datos en tiempo real. A lo largo de estas lecciones, hemos aprendido a crear, actualizar y eliminar contactos, así como a recuperarlos de la base de datos. También hemos trabajado con acciones asincrónicas y hemos utilizado Redux Toolkit para simplificar nuestra gestión de estado.

Espero que hayas adquirido un conjunto valioso de habilidades que te permitirán abordar proyectos más grandes y emocionantes en el futuro. La combinación de Redux Toolkit y Firebase ofrece un potente conjunto de herramientas para el desarrollo de aplicaciones web modernas y escalables.

Puedes encontrar el proyecto completo en mi repositorio de GitHub haciendo clic AQUÍ.

Espero que lo hayas encontrado entretenido, instructivo y claro. Si tienes alguna duda, puedes hacérmelo saber en los comentarios. Pronto estaré subiendo más tutoriales.

Nos vemos en la próxima. Saludos!👋😊