erojas.devertek.io logo
Refactoring of the login page form

Refactoring the Login Form

I started with a classic tiny form that had too much noise—multiple repeated useState calls, duplicated labels and inputs, and validation logic scattered everywhere—so I streamlined it using a useFormManager hook and a lightweight validator class to make the code cleaner, more readable, and easier to extend.

Step 1 — Spot the Repetition

The original form had the usual problems:

  • A separate state for every field.

  • Validation inline conditionals buried inside handleSubmit function.

  • Labels and input elements duplicated with only minor differences.

The goal was simple, move state and error handling into one place, use a single vialidator, and render fields through a reusable input component.

Centralizing with useFormManager

I introduced a hook that owns the form state, a single errors object and a form validation function entry point to call before submission.

1import { useState, useCallback, useMemo } from "react";
2type FormState<T> = T;
3export type FormErrors<T> = Partial<Record<keyof T, string>> & {
4  general?: string;
5};
6type FormActions<T> = {
7  updateForm: (field: keyof T, value: T[keyof T]) => void;
8  resetForm: () => void;
9  setForm: (newState: T) => void;
10  formErrors: FormErrors<T>;
11  setFormError: (error: string) => void;
12  clearFormErrors: () => void;
13  validateForm: () => boolean;
14  validateFormField:(field: keyof T, value: T[keyof T])=> void;
15  isFormValid:boolean
16};
17export function useFormManager<T>(
18  inintialState: T,
19  validate?: (state: T) => FormErrors<T>
20): [FormState<T>, FormActions<T>] {
21  const [formState, setFormState] = useState<FormState<T>>(inintialState);
22  const [formErrors, setformErrors] = useState<FormErrors<T>>({});
23  const updateForm = useCallback((field: keyof T, value: T[keyof T]) => {
24    setFormState((prevState) => ({
25      ...prevState,
26      [field]: value,
27    }));
28  }, []);
29  const setFormError = useCallback((error: string) => {
30    setformErrors((prev) => ({ ...prev, general: error }));
31  }, []);
32  const clearFormErrors = useCallback(() => {
33    setformErrors({});
34  }, []);
35  const resetForm = useCallback(() => {
36    setFormState(inintialState);
37    setformErrors({});
38  }, [inintialState]);
39  const setForm = useCallback((newState: T) => {
40    setFormState(newState);
41  }, []);
42  const validateForm = useCallback(() => {
43    if (validate) {
44      const validationErrors = validate(formState);
45      setformErrors(validationErrors);
46      return Object.keys(validationErrors).length === 0;
47    }
48    return true;
49  }, [validate, formState]);
50  const validateFormField = useCallback((field: keyof T, value: T[keyof T]) => {
51    if(validate){
52        const tempState = {...formState, [field]:value};
53        const errors = validate(tempState);
54        setformErrors(prev => ({
55            ...prev,
56            [field]: errors[field],
57            general: prev.general
58        }))
59    }
60  },[formState, validate]);
61  const isFormValid = useMemo(() => {
62    if(!validate) return true;
63    const errors = validate(formState);
64    return Object.keys(errors).length === 0;
65  },[validate, formState])
66  return [
67    formState,
68    {
69      updateForm,
70      resetForm,
71      setForm,
72      validateForm,
73      formErrors,
74      clearFormErrors,
75      setFormError,
76      validateFormField,
77      isFormValid
78    },
79  ];
80}
81

Moving the validation into a tiny class

I wrapped the validator library behind a small API so I don’t scatter library calls across components:

1import valid from "validator";
2class Validator {
3  validatePasswordStrength(password: string) {
4    const options = { minLength: 6 };
5    const ok = valid.isStrongPassword(password || "", options);
6    return ok
7      ? { valid: true, message: "Strong Password" }
8      : {
9          valid: false,
10          message: `Password must be ≥ ${options.minLength} chars`,
11        };
12  }
13  validEmail(email: string) {
14    return valid.isEmail(email || "");
15  }
16}
17export const validator = new Validator();

Wire it together in the page and block submit when invalid

To improve the reliability and user experience of the form, I introduced two critical changes. First, I now call validateForm() before sending the login request, and immediately stop the submission process if it returns false, preventing unnecessary network calls when the input is already invalid. Second, the submit button is disabled while the form is loading or when validation has already determined the form is invalid, providing instant feedback to the user and eliminating the possibility of repeated or invalid submissions.

1  type LoginFormState = {
2    email: string;
3    password: string;
4    isLoading: boolean;
5  };
6  const validateLoginForm = (state: LoginFormState) => {
7    const errors: FormErrors<LoginFormState> = {};
8    if (!validator.validEmail(state.email)) {
9      errors.email = "invalid email address";
10    }
11    const passwordCheck = validator.validatePasswordStrength(state.password);
12    if (!passwordCheck.valid) {
13      errors.password = passwordCheck.message;
14    }
15    return errors;
16  };
17  const [
18    formState,
19    {
20      updateForm,
21      validateForm,
22      formErrors,
23      validateFormField,
24      setFormError,
25      clearFormErrors,
26      isFormValid,
27    },
28  ] = useFormManager<LoginFormState>(
29    {
30      email: "",
31      password: "",
32      isLoading: false,
33    },
34    validateLoginForm
35  );
36  const handleFieldChange = (
37    field: keyof LoginFormState,
38    value: LoginFormState[keyof LoginFormState]
39  ) => {
40    updateForm(field, value);
41    validateFormField(field, value);
42  };
43  const router = useRouter();
44  const handleSubmit = async (e: React.FormEvent) => {
45    e.preventDefault();
46    if (!validateForm()) return;
47    updateForm("isLoading", true);
48    try {
49      const result = await login(formState.email, formState.password);
50      // Store token and user data
51      if (result.token) {
52        localStorage.setItem("token", result.token);
53        // Also set cookie for middleware access
54        document.cookie = `token=${
55          result.token
56        }; path=/; max-age=${getTokenExpirySeconds()}; secure; samesite=strict`;
57      }
58      // Redirect to dashboard
59      router.push("/dashboard");
60    } catch (err) {
61      const errorMessage = err instanceof Error ? err.message : "Login Failed";
62      setFormError(errorMessage);
63      setTimeout(() => {
64        clearFormErrors();
65      }, 5000);
66    } finally {
67      updateForm("isLoading", false);
68    }
69  };

Final thoughts

To improve the user experience even further, you can prevent the submit button from being clickable until the form contains the minimum required information and is not in a loading state, while still performing full validation at the moment of submission as the final source of truth. The next step in this evolution is to introduce field-level validation that triggers when a user leaves a field, enable real-time validation as they type, and build more flexible input components with consistent styling and optional visual enhancements. This structure works because it clearly separates responsibilities: form state and errors are managed in one place, validation rules are centralized, and inputs remain simple visual elements. As a result, the pattern is highly reusable across login, signup, password reset, and profile forms while staying maintainable and scalable as requirements grow—eliminating repetition and ensuring long-term consistency.