Building Type-Safe React Forms: Beyond useState
CodingReactTypeScript

Building Type-Safe React Forms: Beyond useState

Ihor ChyshkalaIhor Chyshkala

Building forms in React can quickly become complex, especially when dealing with validation, error handling, and type safety. In this article, we'll explore how to create a robust, type-safe form system that goes beyond simple useState implementations.

The Problem with Traditional Form Approaches

Let's start with a common way of handling forms in React:

function UserForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Validation logic here
    // API calls here
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      {errors.name && <span>{errors.name}</span>}
      {/* Repeat for email and age */}
    </form>
  );
}

This approach has several problems:

  1. No type safety for form values
  2. Repetitive state declarations
  3. Validation logic mixed with UI
  4. No centralized form state
  5. Hard to reuse validation logic

Building a Type-Safe Form Hook

Let's create a custom hook that addresses these issues:

interface FormField<T> {
  value: T;
  error?: string;
  touched: boolean;
  validators?: ((value: T) => string | undefined)[];
}

type FormState<T> = {
  [K in keyof T]: FormField<T[K]>;
};

function useForm<T extends Record<string, any>>(
  initialValues: T,
  onSubmit: (values: T) => void | Promise<void>
) {
  const [formState, setFormState] = useState<FormState<T>>(() => {
    const initial: Partial<FormState<T>> = {};
    for (const [key, value] of Object.entries(initialValues)) {
      initial[key as keyof T] = {
        value,
        touched: false,
        validators: [],
      };
    }
    return initial as FormState<T>;
  });

  const setFieldValue = useCallback(<K extends keyof T>(
    field: K,
    value: T[K]
  ) => {
    setFormState((prev) => ({
      ...prev,
      [field]: {
        ...prev[field],
        value,
        touched: true,
      },
    }));
  }, []);

  const registerField = useCallback(<K extends keyof T>(
    field: K,
    validators: ((value: T[K]) => string | undefined)[] = []
  ) => {
    setFormState((prev) => ({
      ...prev,
      [field]: {
        ...prev[field],
        validators,
      },
    }));
  }, []);

  const validateField = useCallback(<K extends keyof T>(
    field: K
  ): boolean => {
    const fieldState = formState[field];
    const validators = fieldState.validators || [];
    
    for (const validator of validators) {
      const error = validator(fieldState.value);
      if (error) {
        setFormState((prev) => ({
          ...prev,
          [field]: { ...prev[field], error },
        }));
        return false;
      }
    }
    
    setFormState((prev) => ({
      ...prev,
      [field]: { ...prev[field], error: undefined },
    }));
    return true;
  }, [formState]);

  const validateForm = useCallback((): boolean => {
    let isValid = true;
    for (const field of Object.keys(formState)) {
      if (!validateField(field as keyof T)) {
        isValid = false;
      }
    }
    return isValid;
  }, [formState, validateField]);

  const handleSubmit = useCallback(
    (e: React.FormEvent) => {
      e.preventDefault();
      if (validateForm()) {
        const values = Object.entries(formState).reduce(
          (acc, [key, field]) => ({
            ...acc,
            [key]: field.value,
          }),
          {} as T
        );
        onSubmit(values);
      }
    },
    [formState, validateForm, onSubmit]
  );

  return {
    formState,
    setFieldValue,
    registerField,
    validateField,
    validateForm,
    handleSubmit,
  };
}

Creating Reusable Validators

Let's create some type-safe validators:

const createValidator = <T>(
  predicate: (value: T) => boolean,
  errorMessage: string
) => {
  return (value: T): string | undefined => 
    predicate(value) ? undefined : errorMessage;
};

export const validators = {
  required: createValidator(
    (value: any) => value !== undefined && value !== '',
    'This field is required'
  ),
  email: createValidator(
    (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    'Invalid email address'
  ),
  minLength: (min: number) =>
    createValidator(
      (value: string) => value.length >= min,
      `Must be at least ${min} characters`
    ),
  maxLength: (max: number) =>
    createValidator(
      (value: string) => value.length <= max,
      `Must be no more than ${max} characters`
    ),
  number: createValidator(
    (value: string) => !isNaN(Number(value)),
    'Must be a number'
  ),
};

Using the Form Hook

Here's how to use our type-safe form system:

interface UserFormData {
  name: string;
  email: string;
  age: string;
}

function UserForm() {
  const {
    formState,
    setFieldValue,
    registerField,
    handleSubmit,
  } = useForm<UserFormData>(
    {
      name: '',
      email: '',
      age: '',
    },
    async (values) => {
      console.log('Form submitted:', values);
      // API call here
    }
  );

  useEffect(() => {
    registerField('name', [validators.required, validators.minLength(2)]);
    registerField('email', [validators.required, validators.email]);
    registerField('age', [validators.required, validators.number]);
  }, [registerField]);

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          value={formState.name.value}
          onChange={(e) => setFieldValue('name', e.target.value)}
        />
        {formState.name.touched && formState.name.error && (
          <span>{formState.name.error}</span>
        )}
      </div>

      <div>
        <input
          value={formState.email.value}
          onChange={(e) => setFieldValue('email', e.target.value)}
        />
        {formState.email.touched && formState.email.error && (
          <span>{formState.email.error}</span>
        )}
      </div>

      <div>
        <input
          value={formState.age.value}
          onChange={(e) => setFieldValue('age', e.target.value)}
        />
        {formState.age.touched && formState.age.error && (
          <span>{formState.age.error}</span>
        )}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

Creating Reusable Form Fields

To reduce boilerplate even further, let's create a reusable form field component:

interface FormFieldProps<T, K extends keyof T> {
  name: K;
  label: string;
  formState: FormState<T>;
  setFieldValue: (field: K, value: T[K]) => void;
}

function FormField<T, K extends keyof T>({
  name,
  label,
  formState,
  setFieldValue,
}: FormFieldProps<T, K>) {
  const field = formState[name];

  return (
    <div className="form-field">
      <label>{label}</label>
      <input
        value={field.value as string}
        onChange={(e) => setFieldValue(name, e.target.value as T[K])}
        className={field.error ? 'error' : ''}
      />
      {field.touched && field.error && (
        <span className="error-message">{field.error}</span>
      )}
    </div>
  );
}

Now our form becomes much cleaner:

function UserForm() {
  const form = useForm<UserFormData>(
    {
      name: '',
      email: '',
      age: '',
    },
    async (values) => {
      console.log('Form submitted:', values);
    }
  );

  useEffect(() => {
    form.registerField('name', [validators.required, validators.minLength(2)]);
    form.registerField('email', [validators.required, validators.email]);
    form.registerField('age', [validators.required, validators.number]);
  }, [form.registerField]);

  return (
    <form onSubmit={form.handleSubmit}>
      <FormField
        name="name"
        label="Name"
        formState={form.formState}
        setFieldValue={form.setFieldValue}
      />
      <FormField
        name="email"
        label="Email"
        formState={form.formState}
        setFieldValue={form.setFieldValue}
      />
      <FormField
        name="age"
        label="Age"
        formState={form.formState}
        setFieldValue={form.setFieldValue}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Advanced Features

Here are some additional features we could add to our form system:

1. Form-Level Validation

interface FormValidation<T> {
  (values: T): Partial<Record<keyof T, string>> | undefined;
}

// Add to useForm parameters
formValidation?: FormValidation<T>

2. Async Validation

const asyncValidator = <T>(
  validate: (value: T) => Promise<string | undefined>
) => {
  return async (value: T): Promise<string | undefined> => {
    return await validate(value);
  };
};

// Example usage
const uniqueEmailValidator = asyncValidator(async (email: string) => {
  const response = await checkEmailExists(email);
  return response.exists ? 'Email already exists' : undefined;
});

3. Field Dependencies

interface FieldDependency<T, K extends keyof T> {
  fields: K[];
  validator: (values: Pick<T, K>) => string | undefined;
}

Performance Considerations

  1. Memoization: Our hook uses useCallback for functions to prevent unnecessary rerenders.
  2. Selective Updates: The form state updates only the changed fields, not the entire form state.
  3. Validation Optimization: Validation runs only on touched fields or during form submission.

Testing Your Forms

Here's how to test your type-safe forms:

import { renderHook, act } from '@testing-library/react-hooks';

describe('useForm', () => {
  it('should initialize with correct values', () => {
    const { result } = renderHook(() =>
      useForm(
        { name: '', email: '' },
        () => {}
      )
    );

    expect(result.current.formState).toEqual({
      name: { value: '', touched: false, validators: [] },
      email: { value: '', touched: false, validators: [] },
    });
  });

  it('should update field value', () => {
    const { result } = renderHook(() =>
      useForm(
        { name: '' },
        () => {}
      )
    );

    act(() => {
      result.current.setFieldValue('name', 'John');
    });

    expect(result.current.formState.name.value).toBe('John');
    expect(result.current.formState.name.touched).toBe(true);
  });
});

Conclusion

Building type-safe forms in React requires careful consideration of:

  • State management
  • Validation
  • Type safety
  • Reusability
  • Performance

Our solution provides:

  • Full type safety with TypeScript
  • Reusable validation logic
  • Centralized form state
  • Performance optimizations
  • Easy testing capabilities

Remember that while this implementation is quite robust, you might want to consider existing form libraries like Formik or React Hook Form for production applications. However, understanding these patterns helps you make better decisions about when to use existing solutions versus building custom implementations.

About the Author

Ihor Chyshkala

Ihor Chyshkala

Code Alchemist: Transmuting Ideas into Reality with JS & PHP. DevOps Wizard: Transforming Infrastructure into Cloud Gold | Orchestrating CI/CD Magic | Crafting Automation Elixirs