【React hook】手把手學會 Form 表單驗證與 UX 優化全教學

2023/07/04閱讀時間約 31 分鐘
Snapshot from project example
在開發 React 時,就發現表格其實是整個網站當中,最常擁有複雜邏輯或驗證的地方。通常表格也會和許多狀態相關,也因此讓設計變得更為複雜。今天這篇文章,就帶大家不使用第三方套件如 Formik,手把手帶你刻 React 的表單驗證。
這篇文章,總共會提到下面三個主題:
  1. 為什麼表單驗證很重要?
  2. 表單驗證的三階段?
  3. 如何優化表單設計?
筆者也會在下面,逐步說明每一個步驟的概念,就讓我們開始吧!

為什麼表單驗證很重要?

在開發前端網頁時,表單是整個開發過程中,非常重要的一塊。因為表單,通常是使用者互動最多的部分,也最容易遇到各種邏輯問題。例如:
  1. 資料型態可能不符合
  2. 需要在使用者輸入錯誤時,提示錯誤並協助更新狀態
  3. 使用者上傳錯誤資訊,導致 Server 收到錯誤資料
甚至在輸入後,也需要進一步至資料庫驗證,還會需要考慮驗證的時間點。例如使用者名稱確認,上述這些情境,都是導致 Form 困難的原因。
也因此,要讓使用者在輸入時,不需額外花費精力理解,但又能讓使用者在輸入錯誤資料時,第一時間獲得反饋,讓使用者有機會,能即時更正輸入的資訊。

前端在表單驗證的角色

對於資料驗證來說,除了在前端進行資料驗證外,也需要在後端進行驗證。主要的原因,是因為前端是能讓使用者透過修改原始碼,讓驗證失效。有興趣的朋友可以參考 Hide JavaScript Code 這篇文章
因此,前端在本身的機制上,無法保證資料的正確性和安全性。

表單驗證的三階段

表單設計 Flow
在分析表單驗證時,主要會分成三個階段,分別是:
  1. 當使用者輸入時
  2. 當表格 lost focus 時
  3. 當表格被 Submit 時
每一個部分,都會有各自所遇到的議題,以下是各自常見的議題:
  1. Input:當使用者在輸入時,需要至少讓使用者有輸入正確的時間點。並要在確認輸入資訊無效時,才提供錯誤 feedback。
  2. Lost Focus:我們預設使用者會輸入正確資訊,真的錯誤才提醒。對於應用在未被編輯的表單時,非常有效。
  3. Submit:資料格式可能錯誤,但我們需要在使用者 Submit 後,才根據他的答案提示錯誤;但帶來的缺點,就是 feedback 太晚。
接著,就讓我們一步一步看下去吧!

一、當使用者輸入時

Snapshot from project example

A. 如何獲取使用者的輸入內容?

獲取使用者在 Input 的輸入內容,是整個表單驗證的核心。因此也將這個步驟,放在第一個階段。要獲取使用者的輸入內容,主要有兩個方式:
  1. 使用 Event handler 抓取資料
  2. 使用 useRef 獲取 DOM 資料
上述兩個方式,各自帶來的優缺點分別為是:
  1. 使用 Event Handler:優點是能夠做到更細緻的資料驗證,能夠即時給予使用者輸入回饋。但部分情境不需要追蹤每一個輸入值,會導致無意義的重渲染。
  2. 使用 useRef:能夠精簡地獲得資料驗證,但通常僅會使用在上傳(Submit)資料時。因為他需要 Callback,才能觸發當時在 DOM 上的資料狀態;缺點則是無法做到較細緻即時輸入驗證。

1.Event Handler 的寫法

在使用之前,需要提醒每一次修改 Input,例如輸入或刪除,都會觸發 React 元件的 re-render。在下列步驟中,利用每一次改變 Input 值時,會呼叫 Input 屬性的 onChange,讓新的值能夠更新至 enteredInput。
具體步驟如下:
  1. 使用 Handler 抓取當前的 event
  2. 使用 event.target.value 抓到 input 的值
  3. 使用 setEnteredInput 更新輸入值
  4. 將 enteredInput 使用於資料驗證
const [enteredInput, setEnteredInput] = useState("");
const submitHandler = (event) => {
  // submit data...
};
const inputChangeHandler = (event) => {
  setEnteredInput(event.target.value);
};
<form onSubmit={submitHandler}>
  <label htmlFor="name">Your Name</label>
  <input type="text" id="name" onChange={inputChangeHandler} />
</form>
其中,label 的屬性 htmlFor,會自動指向相同 id 的 input。因此若點擊 Label 的字,就會自動 Focus name 這個 Input。

2.useRef 的寫法

useRef 的概念更像是快照(snapshot),在特定 Callback 被呼叫時,就可以擷取被 useRef 監聽的元件。而 useRef 會擷取整個被監聽元件的 DOM,也因此可以獲取其中的 value。
具體步驟如下:
  1. 使用 useRef 設定監聽器
  2. 將監聽器傳入 Input 的屬性 ref
  3. 在 Submit 時使用監聽到的資料 inputRef
  4. 從 inputRef.current.value 獲取 Input 的值
const inputRef = useRef();
const submitHandler = (event) => {
  submitDataHandler(inputRef.current.value);
};
return (
  <form onSubmit={submitHandler}>
    <label htmlFor="name">Your Name</label>
    <input type="text" id="name" ref={inputRef} />
  </form>
);

二、當表格 Lost focus 時

Snapshot from project example
當使用者輸入完表格,準備離開當前輸入框時,就可以做第一次的驗證,可以避免使用者最後一刻,才收到修改的提示或無法提交。
要執行 Lost Focus 時的驗證,需要有四個步驟:
  1. 檢驗輸入資料的正確性
  2. 檢驗表格是否輸入過
  3. 顯示錯誤提示訊息
  4. 即時驗證與更新訊息

A. 檢驗輸入資料的正確性

最常見的莫過於姓名與 Email 的驗證,以下簡單提供驗證的方式,當然直接請 ChatGPT 協助你寫正規表達式,整體的速度會更快。
這個階段,是確保從 state 抓到的資料,可以明確知道當前的資料是否符合驗證,若符合驗證,就可以提交;若不符合驗證,就會跳出修改的提示訊息。

為空值 input 進行驗證

透過 trim 去掉頭尾的空白,並確保長度大於 0。
const nameValidation = (name) => {
  const isNameInputValid = name.trim().length !== 0;
  return isNameInputValid;
};

為 Email 進行驗證:

const emailValidation = (email) => {
  const re = /^(([^<>()\\[\\]\\\\.,;:\\s@"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/;
  return re.test(String(email).toLowerCase());
}

B. 檢驗表格是否曾輸入過

除了驗證資料是否正確外,另一個重點,就是確認使用者是否碰觸過輸入框。因此,我們就需要用到 DOM 提供的內建方法 onBlur,來監測當使用者 Lost Focus 時,需要執行的 callback function。
const [enteredNameInput, setEnteredNameInput] = useState("");
const [isNameInputTouched, setIsNameInputTouched] = useState(false);
// DEFINE Callback function
const inputNameBlurHandler = (event) => {
    setIsNameInputTouched(true);
  };
// CALLBACK WHEN INPUT LOST FOCUS
<input onBlur={inputBlurHandler} />

C. 顯示錯誤提示訊息

接著,我們就要使用前面提到的驗證資料正確性,以及確認表格是否被輸入過,卻評估是否需要顯示錯誤訊息。
以下,我們就拿名稱輸入的表格,進行錯誤訊息的提示示範。而錯誤訊息之所以要使用 isNameInputTouched!isNameInputValid (意指驗證無效),是因為我們相信使用者,「一開始是會輸入正確資訊」,所以當 Touched 被預設為 false 時,就不會出現 Error。
直到使用者確定有 Lost Focus 的行為,但卻又沒有通過驗證時,再顯示錯誤訊息才適合適的時機。
假設我們是使用者,也不希望一開始,就被通知輸入無效資訊吧!

使用 CSS Class,控制錯誤訊息顯示

// VALIDATE nameInput is INVALID
const nameInputIsInvalid = isNameInputTouched && !isNameInputValid;
// SET INPUT CLASS WITH ERROR STATE
const nameInputClass = nameInputIsInvalid
    ? "form-control invalid"
    : "form-control";
// DISPLAY error message WHEN INPUT IS INVALID
<div className={nameInputClass}>
  <label htmlFor="name">Your Name</label>
  <input
    value={enteredNameInput}
    type="text"
    id="name"
    onBlur={inputNameBlurHandler}
    onChange={inputNameChangeHandler}
  />
  {nameInputIsInvalid && <p className="error-text">Name must not be empty.</p>}
</div>;

D. 即時驗證與更新訊息

當使用者更正為正確內容時,只要一符合標準,我們就將 Error Message 移除。讓使用者知道,他已經正確達到要求。
這裡要再重新強調一個概念,每當我們使用 Event Listener 監控 Input 的值時,每一次觸發 Callback function,都會讓整個元件重新渲染。因此,在第三階段的「顯示錯誤提示訊息」中,nameInputIsInvalid 每一次都會重新聲明,因此每鍵入一次,就會重新驗證一次。也因此 nameInputIsInvalid 的值,也都會根據每一次輸入,而重新驗證。
除此之外,我們也能夠透過狀態管理,讓整個表格的遞交按鈕,能夠暫時被關閉。

利用驗證,暫時關閉提交(Submit)按鈕

Snapshot from project example
以下就以 Email 和名稱的表格驗證,來示範如何達成。我們先個別設定 Email 和名稱的 Input 的 touched 狀態;接著,撰寫 Email 和名稱的驗證;再來,個別驗證輸入的有效性。最後,才是驗證整個表格的狀態。
const [enteredEmailInput, setEnteredEmailInput] = useState("");
const [isEmailInputTouched, setIsEmailInputTouched] = useState(false);
const [enteredNameInput, setEnteredNameInput] = useState("");
const [isNameInputTouched, setIsNameInputTouched] = useState(false);
const checkEmail = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
const isEmailInputValid = checkEmail.test(enteredEmailInput);
const isNameInputValid = enteredNameInput.trim().length !== 0;
const nameInputIsInvalid = isNameInputTouched && !isNameInputValid;
const emailInputIsInvalid = isEmailInputTouched && !isEmailInputValid;
// APPLY FOR MANAGING BUTTON STATUS
const isFormValid = isNameInputValid && isEmailInputValid;
// JSX SUBMIT BUTTON
<div className="form-actions">
  <button disabled={!isFormValid}>Submit</button>
</div>;

三、當表格被 Submit 時

Snapshot from project example
當使用者輸入完表格(或輸入錯誤的資訊時),仍然要在遞交時,再進行一次 Submit,確保推去後端的資料沒問題。
要執行 Submit 驗證時,有兩個步驟需要被考慮到:
  1. 多表格驗證
  2. 重置表格

A. 多表格驗證

若我們有在前面階段,就使用多表格的確認,就可以同樣在 Submit 時,使用表格驗證來做最後一層確認。我們使用上一個「暫時關閉提交按鈕」的案例來延伸。我們可以在處理提交的 SubmitHandler 中,使用判斷式來決定,是否要執行此次的 Submit 行為。
const submitHandler = (event) => {
  event.preventDefault();
  // DEFAULT USER HAS TOUCHED INPUT DUE TO CONFIRMATION
  setIsFormTouched(true);
  // COULD NOT SUBMIT IF FORM IS NOT VALID
  if (!isFormValid) {
    // DROP OUT DUE TO return
    return;
  }
  fetch(url, { method: "POST", body: ...})
};
其中,我們先使用 event.preventDefault();,來避免 Submit 後會重新 Reload 畫面的預設狀態。接著,為了讓錯誤能跳轉出來,此處我們將 setIsFormTouched 設定為 true,可以視為用戶已經完成編輯,因此一定會接觸過表格。
再來,就是使用一個簡單的判斷式 !isFormValid,來確定表單已經完成驗證,若沒有,就會直接跳出函式,後面的 fetch 遞交也就不會執行。

B. 重置表格

有時我們希望輸入後,表格可以重置,讓使用者可以繼續輸入下一次的內容。這時可以使用兩個方式:
  1. 重置 State
  2. 操控 useRef
以下個別示範各自的用法。
1.重置 State
// STATE UPDATE
const [enteredInput, setEnteredInput] = useState("");
const [isFormTouched, setIsFormTouched] = useState("");
const submitHandler = (event) => {
  event.preventDefault();
  ...
  setEnteredInput("");
  setIsFormTouched(false);
};
<input value={enteredInput} />
2.操控 useRef
// useRef UPDATE | NOT RECOMMAND!
const [isFormTouched, setIsFormTouched] = useState("");
const inputRef = useRef();
const submitHandler = (event) => {
  event.preventDefault();
  inputRef.current.value = "";
  setIsFormTouched(false);
};
<input ref={inputRef} />
第二個方式,是使用 useRef 時,又需要重置 Input 的值時使用。因為會直接操縱到 DOM,因此通常不建議如此使用。如果有重置 Input 的需求,還是盡量使用 useState 進行更新。

優化表單設計

前面敘述的寫法,如果超過一個表格時,就會發現重複的程式碼非常多;更麻煩的是,會讓個別元件變得肥大。試想,如果上述的程式碼需要檢驗 7~8 個表格,雖然結構簡單且易懂,但就容易讓程式碼變得冗長。
因此,若要優化整個元件設計,有兩個常見的改善方式:
  1. 拆分個別元件
  2. 使用 Custom hook
  3. 使用第三方套件

A. 拆分個別元件

這個概念也非常直觀,我們僅需要將每一個 Input 都轉換成一個獨立的元件,並在各自的元件中進行驗證即可。
然而,這個架構會導致一個問題:在進行完整全表單的驗證時,就需要將各自的參數全部傳出,這會導致父層元件多了非常多參數傳遞。
這個方式僅建議使用在 2~3 個 Input 時使用。

B. 使用 Custom hook

一開始的時候,確實很難判斷是否要使用 custom hook,但在開始後發現有重複的地方,就可以考慮重新使用 custom hook 來抽象化。
透過抽離所有重複的程式碼,我們也將原先特定的 nameInput 等,調整成 enteredValue 這樣通用的變數。
// in src/hooks/use-input.js
import { useState } from "react";
// MUST start with "use"
const useInput = (validationFunc) => {
  const [enteredValue, setEnteredValue] = useState("");
  const [isTouched, setIsTouched] = useState(false);
  const isValueValid = validationFunc(enteredValue);
  const hasError = isTouched && !isValueValid; // false and true
  const valueChangeHandler = (event) => {
    setEnteredValue(event.target.value);
  };
  const valueBlurHandler = (event) => {
    setIsTouched(true);
  };
  const resetValueHandler = () => {
    setEnteredValue("");
    setIsTouched(false);
  };
  const inputClassHandler = () => {
    const className = hasError ? "form-control invalid" : "form-control";
    return className;
  };
  return {
    value: enteredValue,
    hasError: hasError,
    isValueValid: isValueValid,
    inputClass: inputClassHandler(),
    reset: resetValueHandler,
    valueChangeHandler,
    valueBlurHandler,
  };
};
export default useInput;

Custom hook 實際案例應用:

在我們欲使用表單的元件中,引入 custom hook 來生成表單驗證。此處,我們使用了 name 和 email 這兩個 Input,設計一張表單來驗證。
透過傳入 validation function,我們可以輕易生成個別表單的各種狀態。透過生成的狀態,就可以在想要管理的 Input 中,傳入各種所需的參數。
import useInput from "../hooks/use-input";
const SimpleForm = (props) => {
  const emailValidation = (email) => {
    const checkEmailRegExp = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
    const isEmailInputValid = checkEmailRegExp.test(email);
    return isEmailInputValid;
  };
  const nameValidation = (name) => {
    const isNameInputValid = name.trim().length !== 0;
    return isNameInputValid;
  };
  const {
    value: emailInput,
    hasError: emailInputHasError,
    isValueValid: isEmailInputValid,
    inputClass: emailInputClass,
    reset: resetEmailInput,
    valueChangeHandler: emailInputChangeHandler,
    valueBlurHandler: emailInputBlurHandler,
  } = useInput(emailValidation);
  const {
    value: nameInput,
    hasError: nameInputHasError,
    isValueValid: isNameInputValid,
    inputClass: nameInputClass,
    reset: resetNameInput,
    valueChangeHandler: nameInputChangeHandler,
    valueBlurHandler: nameInputBlurHandler,
  } = useInput(nameValidation);
  // IF INPUT HAS ERROR, THEN SET BUTTON DISABLE
  const isFormValid = !nameInputHasError && !emailInputHasError;
  const submitHandler = (event) => {
    event.preventDefault();
    // CHECK WHETHER EITHER INPUT IS INVALID, THEN DROPOUT
    if (!isNameInputValid || !isEmailInputValid) {
      return;
    }
    resetEmailInput();
    resetNameInput();
    console.log(emailInput);
    console.log(nameInput);
  };
  return (
    <form onSubmit={submitHandler}>
      <div className={emailInputClass}>
        <label htmlFor="email">Your Email</label>
        <input
          value={emailInput}
          type="email"
          id="email"
          onBlur={emailInputBlurHandler}
          onChange={emailInputChangeHandler}
        />
        {emailInputHasError && (
          <p className="error-text">Email format is not allowed.</p>
        )}
      </div>
      <div className={nameInputClass}>
        <label htmlFor="name">Your Name</label>
        <input
          value={nameInput}
          type="text"
          id="name"
          onBlur={nameInputBlurHandler}
          onChange={nameInputChangeHandler}
        />
        {nameInputHasError && (
          <p className="error-text">Name must not be empty.</p>
        )}
      </div>
      <div className="form-actions">
        <button disabled={!isFormValid}>Submit</button>
      </div>
    </form>
  );
};

C.使用第三方套件

除了自己實現表單驗證外,也有設計好的套件可以使用。例如 React 生態系所使用的 Formik,就是一個很完善的表單套件。可以根據自己的需求取用。

結論

表單設計,一直是前端處理很複雜的一塊;但複雜的並非程式,而是在輸入表單時,能否給予使用者完整、友善的體驗,更讓使用者知道,要如何更改輸入才能通過驗證。
因此在設計表單時,除了確認資料的驗證外,瞭解使用者的輸入習慣,甚至要考量到極端情境或資安問題,才能讓使用者在滿足個人的使用需求外,不需要擔心個人的資料被竊取。
若有有興趣或相關經驗的讀者,也歡迎留言一起分享交流!

參考資料

為什麼會看到廣告
此處作為整理前端(Frontend)和相關的 HTML、CSS、JavaScript、React 等前端觀念與技巧,全部都會收錄在這個專題之中。同時也會將相關的技術與反思記錄在此,歡迎各位讀者互相交流。
留言0
查看全部
發表第一個留言支持創作者!