Forms are essential in most React applications. However, validating user input and displaying error messages in multiple languages can be challenging—especially in internationalized apps.

In this guide, you'll learn how to use React, Joi, and i18n together to build a powerful and easy-to-use form validation system. We'll use Joi to define validation rules, handle error messages, and translate them automatically based on the user's language using i18n.

1. Setup Joi

1.1 Install package

bash

npm install joi
# or
yarn add joi

1.2 Create a Joi config file

Create a new file at utils/joi.ts:

ts

import Joi from "joi";

export const JOI = {
  PHONE_REQUIRE: Joi.string()
    .required()
    .messages({ "string.empty": "required-phone" })
    .regex(/^[0-9]{10}$/)
    .messages({ "string.pattern.base": "invalid-phone" }),
};

export const CUSTOM_JOI_MESSAGE = {
  "string.empty": "required",
};

export class JoiValidate<T> {
  private schema: Joi.ObjectSchema<T>;

  constructor(obj: Joi.PartialSchemaMap<T>) {
    this.schema = Joi.object().options({ abortEarly: false }).keys(obj);
  }

  validateSchema(payload: T) {
    const res = this.schema.validate(payload, { messages: CUSTOM_JOI_MESSAGE });
    const keys = Object.keys(payload as unknown as Record<string, any>);
    const errorMessage: Record<string, string> = {};
    keys.forEach((key) => {
      errorMessage[key] = "";
    });
    if (res.error) {
      res.error.details.forEach((error) => {
        errorMessage[String(error.context?.key)] = error.message;
      });

      return {
        isError: true,
        errorMessage: errorMessage as { [key in keyof T]: string },
      };
    }

    return {
      isError: false,
      errorMessage: errorMessage as { [key in keyof T]: string },
    };
  }
}

1.3 Create validate form hook

Create new hook/useValidation.ts file:

ts

import { JoiValidate } from "@/utils";
import Joi from "joi";
import { useState } from "react";

function useValidation<T>(schema: Joi.PartialSchemaMap<T>) {
  const initKeys = Object.keys(schema) as Array<keyof T>;
  const initErrorMessage: any = {};
  initKeys.forEach((key) => {
    initErrorMessage[key] = "";
  });
  const [isError, setIsError] = useState(false);
  const [errorMessage, setErrorMessage] =
    useState<{ [key in keyof T]: string }>(initErrorMessage);
  const validate = new JoiValidate(schema);

  const onValidate = (payload: T) => {
    const response = validate.validateSchema(payload);
    setIsError(response.isError);
    setErrorMessage(response.errorMessage);

    return response.isError;
  };

  return { isError, errorMessage, onValidate };
}

export default useValidation;

1.4 Create form hook

Create new hook/useFormData.ts file:

ts

import { useState } from "react";

type CamelToSnakeCase<S extends string> = S extends `${infer T}${infer U}`
  ? `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnakeCase<U>}`
  : S;

function useFormData<T>(initData: T) {
  type NumberKeys = keyof T;
  const [formData, setFormData] = useState<T>(initData);
  const listKey = Object.keys(initData as any) as Array<keyof T>; // Extract all form keys to create dynamic handlers and error mapping
  const tempFormKey: any = {};
  listKey.forEach((key) => {
    tempFormKey[
      String(key)
        .replace(/([A-Z])/g, "_$1")
        .toUpperCase()
    ] = key as string;
  });
  const KEY: {
    [key in NumberKeys as Uppercase<CamelToSnakeCase<string & key>>]: string;
  } = tempFormKey;

  const onChangeForm = (key: string) => (value: any) => {
    setFormData((prev) => ({
      ...prev,
      [key]: value,
    }));
  };

  return { formData, KEY, onChangeForm };
}

export default useFormData;

2. Setup i18n

2.1 Install package

bash

npm install i18next react-i18next i18next-browser-languagedetector
# or
yarn add i18next react-i18next i18next-browser-languagedetector

2.2 Create i18n config file

Create new file: src/i18n.ts

ts

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";

import translationEn from "./locales/en/translation.json";
import translationVi from "./locales/vi/translation.json";

const resources = {
  en: { translation: translationEn },
  vi: { translation: translationVi },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: "en",
    interpolation: {
      escapeValue: false,
    },
  });

export default i18n;

2.3 Create your translation files

Folder structure:

txt

src/
├── i18n.ts
└── locales/
    ├── en/
    │   └── translation.json
    └── vi/
        └── translation.json

Example translation.json for English:

json

{
  "validate-message": {
    "required-phone": "Please enter the phone number",
    "invalid-phone": "Phone number is incorrect",
    "required": "Required field"
  }
}

Example for Vietnamese:

json

{
  "validate-message": {
    "required-phone": "Vui lòng nhập số điện thoại",
    "invalid-phone": "Số điện thoại không đúng",
    "required": "Vui lòng không bỏ trống thông tin này"
  }
}

2.4 Import i18n in your index.tsx file

In src/index.tsx:

tsx

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./i18n"; // Important: import your i18n config

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(<App />);

2.5 Why i18n matters in validation

Error messages are part of user experience—and users expect to read them in their preferred language. Without i18n, your app might display English-only messages, which could confuse or frustrate non-English speakers.

Thanks to i18next, we can map error codes returned from Joi (like "required-phone" or "invalid-phone") to translated strings in translation.json. This keeps your form validation logic clean and your UI consistent across multiple languages.

3. How to use

Usage in a component

tsx

import { useTranslation } from "react-i18next";
import { useFormData, useValidation } from "@/hooks";
import { JOI, delayNavigate } from "@/utils";

type FormData = {
  phoneNumber: string;
};

const initForm: FormData = {
  phoneNumber: "",
};

export function LoginContainer() {
  const { t } = useTranslation();
  const { formData, KEY, onChangeForm } = useFormData<FormData>(initForm);
  const { errorMessage, onValidate } = useValidation<FormData>({
    phoneNumber: JOI.PHONE_REQUIRE,
  });

  const handleLogin = async () => {
    const isError = onValidate(formData);
    if (!isError) {
      // Success logic
    }
  };

  return (
    <div>
      <input
        type="tel"
        value={formData.phoneNumber}
        onChange={onChangeForm(KEY.PHONE_NUMBER)}
      />
      Error message: {t(`validate-message.${errorMessage.phoneNumber}`)}
      <button onClick={handleLogin}>Login</button>
    </div>
  );
}

4. Conclusion

By combining Joi and i18n, you can build robust, scalable, and localized form validation in React. This approach makes your forms not only user-friendly but also ready for global audiences.

Powered by

nextjs-icon