Bài 17.3: Form validation trong React-TypeScript

Chào mừng trở lại với series Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một khía cạnh cực kỳ quan trọng của bất kỳ ứng dụng web nào: Form Validation (Kiểm tra hợp lệ dữ liệu biểu mẫu).

Form validation không chỉ giúp đảm bảo dữ liệu người dùng nhập vào là chính xác và đúng định dạng, mà còn là chìa khóa để nâng cao trải nghiệm người dùng (UX) và tăng cường bảo mật. Một form được validate tốt sẽ thông báo rõ ràng cho người dùng biết họ đã nhập sai ở đâu, giúp họ sửa lỗi dễ dàng hơn.

Khi làm việc với React, đặc biệt là kết hợp với sức mạnh của TypeScript, chúng ta có những công cụ rất mạnh mẽ để xây dựng logic validation sạch sẽ, dễ bảo trì và an toàn hơn. Hãy cùng nhau khám phá cách thực hiện điều đó!

Tại sao Form Validation Lại Quan Trọng?

Hãy nghĩ xem: Nếu người dùng cố gắng đăng ký tài khoản nhưng lại gõ sai định dạng email hoặc để trống các trường bắt buộc, điều gì sẽ xảy ra nếu không có validation?

  • Dữ liệu rác: Cơ sở dữ liệu của bạn sẽ chứa dữ liệu không chính xác.
  • Lỗi hệ thống: Logic backend có thể gặp lỗi khi xử lý dữ liệu không mong muốn.
  • Trải nghiệm người dùng tồi tệ: Người dùng sẽ cảm thấy bực bội khi gửi form và không hiểu tại sao lại thất bại, hoặc phải chờ phản hồi từ server mới biết lỗi.
  • Lỗ hổng bảo mật: Dữ liệu không được làm sạch hoặc kiểm tra có thể mở ra các tấn công như Injection.

Validation giúp ngăn chặn những vấn đề này ngay tại phía client, cung cấp phản hồi ngay lập tức cho người dùng.

Cơ Bản Về State và Form trong React

Trong React, các trường nhập liệu (<input>, <textarea>, <select>) thường được quản lý bằng state của component. Chúng ta gọi đây là controlled components. State này sẽ giữ giá trị hiện tại của trường nhập liệu.

Hãy bắt đầu với một ví dụ đơn giản về một input được kiểm soát:

import React, { useState } from 'react';

function SimpleInput() {
  const [inputValue, setInputValue] = useState<string>('');

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(event.target.value);
    console.log('Giá trị hiện tại:', event.target.value);
  };

  return (
    <div>
      <label htmlFor="myInput">Nhập  đó:</label>
      <input
        id="myInput"
        type="text"
        value={inputValue}
        onChange={handleChange}
      />
    </div>
  );
}

Giải thích code:

  • Chúng ta sử dụng useState<string>('') để tạo một state inputValue kiểu string, giá trị khởi tạo là chuỗi rỗng.
  • Hàm handleChange được gọi mỗi khi giá trị input thay đổi.
  • event: React.ChangeEvent<HTMLInputElement>: Đây là lúc TypeScript phát huy tác dụng! Nó định kiểu chính xác cho đối tượng sự kiện event, cho phép chúng ta truy cập các thuộc tính như event.target một cách an toàn và có gợi ý (autocomplete) từ IDE.
  • event.target.value lấy giá trị hiện tại của input.
  • setInputValue(...) cập nhật state inputValue.
  • Input được "kiểm soát" bằng cách gán value={inputValue} (giá trị hiển thị lấy từ state) và onChange={handleChange} (mỗi thay đổi cập nhật state).

Đây là nền tảng. Bây giờ, làm thế nào để thêm validation vào đây?

Thêm Logic Validation và Hiển Thị Lỗi

Validation bao gồm hai phần chính:

  1. Logic kiểm tra: Viết code để xác định xem giá trị nhập vào có hợp lệ hay không (ví dụ: không rỗng, là email hợp lệ, là số, v.v.).
  2. Hiển thị thông báo lỗi: Nếu không hợp lệ, hiển thị thông báo lỗi rõ ràng cho người dùng.

Chúng ta sẽ cần thêm một state nữa để lưu trữ thông báo lỗi.

import React, { useState } from 'react';

function ValidatedInput() {
  const [value, setValue] = useState<string>('');
  const [error, setError] = useState<string | null>(null); // State lưu lỗi, ban đầu là null

  // Hàm validation đơn giản: kiểm tra không rỗng
  const validate = (val: string): string | null => {
    if (!val.trim()) { // trim() loại bỏ khoảng trắng ở đầu/cuối
      return 'Trường này không được để trống.';
    }
    // Thêm các rule khác ở đây nếu cần
    return null; // Không có lỗi
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
    // Tùy chọn: Xóa lỗi ngay khi người dùng bắt đầu gõ lại
    if (error) {
      setError(null);
    }
  };

  // Validate khi người dùng rời khỏi trường nhập (blur)
  const handleBlur = () => {
    const validationError = validate(value);
    setError(validationError);
  };

  return (
    <div>
      <label htmlFor="validatedInput">Tên người dùng:</label>
      <input
        id="validatedInput"
        type="text"
        value={value}
        onChange={handleChange}
        onBlur={handleBlur} // Thêm sự kiện onBlur
        className={error ? 'input-error' : ''} // Thêm class CSS để highlight input lỗi
      />
      {error && <p style={{ color: 'red', fontSize: '0.9em' }}>{error}</p>} {/* Hiển thị lỗi nếu  */}
    </div>
  );
}

Giải thích code:

  • useState<string | null>(null): State error có thể là một chuỗi (thông báo lỗi) hoặc null (không có lỗi). TypeScript giúp ta định rõ điều này.
  • Hàm validate(val: string): string | null nhận giá trị input và trả về lỗi (chuỗi) hoặc null. Việc định kiểu trả về string | null rất quan trọng.
  • handleChange: Cập nhật giá trị và tùy chọn xóa lỗi cũ ngay khi người dùng gõ để phản hồi nhanh hơn.
  • handleBlur: Sự kiện này xảy ra khi input mất focus. Đây là một thời điểm tốt để chạy validation và hiển thị lỗi.
  • onBlur={handleBlur}: Gắn hàm xử lý vào sự kiện onBlur.
  • className={error ? 'input-error' : ''}: Một cách phổ biến để thêm class CSS (ví dụ: border màu đỏ) vào input khi có lỗi.
  • {error && <p>...</p>}: Đây là cú pháp render có điều kiện của React. Đoạn code hiển thị <p> chứa thông báo lỗi chỉ khi error có giá trị (khác null).

Tuyệt vời! Chúng ta đã có thể validate một trường đơn giản. Nhưng một form thường có nhiều trường. Làm thế nào để quản lý state và lỗi cho nhiều input?

Quản Lý Form Với Nhiều Trường

Khi form của bạn có nhiều trường, việc sử dụng nhiều useState riêng lẻ cho từng trường có thể trở nên cồng kềnh. Cách phổ biến và hiệu quả hơn là sử dụng một object trong state để quản lý toàn bộ dữ liệu form và một object khác để quản lý toàn bộ các lỗi.

Đây là lúc TypeScript thực sự tỏa sáng, giúp định nghĩa cấu trúc của dữ liệu form và object lỗi.

import React, { useState } from 'react';

// Định nghĩa kiểu dữ liệu cho form
interface FormData {
  name: string;
  email: string;
  password: string;
}

// Định nghĩa kiểu dữ liệu cho lỗi (mỗi key tương ứng với 1 trường, giá trị là lỗi hoặc undefined)
interface FormErrors {
  name?: string; // Sử dụng ? để chỉ ra rằng trường này có thể không có lỗi (undefined)
  email?: string;
  password?: string;
}

function MultiFieldForm() {
  // State chứa dữ liệu form
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    password: '',
  });

  // State chứa lỗi cho từng trường
  const [errors, setErrors] = useState<FormErrors>({});

  // Hàm validation cho toàn bộ form data
  const validateForm = (data: FormData): FormErrors => {
    const newErrors: FormErrors = {}; // Khởi tạo object lỗi rỗng

    // Rule validation cho name
    if (!data.name.trim()) {
      newErrors.name = 'Tên không được để trống.';
    }

    // Rule validation cho email
    if (!data.email.trim()) {
      newErrors.email = 'Email không được để trống.';
    } else if (!/\S+@\S+\.\S+/.test(data.email)) { // Regex kiểm tra định dạng email cơ bản
      newErrors.email = 'Email không hợp lệ.';
    }

    // Rule validation cho password
    if (!data.password) {
        newErrors.password = 'Mật khẩu không được để trống.';
    } else if (data.password.length < 6) {
        newErrors.password = 'Mật khẩu phải ít nhất 6 ký tự.';
    }

    return newErrors; // Trả về object chứa tất cả lỗi tìm được
  };

  // Xử lý thay đổi input chung cho tất cả các trường
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target; // Lấy 'name' và 'value' của input

    setFormData({
      ...formData, // Giữ lại các giá trị cũ của form
      [name]: value, // Cập nhật giá trị mới cho trường có 'name' tương ứng
    });

    // Tùy chọn: Xóa lỗi của trường hiện tại ngay khi người dùng gõ lại
    // if (errors[name as keyof FormErrors]) { // 'as keyof FormErrors' là một cách để TypeScript hiểu 'name' là một key hợp lệ
    //   setErrors({
    //     ...errors,
    //     [name as keyof FormErrors]: undefined, // Đặt lỗi của trường đó thành undefined
    //   });
    // }
  };

  // Xử lý submit form
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault(); // Ngăn chặn hành vi submit mặc định của trình duyệt (load lại trang)

    const validationErrors = validateForm(formData); // Chạy validation cho toàn bộ form
    setErrors(validationErrors); // Cập nhật state lỗi

    // Kiểm tra xem object lỗi có rỗng không (tức là không có lỗi nào)
    if (Object.keys(validationErrors).length === 0) {
      // Form hợp lệ, thực hiện hành động submit (ví dụ: gọi API)
      alert('Form hợp lệ! Dữ liệu đã sẵn sàng gửi đi.');
      console.log('Dữ liệu gửi đi:', formData);
      // Tại đây bạn sẽ gọi API hoặc thực hiện các thao tác khác...
    } else {
      // Form không hợp lệ, các lỗi đã được hiển thị
      console.log('Form có lỗi:', validationErrors);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Đăng  tài khoản</h2>

      <div>
        <label htmlFor="name">Tên:</label>
        <input
          id="name"
          type="text"
          name="name" // Quan trọng: name phải khớp với key trong state formData
          value={formData.name}
          onChange={handleChange}
          className={errors.name ? 'input-error' : ''}
        />
        {errors.name && <p style={{ color: 'red', fontSize: '0.9em' }}>{errors.name}</p>}
      </div>

      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          name="email" // Quan trọng: name phải khớp với key trong state formData
          value={formData.email}
          onChange={handleChange}
          className={errors.email ? 'input-error' : ''}
        />
        {errors.email && <p style={{ color: 'red', fontSize: '0.9em' }}>{errors.email}</p>}
      </div>

      <div>
        <label htmlFor="password">Mật khẩu:</label>
        <input
          id="password"
          type="password"
          name="password" // Quan trọng: name phải khớp với key trong state formData
          value={formData.password}
          onChange={handleChange}
          className={errors.password ? 'input-error' : ''}
        />
        {errors.password && <p style={{ color: 'red', fontSize: '0.9em' }}>{errors.password}</p>}
      </div>

      <button type="submit">Đăng </button>
    </form>
  );
}

Giải thích code:

  • Interfaces FormDataFormErrors: Đây là nơi TypeScript thể hiện sức mạnh. Chúng ta định nghĩa rõ ràng cấu trúc dữ liệu của form và cấu trúc object chứa lỗi. ? trong FormErrors chỉ ra rằng một trường có thể có lỗi (string) hoặc không có lỗi (undefined). Điều này giúp code an toàn và dễ đọc hơn rất nhiều.
  • useState<FormData>(...)useState<FormErrors>(...): State giờ đây là object tuân thủ các interface đã định nghĩa.
  • validateForm(data: FormData): FormErrors: Hàm validation này nhận toàn bộ object formData và trả về object FormErrors chứa tất cả lỗi tìm thấy. Logic validation cho từng trường được viết tập trung tại đây.
  • handleChange(event: React.ChangeEvent<HTMLInputElement>): Hàm này xử lý thay đổi cho tất cả các input.
    • const { name, value } = event.target;: Lấy thuộc tính name (tên của input) và value (giá trị hiện tại) từ sự kiện. Điều này yêu cầu các input của bạn phải có thuộc tính name khớp với các key trong formData.
    • setFormData({ ...formData, [name]: value });: Sử dụng spread syntax (...formData) để sao chép tất cả các giá trị hiện có trong formData, sau đó dùng cú pháp computed property names [name]: value để ghi đè (cập nhật) giá trị cho trường có tên name tương ứng.
    • Phần tùy chọn xóa lỗi trong handleChange cũng sử dụng name để xác định lỗi cần xóa.
  • handleSubmit(event: React.FormEvent<HTMLFormElement>):
    • event.preventDefault();: Quan trọng! Ngăn trình duyệt gửi form theo cách truyền thống (gây load lại trang).
    • const validationErrors = validateForm(formData);: Gọi hàm validation.
    • setErrors(validationErrors);: Cập nhật state errors. Điều này sẽ khiến component render lại và hiển thị các thông báo lỗi mới.
    • if (Object.keys(validationErrors).length === 0): Kiểm tra số lượng key trong object validationErrors. Nếu bằng 0, tức là không có lỗi nào, form hợp lệ và bạn có thể tiến hành gửi dữ liệu (ví dụ: gọi API sử dụng fetch hoặc axios). Nếu có lỗi, logic tiếp theo sẽ bị bỏ qua và người dùng thấy các thông báo lỗi.
Khi Nào Nên Validate?
  • On Change: Validate khi người dùng gõ ký tự. Phản hồi rất nhanh, nhưng có thể gây phiền phức nếu quy tắc phức tạp (ví dụ: mật khẩu yêu cầu ký tự đặc biệt, số, chữ hoa - hiển thị lỗi ngay khi gõ ký tự đầu tiên là chữ thường không có ý nghĩa).
  • On Blur: Validate khi người dùng rời khỏi trường nhập. Cân bằng tốt giữa phản hồi nhanh và không quá gây phiền phức. Thường dùng cho các trường như email, username.
  • On Submit: Luôn luôn cần validate khi người dùng nhấn nút submit. Đây là "lưới an toàn" cuối cùng trước khi xử lý dữ liệu. Logic này đảm bảo người dùng không thể gửi form với dữ liệu không hợp lệ, kể cả khi họ bỏ qua các trường mà không làm blur.

Trong ví dụ trên, chúng ta chủ yếu áp dụng validation on submit. Bạn có thể kết hợp thêm validation on blur cho từng trường để cải thiện UX.

Comments

There are no comments at the moment.