Errores comunes al crear formularios

por Camilo OrregoAgosto 22, 202011 minutos

Introducción

Los formularios son uno de los componentes mas comunes en la web, debido a que es allí donde los usuarios ingresan información que permitirá al sistema cumplir con su propósito.

A simple vista, uno podría creer que son fáciles de desarrollar y mantener, pero fácilmente pueden crecer en complejidad y darnos ciertos dolores de cabeza, al punto que no queremos volver a tocar ese código. En este post, analizaremos un formulario y cada problema que este código puede generar.

Nota: para este ejercicio, no se usarán frameworks como React o Vue, pero los conceptos son independientes del framework.

Para validar que el formulario funciona correctamente, usaremos jest y testing-library. Nos enfocaremos solo en 2 casos: formulario enviado correctamente y errores devueltos por el servicio.

Clonar el repositorio desde el siguiente vínculo https://github.com/baldore/common-form-mistakes para ir trabajando el ejercicio.

Setup

  1. Correr los siguientes comandos uno a uno
git clone git@github.com:baldore/common-form-mistakes.git
cd common-form-mistakes
git checkout 00-initial-exercise
  1. Instalamos las dependencias usando yarn install o npm i
  2. Correr yarn test:watch o npm run test:watch

Las pruebas serán nuestra fuente de verdad para ver que nuestro código funciona correctamente. Solamente nos enfocaremos en ver que las pruebas pasen mientras vamos refactorizando el formulario.

Nota: En este post no escribiremos pruebas. Con las que hay actualmente debería ser suficiente para el ejercicio.

Análisis del ejercicio inicial

El ejercicio estará enfocado en el archivo src/gif-form.js, que al momento debe lucir así:

import axios from 'axios'

const SERVER_ROOT = 'http://api.sendagifttosomeone.com'

// Needed for analytics
window.dataLayer = window.dataLayer || []

function renderTemplate(template) {
  const container = document.createElement('div')
  container.innerHTML = template
  return container.children[0]
}

function createForm() {
  const container = renderTemplate(`
    <form>
      <h1>Send Gift</h1>
      <div>
        <label for="email">Email</label>
        <input type="text" id="email" name="email" />
      </div>
      <button>Submit</button>
      <div data-message></div>
    </form>
  `)
  const messageContainer = container.querySelector('[data-message]')

  const onSubmit = async (e) => {
    const form = e.target
    e.preventDefault()

    try {
      const formData = new FormData(form)
      const data = Object.fromEntries(formData)

      await axios.post(`${SERVER_ROOT}/gifts`, data)

      messageContainer.innerHTML = `Form sent successfully`
      window.dataLayer.push({ formId: 'gift', success: true })
    } catch (e) {
      messageContainer.innerHTML = `There was an error sending the request. Please try again.`
      window.dataLayer.push({ formId: 'gift', error: e.message })
    }
  }

  // Event binding
  container.addEventListener('submit', onSubmit)

  return {
    container,
  }
}

export default createForm

A primera vista podemos ver que:

  • El componente funciona al crear un closure con la funcionalidad del formulario.
  • renderTemplate genera el DOM basado en nuestra plantilla.
  • Agregamos los event listeners necesarios para el correcto funcionamiento del formulario.
  • Usamos axios para las peticiones, ya que esta librería nos facilita los llamados REST y maneja correctamente los códigos de error de HTTP (como por ejemplo 500 - Internal Server Error), al manejarlos como un reject en la promesa.

Nota: en el ejercicio no se aborda el tema de validación debido a que, en la mayoría de los casos, para este proceso se pueden usar muy buenas librerías.

El método onSubmit es donde estaremos trabajando y donde está la mayor cantidad de problemas.

Problemas de la implementación actual

1. Integración con la API

Supongamos que en nuestro proyecto estamos trabajando con una persona de backend que se encarga de trabajar en la API. Cuando empezamos a trabajar en este formulario, se acuerda con el desarrollador de backend que se enviará un objeto con la siguiente estructura:

{
  "email": string
}

Pero conforme pase el tiempo, puede que el desarrollador backend deba cambiar la API. Supongamos que después de un tiempo, el endpoint de la API es modificado y ahora recibe este objeto:

{
  "userEmail": string
}

Como solo tenemos un campo, lo que podemos hacer es cambiar email a userEmail y problema resuelto. El problema con esto es que estamos cambiando el formulario para que cumpla con la API. Si más adelante, la API cambia nuevamente, ¿tendremos que cambiar nuevamente el template del formulario?

El formulario y la API deben ser independientes. Al formulario no le debe importar como se estructura la API, excepto cuando se va a enviar la información. Lo que debemos hacer es mapear la información antes de enviarla en la API.

const onSubmit = async (e) => {
  const form = e.target
  e.preventDefault()

  try {
    const formData = new FormData(form)
    const data = Object.fromEntries(formData)
    const requestData = {      email: data.email,    }    await axios.post(`${SERVER_ROOT}/gifts`, requestData)
    messageContainer.innerHTML = `Form sent successfully`
    window.dataLayer.push({ formId: 'gift', success: true })
  } catch (e) {
    messageContainer.innerHTML = `There was an error sending the request. Please try again.`
    window.dataLayer.push({ formId: 'gift', error: e.message })
  }
}

En este caso, si debemos cambiarlo a userEmail, simplemente hacemos el cambio en data.

const data = {
  userEmail: data.email,
}

2. Funciones demasiado grandes

Este problema sucede muy a menudo cuando estamos trabajando en equipo. La función empieza a crecer lentamente mientras cada desarrollador debe agregar algo nuevo, y termina en algo similar a lo que tenemos. Desafortunadamente, lo normal es que los desarrolladores vean el código, pero teman hacer el cambio o no lo hagan porque no es su responsabilidad.

Una función es como un edificio. Si el edificio tiene un par de ventanas rotas y un graffiti, es muy probable que siga siendo objeto de vandalismo, pero si el edificio está bien organizado, es mucho más probable que se conserve organizado.

Si empezamos por la data, vemos que están pasando muchas cosas para obtener la información a enviar a la API. Podemos crear una función que se encargue de esto, tal y como se muestra a continuación:

const getData = () => {  const formData = new FormData(container)  const data = Object.fromEntries(formData)  return {    email: data.email,  }}
const onSubmit = async (e) => {
  const form = e.target
  e.preventDefault()

  try {
    const data = getData()    await axios.post(`${SERVER_ROOT}/gifts`, data)
    messageContainer.innerHTML = `Form sent successfully`
    window.dataLayer.push({ formId: 'gift', success: true })
  } catch (e) {
    messageContainer.innerHTML = `There was an error sending the request. Please try again.`
    window.dataLayer.push({ formId: 'gift', error: e.message })
  }
}

Las funciones son una de las mejores herramientas para tener un código limpio. Al leer const data = getData(), no tenemos que hacer un esfuerzo mental para entender la implementación (como si era necesario antes), y empezamos a ver que nuestro código es un poco más limpio.

Continuemos extrayendo la lógica para success y error. Recordar siempre revisar que las pruebas estén pasando.

const getData = () => {
  const formData = new FormData(container)
  const data = Object.fromEntries(formData)
  return {
    email: data.email,
  }
}

const handleSuccess = () => {  messageContainer.innerHTML = `Form sent successfully`  window.dataLayer.push({ formId: 'gift', success: true })}
const handleError = (error) => {  messageContainer.innerHTML = `There was an error sending the request. Please try again.`  window.dataLayer.push({ formId: 'gift', error: error.message })}
const onSubmit = async (e) => {
  e.preventDefault()
  try {
    const data = getData()
    await axios.post(`${SERVER_ROOT}/gifts`, data)
    handleSuccess()
  } catch (e) {
    handleError(e)
  }
}

Ahora onSubmit luce mucho mejor. Aún hay otras cosas a mejorar. Lo primero es que no deberíamos usar API de librerías de terceros directamente en nuestros componentes.

3. Crear capas para manejar librerías de terceros

Tomemos el ejemplo de analytics: en estos momentos usamos window.dataLayer y agregamos eventos directamente. No hay problema aparente, pero, ¿qué sucedería si en determinado momento se va a utilizar un nuevo servicio de analytics que tiene su propia librería? Tendríamos que ir por todos los componentes que estén usando window.dataLayer y agregar la nueva integración. Por ello y nuevamente, desde un inicio es mejor crear funciones para usar librerías de terceros.

Crearemos una función trackAnalyticsEvent por fuera de createForm y haremos los cambios necesarios.

function trackAnalyticsEvent(event) {  window.dataLayer = window.dataLayer || []  window.dataLayer.push(event)}

Y ahora aplicado en cada punto.

const getData = () => {
  const formData = new FormData(container)
  const data = Object.fromEntries(formData)
  return {
    email: data.email,
  }
}

const handleSuccess = () => {
  messageContainer.innerHTML = `Form sent successfully`
  trackAnalyticsEvent({ formId: 'gift', success: true })}

const handleError = (error) => {
  messageContainer.innerHTML = `There was an error sending the request. Please try again.`
  trackAnalyticsEvent({ formId: 'gift', error: error.message })}

const onSubmit = async (e) => {
  e.preventDefault()
  try {
    const data = getData()
    await axios.post(`${SERVER_ROOT}/gifts`, data)
    handleSuccess()
  } catch (e) {
    handleError(e)
  }
}

Anteriormente, teníamos un código críptico para añadir analytics. Podríamos usar comentarios en cada línea para explicar que hace el código. Sin embargo, este tipo de comentarios son considerados mala práctica e innecesarios si el código es lo suficientemente limpio. Ante este problema, una función bien nombrada puede mejorar la calidad del código.

Podemos ir más allá y mover trackAnalyticsEvent a su propio archivo para ser usado en otros componentes.

// file: src/utils/analytics.js
export function trackAnalyticsEvent(event) {
  window.dataLayer = window.dataLayer || []
  window.dataLayer.push(event)
}

Y en nuestro componente, importamos la función:

import axios from 'axios'
import { trackAnalyticsEvent } from './utils/analytics'

Ahora, si en algún momento necesitamos agregar o cambiar un servicio de analytics, podemos ir directo a src/utils/analytics.js y hacer el cambio para todo el proyecto.

4. Crear servicios para llamados a APIs

Lo último que podemos mejorar son los llamados a la API. El llamado al endpoint que tenemos en estos momentos es suficientemente bueno para lo que necesitamos, pero pensemos en lo siguiente:

  • ¿qué pasaría si otro componente necesita de este endpoint? Tendríamos que duplicar código.
  • ¿qué pasaría si ya no vamos a usar axios sino otra tecnología como graphql? Tendríamos que hacer el cambio en onSubmit y en los otros componentes que lo necesiten.
  • ¿qué pasaría si la API cambia y ya no es /gifts? Tendríamos que ir a buscar estos llamados por todos los archivos y hacer los cambios respectivos.
  • ¿qué pasaría si necesitáramos hacer más peticiones a otros endpoints y procesar esa información? Estaríamos agregando demasiada lógica a la función onSubmit.

Podemos hacer lo mismo que hicimos en analytics. Crearemos una nueva carpeta services, crearemos un archivo gifts.js y exportaremos una función createGift.

// file: src/services/gifts.js
import axios from 'axios'

const SERVER_ROOT = 'http://api.sendagifttosomeone.com'

export async function createGift(postData) {
  await axios.post(`${SERVER_ROOT}/gifts`, postData)
}

Y en nuestro archivo principal.

import { trackAnalyticsEvent } from './utils/analytics'import { createGift } from './services/gifts'
const onSubmit = async (e) => {
  e.preventDefault()
  try {
    const data = getData()
    await createGift(data)    handleSuccess()
  } catch (e) {
    handleError(e)
  }
}

Entre las ventajas de este patrón:

  • El código no depende del cliente http (en este caso axios). Podemos hacer un cambio de http a grapql, gRPC o lo que queramos. Todo es transparente para el componente.
  • Se puede usar la función para hacer un mock sencillo (simplemente retornando el objeto que esperamos).

EXTRA: Remover try / catch

Este paso es más una preferencia personal. Considero que el async / await ha sido una de las mejores adiciones al lenguaje, pero el uso constante de try / catch para reemplazar then / catch me parece un tanto verbose.

Acá expongo una estrategia que puede funcionar (puede quedar oculto en cada método).

Primero, cambiaremos nuestro servicio como se muestra a continuación:

import axios from 'axios'

const SERVER_ROOT = 'http://api.sendagifttosomeone.com'

export async function createGift(postData) {
  return axios    .post(`${SERVER_ROOT}/gifts`, postData)    .then(() => ({ success: true }))    .catch((error) => ({ error }))}

Y así quedaría la función onSubmit.

const onSubmit = async (e) => {
  e.preventDefault()
  const data = getData()  const { success, error } = await createGift(data)
  if (success) handleSuccess()  if (error) handleError(error)}

Conclusiones

Si comparamos el código inicial con el final, podemos ver que nuestro código ha mejorado mucho y que es mucho más fácil de leer.

Resumiendo:

  1. No enviar la información directamente del formulario al endpoint. Son dos elementos independientes. Siempre es mejor organizar el objeto a mandar explícitamente.
  2. Hacer que las funciones sean más pequeñas y comprensibles. Evitar el uso de comentarios para explicar código, es mucho mejor crear una función con un nombre descriptivo.
  3. Dividir funciones también hace más fácil el testing. Podemos probar pequeños fragmentos de código (unit testing) o mockear fácilmente la funcionalidad en módulos que no dependen de la ejecución, pero si del resultado.
  4. Crear capas para manejar librerías de terceros. Evitar la máximo usar directamente librerías de terceros. Es preferible usar funciones.
  5. Crear servicios para llamados a APIs. Podemos decir que esta es una extensión del punto anterior.
  6. Hay formas de evitar el try / catch. Queda a preferencia personal.

Ante cualquier duda, revisar el código final en este link: https://github.com/baldore/common-form-mistakes/tree/final