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
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>
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; };
Ahora en la UI podemos acceder al error devuelto desde la action usando useActionData
:
const actionData = useActionData<typeof newContactAction>();
Y mostrarlo en el componente:
{actionData?.error && ( <div className="text-red-500 mb-4"> {actionData.error} </div> )}
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;
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;
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
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;
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 elData 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)