DEV Community

Cover image for React Router Data Mode: Parte 8 – Validaciones, useFetcher y React Hook Form
Kevin Julián Martínez Escobar
Kevin Julián Martínez Escobar

Posted on • Edited on

React Router Data Mode: Parte 8 – Validaciones, useFetcher y React Hook Form

Continuamos con la octava entrega de esta serie sobre React Router Data Mode.
En esta ocasión, vamos a responder dos preguntas que quedaron pendientes en el post anterior:

  • ¿Tengo que poner un form en cada botón?
  • ¿Cómo puedo validar los datos del formulario?

Para responderlas, veremos las diferentes formas de validar datos en una action. También hablaremos de uno de los hooks más útiles e importantes de React Router: useFetcher.


Si vienes del post anterior, puedes continuar con tu proyecto tal cual. Pero si prefieres empezar limpio o asegurarte de estar en el punto exacto, ejecuta los siguientes comandos:

# Enlace del repositorio https://github.com/kevinccbsg/react-router-tutorial-devto git reset --hard git clean -d -f git checkout 07-form-validation 
Enter fullscreen mode Exit fullscreen mode

Validación en la action

Vamos a trabajar con el formulario de creación de un contacto en src/pages/ContactForm.tsx.

Primero, desactivamos la validación por defecto del navegador añadiendo noValidate en la etiqueta del formulario:

<Form className="space-y-4" method="POST" noValidate> 
Enter fullscreen mode Exit fullscreen mode

Así React Router nos permite controlar completamente la validación desde nuestra action. Podemos hacerlo manualmente con if/else, o usando una librería como zod o yup.

Importante: no usamos throw para lanzar errores, ya que eso activaría un ErrorBoundary, que aún no hemos definido. En este caso, devolveremos un objeto con la información del error.

export const newContactAction = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const method = request.method.toUpperCase(); const handlers: Record<string, () => Promise<Response | { error: string; }>> = { POST: async () => { const newContact: NewContact = { firstName: formData.get('firstName') as string, lastName: formData.get('lastName') as string, username: formData.get('username') as string, email: formData.get('email') as string, phone: formData.get('phone') as string, avatar: formData.get('avatar') as string || undefined, }; // Añadir la validación que quieras zod, if-else, yup if (!newContact.firstName) { return { error: "First name is required." }; } const newContactResponse = await createContact(newContact); return redirect(`/contacts/${newContactResponse.id}`); }, }; if (handlers[method]) { return handlers[method](); } return null; }; 
Enter fullscreen mode Exit fullscreen mode

Ahora en la UI podemos acceder al error devuelto desde la action usando useActionData:

const actionData = useActionData<typeof newContactAction>(); 
Enter fullscreen mode Exit fullscreen mode

Y mostrarlo en el componente:

{actionData?.error && ( <div className="text-red-500 mb-4"> {actionData.error} </div> )} 
Enter fullscreen mode Exit fullscreen mode

Quedando de esta manera:

import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Form, useActionData, useNavigation } from 'react-router'; import { newContactAction } from './actions'; const ContactForm = () => { const navigation = useNavigation(); const actionData = useActionData<typeof newContactAction>(); const isSubmitting = navigation.state === 'submitting' && navigation.formAction === '/contacts/new'; const isLoading = navigation.state === 'loading' && navigation.formAction === '/contacts/new'; const disabled = isSubmitting || isLoading; return ( <div className="max-w-md mx-auto"> <h1 className="text-2xl font-bold mb-4">Create New Contact</h1> <Form className="space-y-4" method="POST" noValidate> {/* show error message */} {actionData?.error && ( <div className="text-red-500 mb-4"> {actionData.error} </div> )} {/* los otros campos */} <Button type="submit" disabled={disabled}> {disabled ? 'Creating...' : 'Create Contact'} </Button> </Form> </div> ); }; export default ContactForm; 
Enter fullscreen mode Exit fullscreen mode

Esto nos permite mostrar errores sin recargar la página, pero perdemos muchas ventajas de validación inmediata que ofrecen librerías como react-hook-form. Y como Form no nos permite interceptar el onSubmit, es aquí donde entra en juego useFetcher.

¿Qué es useFetcher?

Según la documentación oficial:

"Fetcher es útil para crear interfaces dinámicas y complejas que requieren múltiples interacciones con datos concurrentes, sin provocar una navegación."
"Los fetchers tienen su propio estado independiente y pueden usarse para cargar datos, enviar formularios e interactuar con loaders y actions."

Vamos a migrar nuestro formulario para que use useFetcher.

Migrar a useFetcher

Aquí tienes cómo quedaría el componente usando fetcher.Form:

import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useFetcher } from 'react-router'; import { newContactAction } from './actions'; const ContactForm = () => { const fetcher = useFetcher<typeof newContactAction>(); const disabled = fetcher.state === 'submitting' || fetcher.state === 'loading'; return ( <div className="max-w-md mx-auto"> <h1 className="text-2xl font-bold mb-4">Create New Contact</h1> <fetcher.Form className="space-y-4" method="POST" noValidate> {fetcher.data?.error && ( <div className="text-red-500 mb-4"> {fetcher.data.error} </div> )} {/* los otros campos */} <Button type="submit" disabled={disabled}> {disabled ? 'Creating...' : 'Create Contact'} </Button> </fetcher.Form> </div> ); }; export default ContactForm; 
Enter fullscreen mode Exit fullscreen mode

Con esto ya no necesitas useActionData ni useNavigation, ya que fetcher te da acceso directo al estado del envío y a los datos devueltos.

Validación con react-hook-form + useFetcher

Si quieres una mejor experiencia de validación en el lado del cliente, puedes usar react-hook-form.

Primero instalamos la librería:

npm install react-hook-form 
Enter fullscreen mode Exit fullscreen mode

Como vamos a manejar el onSubmit manualmente, no necesitamos fetcher.Form ni Form. Basta con usar fetcher.submit() en el handleSubmit.

import { useForm, SubmitHandler } from "react-hook-form" import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useFetcher } from 'react-router'; import { newContactAction } from './actions'; interface FormValues { firstName: string; lastName: string; username: string; email: string; phone: string; avatar?: string; } const ContactForm = () => { const fetcher = useFetcher<typeof newContactAction>(); const { register, handleSubmit, formState: { errors }, } = useForm<FormValues>(); const onSubmit: SubmitHandler<FormValues> = (data) => { fetcher.submit({ ...data }, { method: 'POST', action: '/contacts/new' }); }; const disabled = fetcher.state === 'submitting' || fetcher.state === 'loading'; return ( <div className="max-w-md mx-auto"> <h1 className="text-2xl font-bold mb-4">Create New Contact</h1> <form className="space-y-4" method="POST" noValidate onSubmit={handleSubmit(onSubmit)}> <div> <Label className="mb-2" htmlFor="firstName">First Name</Label> <Input type="text" id="firstName" {...register("firstName", { required: true })} /> {errors.firstName && ( <p className="text-red-500 text-sm mt-1"> First name is required </p> )} </div> <div> <Label className="mb-2" htmlFor="lastName">Last Name</Label> <Input type="text" id="lastName" {...register("lastName", { required: true })} /> {errors.lastName && ( <p className="text-red-500 text-sm mt-1"> Last name is required </p> )} </div> <div> <Label className="mb-2" htmlFor="username">Username</Label> <Input type="text" id="username" {...register("username", { required: true })} /> {errors.username && ( <p className="text-red-500 text-sm mt-1"> Username is required </p> )} </div> <div> <Label className="mb-2" htmlFor="email">Email</Label> <Input type="email" id="email" {...register("email", { required: true })} /> {errors.email && ( <p className="text-red-500 text-sm mt-1"> Email is required </p> )} </div> <div> <Label className="mb-2" htmlFor="phone">Phone</Label> <Input type="tel" id="phone" {...register("phone", { required: true })} /> {errors.phone && ( <p className="text-red-500 text-sm mt-1"> Phone is required </p> )} </div> <div> <Label className="mb-2" htmlFor="avatar">Avatar (Optional)</Label> <Input type="url" id="avatar" {...register("avatar")} /> {errors.avatar && ( <p className="text-red-500 text-sm mt-1"> Avatar URL is invalid </p> )} </div> <Button type="submit" disabled={disabled}> {disabled ? 'Creating...' : 'Create Contact'} </Button> </form> </div> ); }; export default ContactForm; 
Enter fullscreen mode Exit fullscreen mode

Con eso puedes aprovechar todo lo bueno de react-hook-form (validación en tiempo real, errores por campo, etc.) y seguir usando las actions de React Router para gestionar los datos.

Conclusión

En este post vimos:

  • Cómo hacer validaciones en las actions de React Router
  • Cómo mostrar errores con useActionData o directamente desde fetcher
  • Qué es useFetcher y cómo nos permite trabajar con formularios sin cambiar de ruta
  • Cómo integrar react-hook-form con el Data Mode

En el próximo post...

Vamos a aplicar useFetcher a las acciones de borrado y marcado como favorito, y hablaremos de un concepto súper interesante: Optimistic UI.
Esto nos permitirá mejorar la experiencia del usuario con actualizaciones instantáneas antes de que el servidor confirme la operación.

¡Nos vemos en la próxima entrega!

Top comments (0)