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

閱讀時間約 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
查看全部
發表第一個留言支持創作者!
useContext 是一種 React hook,讓我們能夠直接取用其他元件的 Context,而無須層層傳遞 props,進而使程式碼簡潔易讀。
在傳統開發的過程中,很容易會搞混一般的 this 和箭頭函式(arrow function)中的 lexcial "this" 兩者的差異。本文就以實際的例子來說明各自的差異,以及在未來使用時需要注意哪一些細節。
useContext 是一種 React hook,讓我們能夠直接取用其他元件的 Context,而無須層層傳遞 props,進而使程式碼簡潔易讀。
在傳統開發的過程中,很容易會搞混一般的 this 和箭頭函式(arrow function)中的 lexcial "this" 兩者的差異。本文就以實際的例子來說明各自的差異,以及在未來使用時需要注意哪一些細節。
你可能也想看
Google News 追蹤
Thumbnail
接下來第二部分我們持續討論美國總統大選如何佈局, 以及選前一週到年底的操作策略建議 分析兩位候選人政策利多/ 利空的板塊和股票
Thumbnail
🤔為什麼團長的能力是死亡筆記本? 🤔為什麼像是死亡筆記本呢? 🤨作者巧思-讓妮翁死亡合理的幾個伏筆
Thumbnail
前言 嗨,各位懷舊遊戲愛好者!今天要跟大家分享一個有趣的主題:如何利用React和Pixi.js這兩大神兵利器,重塑我們那個年代的經典紅白機打磚塊遊戲! 先跟大家簡單科普一下,React是一個超級火爆的前端框架,能讓我們輕鬆創建可重用的UI組件,組件間的狀態管理也相當方便。。。
Thumbnail
想要知道如何用最新技術來製作一個App嗎? 跟著JayLin用React | Redux Tool Kit | TypeScript | TailwildCSS 來製作一個Drawing App
Thumbnail
React Hook onclick call a callback function with params, and change css style example: 本筆記參考: 1. https://www.codegrepper.com/code-examples/javascrip
Thumbnail
Create React App 提供了快速建立React App環境的方法: 1. 安裝node.js 2. 建立React project: $ npx create-react-app my-app 3. 啟動app $ cd my-app $ npm start npm start之後即打
Thumbnail
接續上一篇,navbar元件其實寫的不是很好,還不能說是可真正reuse,我們把程式改成這樣,透過props傳入navbar的items,定義好navbar title, li的href/name/active等等,就可以達到navbar元件無須改code就能重用的目的! Navbar 元件中用m
Thumbnail
接續上一篇,這邊要來寫一個React hello world app,最後安裝webpack-dev-server來提升開發效率。 使用npm安裝react, react-dom: $ npm install react react-dom --save dependencies下紀錄的是生產環境會
Thumbnail
React開發有兩種方式,一種是使用CDN方式include react的官方lib,然後使用babel來將JSX編譯成瀏覽器看得懂的javascript。 但是在react中還會使用到sass, scss等等,還需要額外編譯成css瀏覽器才看得懂。 而webpack的誕生,就是為了解決上述的問題,
Thumbnail
接下來第二部分我們持續討論美國總統大選如何佈局, 以及選前一週到年底的操作策略建議 分析兩位候選人政策利多/ 利空的板塊和股票
Thumbnail
🤔為什麼團長的能力是死亡筆記本? 🤔為什麼像是死亡筆記本呢? 🤨作者巧思-讓妮翁死亡合理的幾個伏筆
Thumbnail
前言 嗨,各位懷舊遊戲愛好者!今天要跟大家分享一個有趣的主題:如何利用React和Pixi.js這兩大神兵利器,重塑我們那個年代的經典紅白機打磚塊遊戲! 先跟大家簡單科普一下,React是一個超級火爆的前端框架,能讓我們輕鬆創建可重用的UI組件,組件間的狀態管理也相當方便。。。
Thumbnail
想要知道如何用最新技術來製作一個App嗎? 跟著JayLin用React | Redux Tool Kit | TypeScript | TailwildCSS 來製作一個Drawing App
Thumbnail
React Hook onclick call a callback function with params, and change css style example: 本筆記參考: 1. https://www.codegrepper.com/code-examples/javascrip
Thumbnail
Create React App 提供了快速建立React App環境的方法: 1. 安裝node.js 2. 建立React project: $ npx create-react-app my-app 3. 啟動app $ cd my-app $ npm start npm start之後即打
Thumbnail
接續上一篇,navbar元件其實寫的不是很好,還不能說是可真正reuse,我們把程式改成這樣,透過props傳入navbar的items,定義好navbar title, li的href/name/active等等,就可以達到navbar元件無須改code就能重用的目的! Navbar 元件中用m
Thumbnail
接續上一篇,這邊要來寫一個React hello world app,最後安裝webpack-dev-server來提升開發效率。 使用npm安裝react, react-dom: $ npm install react react-dom --save dependencies下紀錄的是生產環境會
Thumbnail
React開發有兩種方式,一種是使用CDN方式include react的官方lib,然後使用babel來將JSX編譯成瀏覽器看得懂的javascript。 但是在react中還會使用到sass, scss等等,還需要額外編譯成css瀏覽器才看得懂。 而webpack的誕生,就是為了解決上述的問題,