Skip to content
En esta página

Formularios en Vue

Hay distintas liberías que se pueden usar para la creación de formularios en Vue, como por ejemplo:

VueForm
Vuelidate
VeeValidate
FormKit

El problema con el uso de librerías es que abultan nuestro bundle final por la cantidad de código que contienen. Por otro lado, la documentación de estas librerías muchas veces es compleja o confusa. Suele ocurrir que lleve más tiempo aprender a usar la librería que implementar nuestra propia solución.

Otro problema que tiene el uso de librerías para forms es que suelen usar un sólo componente que engloba todo el formulario (por ejemplo, el componente VueForm). Entonces, toda la lógica del data binding, de las validaciones, los mensajes de error y el envío del formulario debe estar concentrada en ese único componente (excepto en el caso de FormKit, que permite la componentización de los campos del formulario). El problema de esto es que a medida que se agregan campos y validaciones el componente se va agrandando hasta tomar dimensiones descomunales y se vuelve muy difícil entender su lógica 😬️

Y al usar librerías para formularios también está el problema del layout shift. Eso ocurre cuando la aparición de los mensajes de error por debajo de cada campo hace que todos los campos del formulario cambien abruptamente de lugar, lo cual resulta muy molesto y desorientador para el usuario, como en este ejemplo que usa Bootstrap + VueForm. Fíjense lo que ocurre al cliquear en Enviar sin completar los campos. Este layout shift se podría evitar usando tooltips para los errores, como vamos a ver a continuación (o con animaciones para que el layout shift resulte menos molesto, que es la solución usada por FormKit).

Por otro lado, usar las validaciones de las librerías de formularios no hace que nuestra aplicación sea más segura porque en el frontend el código siempre está expuesto en las DevTools del browser (por ejemplo, en Chrome, entrando en la pestaña Source) y puede ser modificado por el usuario (si sabe algo de JavaScript, claro). Es por eso que, cuando el frontend envía un formulario al backend, el backend siempre vuelve a hacer una nueva validación. La validación del backend, al no estar expuesta en el browser, no puede ser alterada (a menos que logremos hackear el servidor, lo cual no es nada fácil).

Las validaciones que se hacen en el frontend sirven más que nada para avisarle al usuario que está cometiendo errores (por ejemplo, que olvidó poner .com luego de @gmail) pero no para evitar hackeos, simplemente porque es imposible que puedan hacerlo. Para eso está el backend.

Formularios en HTML

En los buenos viejos tiempos crear formularios era muy fácil. Ni siquiera hacía falta usar JavaScript!

html
<form action="/form.php" method="post">

  <label for="nombre">Nombre:</label>
  <input type="text" id="nombre" name="nombre" required>
  
  <label for="apellido">Apellido:</label>
  <input type="text" id="apellido" name="apellido" required>
  
  <label for="email">Email:</label>
  <input type="email" id="email" name="email" required>
  
  <input type="submit" value="Enviar">
</form>

Parece todo muy simple, excepto por un detalle: ¿qué es ese archivo form.php dentro de la action? Lo que hay dentro de esa action es una petición al servidor en el path donde se encuentra el archivo form.php, y ese archivo contiene código en lenguaje PHP del lado del backend, no del frontend, que procesa la información del formulario, realiza las validaciones correspondientes y, si las validaciones no dan error, responde desde el servidor con un mensaje diciendo que la información fue recibida correctamente.

Entonces, para que este formulario funcione, tendríamos que:

  1. Crear una aplicación de backend en PHP que procese la información del formulario, realice las validaciones y emita una respuesta.

  2. Generar una base de datos donde guardar la información y acceder a esta base de datos desde nuestra aplicación de backend.

  3. Configurar la base de datos con el schema de información correspondiente.

  4. Contratar un VPS (Virtual Private Server) donde alojar nuestra aplicación de backend y nuestra base de datos.

  5. Configurar el VPS con Nginx o Apache o Windows Server.

  6. Configurar el ruteo del servidor para que el frontend y el backend compartan un mismo dominio, de lo contrario tendríamos un error de CORS al intentar hacer una petición POST a un dominio diferente al del frontend.

Como ven, detrás de la aparente simplicidad del formulario HTML se esconde una complejidad enorme.

Pero, ¿cómo hacemos si queremos simplificar esta complejidad usando un Backend as a Service como Firebase o Supabase? Este tipo de servicios ofrecen un backend ya armado, que podemos configurar de forma relativamente fácil en una GUI (graphic user interface).

Luego, para acceder al backend desde nuestro frontend tenemos que usar JavaScript (con un fetch de tipo POST), no podemos accederlos simplemente con un form en HTML.

Y manejar formularios con JavaScript es más simple si usamos un framework de frontend como Vue.

Formulario en VueForm

La razón por la que en Vue se suele usar un sólo componente para todo el formulario es porque es más fácil sincronizar los v-model de cada campo del formulario con el objeto data si está todo dentro del mismo componente.

El problema es que, a medida que agregamos nuevos campos, el componente se vuelve kilométrico.

Acá pueden ver un ejemplo de un formulario usando la librería VueForm.

Como ven, solamente para validar 4 campos (nombre, apellido, edad y mail) es necesario un componente de más de 200 líneas de código 😬️ Imagíńense si tuviesemos 10 campos, con passwords, fechas, checkboxes, radio inputs...

Formulario sin librerías

Ahora intentemos crear un formulario sin librerías, dividiendo cada campo en un componente aparte.

Primero creamos el componente principal del formulario, FormComponent:

html
<template>
  <form @submit.prevent="">
    
    <h1>Formulario</h1>

    <button type="submit">Enviar</button>

  </form>
</template>

El evento @submit.prevent es provisto por Vue para poder usar elementos <form> evitando su comportamiento por defecto, o sea, la recarga de la página. Es como el event.preventDefault() de JavaScript nativo.

Más adelante le vamos a agregar un método que sea disparado al enviar el formulario.

Store para el formulario

Luego, en un archivo .js aparte (no .vue) creamos una store para el formulario. Esto nos va a facilitar la comunicación entre componentes sin tantos props y emits. Además, podemos concentrar la lógica de las validaciones en la store sin abultar el componente del formulario.

js
// src/stores/formStore.js

export default {

  // STATE
  form: {
    firstname: '',
    lastname: '',
    email: '',
    password: ''
  },

  // Booleano para mostrar mensajes de error
  // si el usuario cliqueó en enviar pero no lo completó bien
  submitted: false,

  // Array para guardar los formularios completados
  users: [],

  // ACTIONS
  submitForm() {
    this.submitted = true
  },

  resetForm() {
    // Para resetear el formulario hay que hacer 
    // que todos los keys del objeto this.form 
    // pasen a ser Strings vacíos
    Object.keys(this.form).forEach(key => this.form[key] = '')
    this.submitted = false
  }
}

Por ahora dejamos el método submitForm() así. Luego lo vamos a completar.

Íconos SVG

Luego, dentro de la carpeta src/components/icons, creamos un componente que contenga un ícono svg para el campo del nombre:

html
<template>
  <svg viewBox="3 3 18 18">
    <path d="M8 9C8 6.79086 9.79086 5 12 5C14.2091 5 16 6.79086 16 9C16 11.2091 14.2091 13 12 13C9.79086 13 8 11.2091 8 9ZM15.8243 13.6235C17.1533 12.523 18 10.8604 18 9C18 5.68629 15.3137 3 12 3C8.68629 3 6 5.68629 6 9C6 10.8604 6.84668 12.523 8.17572 13.6235C4.98421 14.7459 3 17.2474 3 20C3 20.5523 3.44772 21 4 21C4.55228 21 5 20.5523 5 20C5 17.7306 7.3553 15 12 15C16.6447 15 19 17.7306 19 20C19 20.5523 19.4477 21 20 21C20.5523 21 21 20.5523 21 20C21 17.2474 19.0158 14.7459 15.8243 13.6235Z">
    </path>
  </svg>
</template>

<style scoped>

svg {
  fill: #555;
  width: 1rem;
  height: 1rem;
}
  
</style>

Usar los svg como componentes de Vue tiene muchas ventajas.

Primero que no abultan el template con todos los datos de los vectores, lo que hace que el template se vuelva confuso y difícil de desentrañar:

html
<template>
  <div class="card">

    <!-- POR FAVOR NO HAGAN ESTO 🙏️🙏️🙏️ -->
    
    <svg viewBox="0 0 24 24" fill="none">
      <g id="heart-icon">
        <path 
          id="Vector" 
          d="M19.2373 6.23731C20.7839 7.78395 20.8432 10.2727 19.3718 11.8911L11.9995 20.0001L4.62812 11.8911C3.15679 10.2727 3.21605 7.7839 4.76269 6.23726C6.48961 4.51034 9.33372 4.66814 10.8594 6.5752L12 8.00045L13.1396 6.57504C14.6653 4.66798 17.5104 4.51039 19.2373 6.23731Z" 
          stroke-width="2" 
          stroke-linecap="round" 
          stroke-linejoin="round" 
          style="paint-order: fill; fill: rgb(255, 255, 255); stroke: rgb(255, 255, 255);">
        </path>
      </g>
    </svg>
    <img :src="product.imgsrc" :alt="product.title">
    <header><h4>{{ product.title }}</h4><p>$ {{ product.price }}</p></header>
    <footer>
      <button @click="addToCart">
        <svg viewBox="0 0 40 40" width="40" height="40">
          <g id="Layer_2" data-name="Layer 2">
            <g id="invisible_box" data-name="invisible box">
            <rect width="30" height="30" fill="none"></rect>
          </g>
          <g id="cart-icon" data-name="cart-icon">

            <!-- un path larguisimo abultando el markup... 🤦‍♂️️ -->

            <path d="M44.3,10A3.3,3.3,0,0,0,42,9H35a2,2,0,0,0-2,2h0a2,2,0,0,0,2,2h5.8L38.5,27H13.6L12,13h7a2,2,0,0,0,2-2h0a2,2,0,0,0-2-2H11.5l-.4-3.4A3,3,0,0,0,8.1,3h-3A2.1,2.1,0,0,0,3,4.7,2,2,0,0,0,5,7H7.2l3.2,26.9a5.9,5.9,0,0,0-2.8,6.3,6.1,6.1,0,0,0,4.6,4.7,6.2,6.2,0,0,0,7-3.9H29.8a6,6,0,1,0,5.7-8,6.2,6.2,0,0,0-5.7,4H19.2a6,6,0,0,0-4.9-3.9L14.1,31H39.4a3,3,0,0,0,2.9-2.6L45,12.6A3.6,3.6,0,0,0,44.3,10ZM35.5,37a2,2,0,1,1-2,2A2,2,0,0,1,35.5,37Zm-20,2a2,2,0,1,1-2-2A2,2,0,0,1,15.5,39Z"></path>
            <path d="M20.4,16.8a2.1,2.1,0,0,0,.2,2.7l5,4.9a1.9,1.9,0,0,0,2.8,0l5-4.9a2.1,2.1,0,0,0,.2-2.7,1.9,1.9,0,0,0-3-.2L29,18.2V9a2,2,0,0,0-4,0v9.2l-1.6-1.6A1.9,1.9,0,0,0,20.4,16.8Z"></path>
          </g>
        </g>
        </svg>
      </button>
    </footer>
  </div> 
</template>

Y segundo que al usar los íconos como componentes podemos modificar más fácilmente sus atributos, como los estilos de CSS. Incluso le podemos pasar props desde el componente padre para modificarlos, o usar data y computed para hacer animaciones 🥳️

En Google Fonts pueden encontrar una gran variedad de íconos SVG para descargar.

Recuerden que para trabajar con SVG deben primero entender su funcionamiento, sobre todo el tema de la viewBox que suele dar bastantes dolores de cabeza 🥵️

Componente para el nombre

Luego creamos un componente para el input del nombre dentro de la carpeta src/components/inputs en el que importamos el ToolTip y el ícono.

Importamos también la store y la insertamos en data. De esta forma, el v-model del input queda sincronizado con la propiedad formStore.form.nombre de la store. Para verificar que estén sincronizados incluímos un elemento <pre> que muestre la data que viene de la store:

html
<template>
  <div>

    <!-- Al importar el ícono SVG como un componente
    el markup es mucho más claro 🙂️ -->

    <i><UserIcon/></i>

    <input
      type="text" 
      v-model="formStore.form.firstname" 
      placeholder="Nombre(s)"
    />
    <span>*</span>

    <pre>{{ formStore.form.firstname }}</pre>

  </div>
</template>

<script>
import UserIcon from '../icons/UserIcon.vue'
import formStore from '@/stores/formStore'

export default {

  name: 'FirstNameInput',

  components: {
    UserIcon
  },

  data: () => ({
    formStore
  })
}
</script>

Y ahora ya podemos importar el componente del nombre y la store en el formulario:

html
<template>
  <form @submit.prevent="formStore.submitForm()">
    
    <h1>Formulario</h1>

    <FirstNameInput/>

    <button type="submit">Enviar</button>

  </form>
</template>

<script>
import FirstNameInput from './components/FirstNameInput.vue'
import formStore from './stores/formStore'

export default {

  name: 'FormComponent',

  components: {
    FirstNameInput
  },

  data: () => ({ 
    formStore // Insertamos el objeto formStore en data
  })
}

</script>

Y listo, ya tenemos nuestro componente para el nombre 🥳️

Podemos probar que el input esté sincronizado con la store escribiendo algo dentro:

Formulario

*

* Estos campos son obligatorios.

v-model + Store

Funciona, pero tiene un problema: en el v-model del input estamos quebrando una de las reglas del patrón Flux: la store debe ser el único lugar donde ocurren las mutaciones.

Pero por la forma en que funciona v-model, cada vez que el valor del input cambia v-model produce una mutación directa en la store:

html
<input
  type="text" 
  v-model="formStore.form.firstname" 
  placeholder="Nombre(s)"
/>

En realidad podríamos dejarlo así, porque funciona. Pero si los inquisidores del patrón Flux ven esto dirían que es un error gravísimo 🙄️

De hecho, en la documentación oficial de Vuex advierten sobre el problema de usar v-model con stores. Así que vamos a seguir las instrucciones de la documentación para hacerlo en forma correcta 🤷‍♂️️

En la store agregamos un método que genere la mutación. Este método va a tomar dos params, uno para el campo del formulario que será mutado (field) y otro con el nuevo valor que viene del v-model del input (newValue):

js
// ACTIONS
submitForm() {
  this.submitted = true
},

// Setter para v-model
setNewValue(field, newValue) { 
  this.form[field] = newValue 
},

// etc...

Luego, en el componente del input agregamos una computed con un get (obtener valor) y un set (cambiar valor). El get va a retornar el estado de la store y el set va a invocar al método de mutación que acabamos de crear en la store (setNewValue(field, newValue))

js
export default {

  name: 'FirstNameInput',

  components: {
    UserIcon
  },
  data: () => ({
    formStore
  }),
  computed: {
    firstname: {
      get() {
        return this.formStore.form.firstname
      },
      set(newValue) {
        this.formStore.setNewValue('firstname', newValue)
      } 
    }
  }
}

Y finalmente cambiamos el v-model para que sincronice con la computed y no directamente con la store:

html
<input
  type="text" 
  v-model="firstname" 
  placeholder="Nombre(s)"
/>

Y listo, los fiscales del patrón Flux ya no nos pueden acusar de nada 🤜️

Es un poco engorroso hacerlo de esta forma, pero al menos así nos vamos acostumbrando a un modo de trabajo que al usar Vuex va a ser inevitable (porque Vuex tira un mensaje de error cuando se hacen mutaciones directas).

Validaciones

Ahora podemos agregar algunas validaciones para el input del nombre: para chequear que no esté vacío, que el nombre tenga al menos 2 letras (o que no tenga más de 20) y otra para chequear que no incluya caracteres inválidos.

Para chequear los caracteres usamos una Regular Expression que sólo acepta letras, incluyendo las tildes y la eñe. Confieso que esta RegEx no la hice yo 🤷‍♂️️ la encontré en Stack Overflow. Como verán, el sistema de las RegEx es bastante complejo y muy difícil de hacer a mano.

Estas validaciones las vamos a agregar a la store del formulario:

js
// Regular expression para letras con tildes y eñe:
const letrasRegEx = /^[a-zA-ZñÑáéíóúÁÉÍÓÚ]+$/;

export default {

  // STATE
  form: {
    firstname: '',
    lastname: '',
    email: '',
    password: ''
  },

  users: [],

  submitted: false,

  // Validaciones

  emptyField() {
    // Si el usuario cliqueó en enviar pero no ingresó el nombre
    // retornar un mensaje de error
    if (this.submitted && !this.form.firstname) return 'Este campo es obligatorio.'
    else return ''
  },

  tooShort() {
    // Si son menos de dos letras, mostrar error
    if (
      this.submitted && 
      this.form.firstname && 
      this.form.firstname.length < 2
    ) return 'El nombre debe contener al menos 2 letras.'
    else return ''
  },

  tooLong() {
    // Si son más de 20 letras, mostrar error
    if (
      this.submitted && 
      this.form.firstname && 
      this.form.firstname.length > 20
    ) return 'El nombre no debe contener más de 20 letras.'
    else return ''
  },

  invalidChars() {
    if (
      this.submitted &&
      this.form.firstname &&
      !letrasRegEx.test(this.form.firstname)
    ) return 'El nombre sólo debe contener letras.' 
    else return ''
  },
  
  checkFields() {
    // Si no hay errores, retorna true
    return (
      !this.emptyField() && 
      !this.tooShort() &&
      !this.tooLong() &&
      !this.invalidChars()
    )
  },
  
  // ACTIONS
  submitForm() {
    this.submitted = true
    // Si no hay errores checkFields() retorna true
    // y se agrega el contenido del formulario al array de usuarios
    if (this.checkFields()) {
      this.users.push({ ...this.form })
      this.resetForm()
    } else {
      // Si hay errores hace un scroll hacia arriba
      // para que el usuario vea los mensajes de error
      window.scroll(0, 0)
    }
  },
  setNewValue(field, newValue) {
    this.form[field] = newValue
  },
  resetForm() {
    Object.keys(this.form).forEach(key => this.form[key] = '')
    this.submitted = false
  }
}

Array.push() + Spread operator

Fíjense que para hacer el push del formulario completado dentro del array de usuarios no lo estamos haciendo en forma directa sino que usamos un spread operator:

js
// Esto no va a funcionar ❌️
this.users.push(this.form)
this.resetForm()

// Con spread operator: push({ ...objeto }) ✅️ 
this.users.push({ ...this.form })
this.resetForm()

¿Por qué el push normal no funciona? Porque al ejecutarse resetForm() todos los campos de this.form se vacían, y aunque this.form esté dentro del array de usuarios sigue siendo el mismo objeto, entonces, la información del formulario dentro del array de usuarios también se vacía.

Esto es porque en JavaScript los objetos son asignados por referencia 🤔️

Sí, sé que suena complicado, pero acá pueden encontrar una explicación muy clara sobre el tema.

En cambio, al usar el spread operator el objeto es clonado y pasa a funcionar como un objeto independiente.

De todas formas, hay que tener en cuenta que esto funciona solamente para objetos simples, en los que todas las propiedades son valores primitivos (como es el caso de this.form en el que todas las propiedades son de tipo String). Pero en objetos que tienen como propiedades otros objetos anidados, el spread operator tiene algunas limitaciones:

js
form: {
  email: 'mail@mail.com'
  direccion: {
    calle: '13'
  }
}

const array = []

// Shallow Clone con Spread Operator
array.push({ ...form })

form.email = '' // email se borra en el objeto original pero no en el array
form.direccion.calle = '' // calle se borra en ambos 🤔️

console.log(array) 
// [{email: 'mail@mail.com', direccion: { calle: '' }}]

Esto sucede por la misma razón por la que el primer push simple fallaba: lo que se copió no fue el contenido de direccion sino una referencia a direccion 🤷‍♂️️

En fin... cosas raras que tiene JavaScript. Si les interesa el tema les recomiendo que lean este artículo en javascript.info.

💡️

En el caso de que necesiten clonar un objeto con propiedades anidadas deben usar el método structuredClone o el método cloneDeep() de lodash.

Tooltip para mensajes de error

Ahora vamos a crear un componente ToolTip para mostrar los mensajes de error. El componente recibe una prop con el error y sólo se muestra si error contiene algo:

html
<template>
  <div class="tooltip" v-show="error">
    <p>{{ error }}</p>
  </div>
</template>

<script>
export default {

  name: 'ToolTip',

  props: {
    error: String
  }
}
</script>

<style scoped>

.tooltip {
  min-width: 16rem; 
  top: -0.5rem;
  left: 20rem;
  transform: translate(-50%, -100%);
  padding: 0.5rem 1rem;
  background-color: #cc0000;
  border-radius: 5px;
  position: absolute;
  z-index: 100;
  box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}

.tooltip > p {
  color: snow;
  font-size: 1rem;
}

/* Flecha del Tooltip */
.tooltip::after {
  content: '';
  position: absolute;
  top: 100%; /* Por debajo del Tooltip */
  left: 50%;
  margin-left: -5px;
  border-width: 0.5rem;
  border-style: solid;
  border-color: #cc0000 transparent transparent transparent;
}

</style>

Y en el componente del input agregamos una computed que retorna los mensajes de error de la store y los muestra en ToolTip

html
<template>
  <div>

    <i><UserIcon/></i>

    <input
      type="text" 
      v-model="firstname" 
      placeholder="Nombre(s)"
    />
    <span>*</span>

    <ToolTip v-show="error" :error="error"/>

  </div>
</template>

<script>
import UserIcon from '../icons/UserIcon.vue'
import ToolTip from './ToolTip.vue'

import formStore from '@/stores/formStore'

export default {

  name: 'FirstNameInput',

  components: {
    UserIcon, ToolTip
  },

  data: () => ({
    formStore,
  }),

  computed: {
    error() {
      return (
        this.formStore.emptyField() ||
        this.formStore.tooShort() ||
        this.formStore.tooLong() ||
        this.formStore.invalidChars()
      )
    },
    nombre: {
      get() {
        return this.formStore.form.firstname
      },
      set(newValue) {
        this.formStore.setNewValue('firstname', newValue)
      } 
    }
  }
}
</script>

En la computed los mensajes de error están ordenados por prioridad y separados por un operador OR, o sea: ||.

Por ejemplo, si el usuario ingresó una sola letra la primera validación (this.formStore.emptyField()) retorna false (porque el input no está vacío), entonces pasa a la siguiente. Como hay una sola letra la segunda validación (this.formStore.tooShort()) retorna el mensaje de error y éste se muestra en el ToolTip. Y así con cada una de las cuatro validaciones.

Y ahora, si le damos ENVIAR pero no hay nada en el input, o hay una sola letra, o hay más de 20, o hay caracteres inválidos, sale el mensaje de error en el ToolTip:

Formulario

*

* Estos campos son obligatorios.

[]

Si el input es correcto, se agrega al array de usuarios. Para verificar que se agregó, al cliquear en Enviar mostramos el array en bruto en un elemento <pre> ⬆️

Input del apellido

Ahora podemos agregar el input del apellido. Las validaciones para el apellido van a ser exactamente las mismas. Podríamos simplemente copiar y pegar las validaciones y solamente reemplazar donde diga this.formStore.nombre por this.formStore.apellido 🦥️

Sí, funciona, pero no lo vamos a hacer. Tenemos que mantener nuestro código lo más DRY posible (Don't Repeat Yourself).

El problema es que si reutilizamos las cuatro validaciones para el nombre que creamos dentro de la store, si hay un error en el nombre pero el apellido está bien el mensaje de error se va a mostrar en ambos campos ☹️

Es que justamente así funciona una store, sirve para mantener sincronizado el estado entre varios componentes. Pero en el caso de las validaciones, cada validación tiene que tener su propio estado. Por ejemplo, si el nombre está mal pero el apellido está bien, que sólo muestre un mensaje de error para el nombre, no para los dos.

Lo que podríamos hacer es crear una store distinta para cada uno de los campos del formulario, pero sería demasiado repetitivo...

Entonces, el patrón store no nos va a servir para las validaciones 😕️

Y, ¿cómo podemos hacer para reutilizar el código de las validaciones pero mantener un estado distinto para cada input? Usando class.

Validaciones con class

Las clases se pueden usar para crear distintos objetos a partir de un mismo prototipo. A diferencia de las stores, que tienen un sólo estado compartido por cualquier método que acceda a sus propiedades, las instancias de las clases tienen cada una su propio estado, aunque los métodos internos sean exactamente los mismos.

Para empezar, creamos un nuevo archivo .js (no .vue) que puede estar en la misma carpeta donde tenemos la store. Lo podemos llamar validationClass.js:

js
const letrasRegEx = /^[a-zA-ZñÑáéíóúÁÉÍÓÚ]+$/;

export class Validation {

  // En el constructor van los params que toman
  // las instancias de la clase
  constructor (input, submitted) {
    this.input = input
    this.submitted = submitted
  }

  emptyField() {
    if (this.submitted && !this.input) return 'Este campo es obligatorio.'
    else return ''
  }
  
  tooShort() {
    if (
      this.submitted && 
      this.input && 
      this.input.length < 2
    ) return 'Este campo debe contener al menos 2 letras.'
    else return ''
  }

  tooLong() {
    if (
      this.submitted && 
      this.input && 
      this.input.length > 20
    ) return 'Este campo no debe tener más de 20 letras.'
    else return ''
  }

  invalidChars() {
    if (
      this.submitted && 
      this.input && 
      !letrasRegEx.test(this.input)
    ) return 'Este campo sólo debe contener letras.'
    else return ''
  }
  
  noErrors() {
    return !this.emptyField() && !this.tooShort() && !this.invalidChars()
  }
}

Y ahora, dentro de la store importamos la clase Validation y la instanciamos para cada uno de los dos inputs con los dos params: uno para el contenido del input y otro para el booleano que nos dice si el formulario fue enviado o no.

js
import { Validation } from './validationClass'

export default {

  // STATE
  form: {
    firstname: '',
    lastname: '',
    email: '',
    password: ''
  },
  users: [],
  submitted: false,

  // Validaciones
  firstnameErrors() {
    return new Validation(this.form.firstname, this.submitted)
  },
  lastnameErrors() {
    return new Validation(this.form.lastname, this.submitted)
  }

  checkFields() {
    return (
      this.firstnameErrors().noErrors() &&
      this.lastnameErrors().noErrors()
    )
  },
  
  // ACTIONS
  // Esto lo dejamos igual
}

Y ahora ya podemos validar los dos campos por separado, reutilizando las mismas validaciones:

Formulario

*
*

* Estos campos son obligatorios.

[]

Y si no hubo errores chequeamos el array para ver si la data se agregó al clickear en Enviar.

Don't Repeat Yourself

Si se fijan en el ejemplo de la librería VueForm van a ver que las validaciones son muy repetitivas y abultan el componente. Haciéndolo de esta forma evitamos esas repeticiones y, por otro lado, hacemos que el código quede modularizado, es decir, repartido en distintos módulos interconectados, en vez de tenerlo todo en un componente monolítico.

Input del email

Para el input del email tenemos que agregar una validación más, para verificar que el formato de email sea correcto. Se podría decir que esto es innecesario ya que HTML nativo hace validaciones automáticas de los mails cuando usamos el atributo type="email":

Pero como verán, la única validación que hace el browser por sí solo es verificar que haya una @ entre medio de dos caracteres alfanuméricos. Si por ejemplo ingresamos mail@ muestra el mensaje de error, pero si ponemos mail@1 no muestra nada... Entonces, sería mejor tener nuestra propia validación.

En el archivo de la class agregamos una extensión de la clase Validation:

js
const emailRegEx = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,6}$/;

export class EmailValidation extends Validation {
  
  constructor (input, submitted) {
    super(input, submitted)
  }
  
  invalidEmail() {
    // El usuario ingreso un mail, pero el formato es incorrecto
    // En lugar de if-else se puede usar un operador ternario:
    // condicion ? true : false;
    return this.input && !emailRegEx.test(this.input) ? 
      'Formato de e-mail inválido.' : 
      '';
  }

  noEmailErrors() {
    return !this.emptyField() && !this.invalidEmail()
  }
}

Recuerden que para heredar los params de la clase madre, el constructor de la clase hija tiene que usar el método super:

js
constructor (input, submitted) {
  super(input, submitted)
}

Y así podemos reutilizar las validaciones de la clase madre, como this.emptyField() sin tener que repetirlas 🥳️

Y ahora creamos un nuevo componente para el input del mail:

html
<template>
  <div class="input-field">

    <i><EmailIcon/></i>

    <input
      type="text" 
      v-model="email" 
      placeholder="email@email.com"
    />
    <span>*</span>

    <ToolTip v-show="error" :error="error"/>

  </div>
</template>

<script>
import EmailIcon from './icons/EmailIcon.vue'
import ToolTip from './icons/ToolTip.vue'

import formStore from '../stores/formStore'

export default {

  components: {
    EmailIcon, ToolTip
  },

  data: () => ({
    formStore
  }),

  computed: {
    error() {
      return (
        this.formStore.emailErrors().emptyField() ||
        this.formStore.emailErrors().invalidEmail()
      )
    },
    email: {
      get() {
        return this.formStore.form.email
      },
      set(newValue) {
        this.formStore.setNewValue('email', newValue)
      } 
    }
  },
}

</script>

Y lo importamos en el componente el componente del formulario:

html
<template>

  <form @submit.prevent="formStore.submitForm()">
    
    <h2>Formulario</h2>

    <FirstNameInput/>
    <LastNameInput/>
    <EmailInput/>

    <p>* Estos campos son obligatorios.</p>

    <button type="submit">Enviar</button>

    <pre>{{ formStore.users }}</pre>

  </form>
</template>

Y ya lo podemos probar:

Formulario

*
*
*

* Estos campos son obligatorios.

[]

Como verán, la validación con RegEx chequea que, además de tener la @ y el nombre de dominio, el mail tenga al menos un punto seguido de al menos dos letras.

Input para el passoword

Si luego quieren agregarle un campo para el password hay que sumar una nueva validación dentro del archivo de las clases:

js
// Solo letras y números, con al menos un número y no menos de 6 caracteres
const passwordRegEx = /^(?=.*[0-9])(?=.*[a-zA-Z])[a-zA-Z0-9]{6,}$/;

export class PasswordValidation extends Validation {
  
  constructor (input, submitted) {
    super(input, submitted)
  }
  
  invalidPassword() {
    // El usuario ingresó un password, pero el formato es incorrecto
    if (
      this.input && 
      !passwordRegEx.test(this.input)
    ) return 'La clave debe contener no menos de 6 caracteres y al menos un número' 
    else return ''
  }

  noPasswordErrors() {
    return !this.emptyField() && !this.invalidPassword()
  }
}

El componente el input del password va a ser muy parecido al del mail, sólo que el type del input sería, obviamente, password. De esta forma, cuando el usuario ingresa su clave, en lugar de ver el texto se ven unos puntos:

password field

Pero sería mejor si el usuario pudiese tener la opción de ver el password u ocultarlo. Para eso podemos agregar el ícono del ojo:

visible icon

Y hacer que cuando el usuario clickea en el ícono el password se muestre.

Como los íconos svg no son más que código HTML, podemos manipular los estilos del HTML para mostrar o no mostrar la barra que cubre el ojo cuando el ícono es clickeado. Para eso podemos pasarle al componente del ícono una prop booleana desde el componente PasswordInput.

Y al elemento line del svg (la barra que cubre el ojo) le podemos poner un atributo dinámico con :style={ stroke: lineStyle } y una computed que según el valor de la prop booleana (visible) retorna el valor para style. Y así la barra se muestra según el valor de la prop:

html
<template>
  <svg viewBox="2 0 20.423 16.903" width="20.423" height="16.903" fill="none">
 <path d="M12 7C7.60743 7 4.49054 10.5081 3.41345 11.9208C3.15417 12.2609 3.17881 12.7211 3.4696 13.0347C4.66556 14.3243 8.01521 17.5 12 17.5C15.9848 17.5 19.3344 14.3243 20.5304 13.0347C20.8212 12.7211 20.8458 12.2609 20.5865 11.9208C19.5095 10.5081 16.3926 7 12 7Z" 
   stroke="#555" stroke-width="2"></path>
 <path d="M14 12C14 13.1046 13.1046 14 12 14C10.8954 14 10 13.1046 10 12C10 10.8954 10.8954 10 12 10C13.1046 10 14 10.8954 14 12Z" fill="#555"></path>
 <line :style="{ stroke: lineShadow }" style="stroke-linecap: round; stroke-width: 2px; paint-order: stroke;" x1="4.636" y1="6.095" x2="18.284" y2="19.83"></line>
 <line :style="{ stroke: lineStyle }" style="stroke-linecap: round; stroke-width: 2.5px;" x1="5.995" y1="4.657" x2="19.643" y2="18.392"></line>
</svg>
</template>

<script>

export default {

 props: {
   visible: Boolean
 },

 computed: {
   lineShadow() {
     return this.visible ? 'snow' : 'transparent'
   },
   lineStyle() {
     return this.visible ? '#555' : 'transparent'
   }
 }
}
</script>

<style scoped>
  svg {
    fill: rgba(0,0,0,0);
    width: 1.5rem;
    height: 1.6rem;
  }
</style>

Y en el componente PasswordInput importamos el componente del ícono y declaramos el booleano visible en data y se lo pasamos al ícono como una prop.

Para que el texto del password se vea podemos hacer que la propiedad type del input sea dinámica, es decir: v-bind:type="inputType" o, más corto, :type="inputType".

Y creamos una computed llamada inputType que retorna text o password según el valor de this.visible.

Entonces, al cliquear en el ícono del ojo this.visible pasa a ser true, inputType pasa a ser text y la computed lineStyle en el componente del ícono pasa a ser #555.

Y al volver a cliquear invertimos el valor de visible y todos estos valores se invierten, y así el password nuevamente queda oculto: @click.native="visible = !visible".

html
<template>
  <div class="input-field">

    <i><PasswordIcon/></i>

    <input
      :type="inputType"
      v-model="password" 
      placeholder="password"
    />

    <VisibleIcon
      :visible="visible" 
      @click.native="visible = !visible" 
    />

    <span>*</span>

    <ToolTip v-show="error" :error="error"/>

  </div>
</template>

<script>
import PasswordIcon from './icons/PasswordIcon.vue'
import VisibleIcon from './icons/VisibleIcon.vue'
import ToolTip from './icons/ToolTip.vue'

import formStore from '../stores/formStore'

export default {

  components: {
    PasswordIcon, VisibleIcon, ToolTip
  },

  data: () => ({
    formStore,
    visible: false
  }),

  computed: {
    error() {
      return (
        this.formStore.passwordErrors().emptyField() ||
        this.formStore.passwordErrors().invalidPassword()
      )
    },
    inputType() {
      return this.visible ? 'text' : 'password'
    },
    password: {
      get() {
        return this.formStore.form.password
      },
      set(newValue) {
        this.formStore.setNewValue('password', newValue)
      } 
    }
  }
}
</script>

Y de esta forma el password es visible al cliquear en el ícono, y al volver a clickear vuelven a verse los puntos.

@click.native

Recuerden que para agregar eventos @click directamente sobre componentes de Vue hay que usar el modificador .native, con @click solo no va a funcionar.

Y listo, ya tenemos nuestro formulario completo 🥳️

Formulario

*
*
*
*

* Estos campos son obligatorios.

[]

Conclusión

Admito que crear un formulario sin usar librerías tiene su complejidad, y en algunos casos puede ser más rápido y conveniente leer la documentación de la librería y aprender a usarla, en lugar de hacer todo el proceso de crearlo uno mismo.

Pero al menos, hacerlo nosotros mismos nos sirvió para aprender más sobre Vue y sobre JavaScript (dividir en componentes, usar stores, usar SVG, Regular Expressions, Spread Operator, Clases...) en cambio, estudiando la documentación de una librería sólo estamos aprendiendo a usar esa librería y nada más.