DEV Community

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

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)