Back to posts

Building Type-Safe Forms with Formik and Yup in React TypeScript

Erik Nguyen / January 6, 2025

Building Type-Safe Forms with Formik and Yup in React TypeScript

Form handling in React can be challenging, especially when you need to manage complex validation rules and ensure type safety. This guide will show you how to leverage Formik and Yup to create robust, type-safe forms in your React TypeScript applications.

Understanding the Stack

Before we dive in, let's understand why we're using these specific tools:

  • Formik: Handles form state management, validation, and submission
  • Yup: Provides a schema-based approach to validation with TypeScript support
  • TypeScript: Adds static typing to ensure type safety throughout our application

Setting Up Your Project

First, let's install the necessary dependencies:

npm install formik yup @types/yup

Creating a Type-Safe Schema

Let's start by defining our form schema using Yup:

import * as Yup from 'yup';

// Define the validation schema
const userFormSchema = Yup.object().shape({
  username: Yup.string()
    .min(3, 'Username must be at least 3 characters')
    .required('Username is required'),
  email: Yup.string()
    .email('Invalid email address')
    .required('Email is required'),
  password: Yup.string()
    .min(8, 'Password must be at least 8 characters')
    .matches(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      'Password must contain at least one uppercase letter, one lowercase letter, and one number'
    )
    .required('Password is required'),
});

// Infer the TypeScript type from the schema
type UserFormValues = Yup.InferType<typeof userFormSchema>;

Implementing the Form Component

Now, let's create a form component that uses Formik with our schema:

import React from 'react';
import { Formik, Form, Field, ErrorMessage } from 'formik';

const UserRegistrationForm: React.FC = () => {
  // Initial form values matching our schema
  const initialValues: UserFormValues = {
    username: '',
    email: '',
    password: '',
  };

  const handleSubmit = async (values: UserFormValues) => {
    try {
      // Here you would typically make an API call
      console.log('Form submitted:', values);
    } catch (error) {
      console.error('Submission error:', error);
    }
  };

  return (
    <Formik
      initialValues={initialValues}
      validationSchema={userFormSchema}
      onSubmit={handleSubmit}
    >
      {({ isSubmitting, touched, errors }) => (
        <Form className="space-y-4">
          <div>
            <label htmlFor="username" className="block mb-2">
              Username
            </label>
            <Field
              type="text"
              id="username"
              name="username"
              className={`w-full p-2 border rounded ${
                touched.username && errors.username ? 'border-red-500' : 'border-gray-300'
              }`}
            />
            <ErrorMessage
              name="username"
              component="div"
              className="text-red-500 text-sm mt-1"
            />
          </div>

          <div>
            <label htmlFor="email" className="block mb-2">
              Email
            </label>
            <Field
              type="email"
              id="email"
              name="email"
              className={`w-full p-2 border rounded ${
                touched.email && errors.email ? 'border-red-500' : 'border-gray-300'
              }`}
            />
            <ErrorMessage
              name="email"
              component="div"
              className="text-red-500 text-sm mt-1"
            />
          </div>

          <div>
            <label htmlFor="password" className="block mb-2">
              Password
            </label>
            <Field
              type="password"
              id="password"
              name="password"
              className={`w-full p-2 border rounded ${
                touched.password && errors.password ? 'border-red-500' : 'border-gray-300'
              }`}
            />
            <ErrorMessage
              name="password"
              component="div"
              className="text-red-500 text-sm mt-1"
            />
          </div>

          <button
            type="submit"
            disabled={isSubmitting}
            className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600 disabled:bg-blue-300"
          >
            {isSubmitting ? 'Submitting...' : 'Register'}
          </button>
        </Form>
      )}
    </Formik>
  );
};

export default UserRegistrationForm;

Adding Custom Field Components

To make our form more reusable, let's create a custom field component:

interface FormFieldProps {
  label: string;
  name: string;
  type?: string;
  placeholder?: string;
}

const FormField: React.FC<FormFieldProps> = ({ label, name, type = 'text', placeholder }) => {
  return (
    <div>
      <label htmlFor={name} className="block mb-2">
        {label}
      </label>
      <Field
        type={type}
        id={name}
        name={name}
        placeholder={placeholder}
        className="w-full p-2 border rounded"
      />
      <ErrorMessage
        name={name}
        component="div"
        className="text-red-500 text-sm mt-1"
      />
    </div>
  );
};

Handling Form Submission

Here's how to properly handle form submission with TypeScript:

const handleSubmit = async (
  values: UserFormValues,
  { setSubmitting, setErrors }: FormikHelpers<UserFormValues>
) => {
  try {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));

    // Handle successful submission
    console.log('Form submitted successfully:', values);

    // You could redirect here
    // navigate('/success');
  } catch (error) {
    // Handle submission error
    setErrors({
      submit: 'An error occurred while submitting the form'
    });
  } finally {
    setSubmitting(false);
  }
};

Best Practices and Tips

  1. Type Safety: Always use TypeScript interfaces or types for your form values:
interface UserForm {
  username: string;
  email: string;
  password: string;
}
  1. Custom Validation: You can combine Yup with custom validation functions:
const validateUsername = async (value: string) => {
  // Simulate API call to check username availability
  const isAvailable = await checkUsernameAvailability(value);
  return isAvailable ? undefined : 'Username is already taken';
};
  1. Form State Management: Use Formik's built-in hooks for accessing form state:
import { useFormikContext } from 'formik';

const FormStatus: React.FC = () => {
  const { isSubmitting, isValid } = useFormikContext();
  return (
    <div>
      {isSubmitting && <p>Submitting...</p>}
      {!isValid && <p>Please fix form errors</p>}
    </div>
  );
};

Conclusion

By combining Formik with Yup in a TypeScript environment, we've created a type-safe, validated form system that provides excellent developer experience and runtime safety. This approach scales well for both simple and complex forms while maintaining code quality and type safety throughout your application.

Remember to:

  • Always define your validation schema first
  • Use TypeScript types derived from your Yup schema
  • Leverage Formik's built-in components and hooks
  • Create reusable components for common form patterns

Happy coding!