วิธีกันฟอร์มส่งซ้ำด้วย hook ที่คุมการ submit ที่เดียว

คำถามว่าฟอร์มส่งได้ตอนนี้ไหมควรมีคำตอบที่เดียว คือ hook ที่รวมเงื่อนไขทุกข้อ ถือ ref กันคลิกซ้ำระหว่างรอ แล้วส่ง disabled กับ handler ออกไปให้ปุ่ม แต่ด่านฝั่ง browser เป็นแค่ความสะดวก ฝั่ง server ต้องรับคำขอซ้ำได้เองด้วยการ claim แบบ atomic และ idempotency key

เผยแพร่
เวลาอ่าน
5 นาที
แท็ก
#Next.js#React

หมวด 3 · Componentบทที่ 9 จาก 14

สารบัญบทเรียน บทที่ 9 จาก 14
ในบทนี้ 10 หัวข้อ
  1. คำขอซ้ำมาจากหลายทาง
  2. เงื่อนไขการส่งที่กระจายอยู่หลายที่คือจุดรั่ว
  3. ref กันคลิกที่สองได้ state ทำไม่ได้
  4. ฟอร์มแบบ action ของ React 19 ยังต้องมีด่าน
  5. ฝั่ง server ต้องรับคำขอซ้ำได้เอง
  6. rate limit ไม่ได้กันคำขอซ้ำ
  7. ตอนแยก component อย่าแยกด่านออกเป็นสองที่
  8. เมื่อไหร่ไม่ต้องมีด่านเต็มชุด
  9. จากงานจริง
  10. อ้างอิง

ฟอร์มที่ส่งซ้ำไม่ได้มีหลายแบบ สั่งซื้อ ชำระเงิน สร้างใบแจ้งหนี้ ส่งคำเชิญ หรือยืนยันรหัส OTP ทุกแบบเจอปัญหาเดียวกัน คือผู้ใช้ทำให้คำขอเดียวกันไปถึง server สองครั้งได้ และบ่อยครั้งไม่ได้ตั้งใจ บทนี้ว่าด้วยการวางด่านกันไว้ทั้งสองฝั่ง โดยฝั่ง React เริ่มจากโครงของ hook ที่เราแยกไว้ในบท แยก custom hook ทีละเรื่อง

คำขอซ้ำมาจากหลายทาง

ก่อนจะวางด่าน ลองนึกดูว่าคำขอซ้ำเกิดได้จากที่ไหนบ้าง

  • คลิกเร็วสองครั้ง ปุ่มจะ disabled ได้ก็ต่อเมื่อ React render รอบใหม่แล้ว คลิกที่สองที่มาก่อนนั้นยังเห็นปุ่มที่กดได้
  • กด Enter ในช่องกรอก ฟอร์ม HTML ส่งตัวเองเมื่อกด Enter โดยไม่สนว่าปุ่มหน้าตาเป็นยังไง ถ้าด่านอยู่ที่ onClick ของปุ่มอย่างเดียว ทางนี้ข้ามไปได้
  • เปิดสองแท็บ แต่ละแท็บมี state ของตัวเอง ด่านฝั่ง browser ของแท็บหนึ่งไม่รู้จักอีกแท็บ
  • ลองใหม่หลัง timeout คำขอแรกไปถึง server และทำงานเสร็จแล้ว แต่คำตอบหายระหว่างทาง ผู้ใช้เห็น error จึงกดอีกครั้ง

สองข้อแรกกันได้ฝั่ง browser สองข้อหลังกันได้ที่ server เท่านั้น ด่านจึงต้องมีทั้งสองฝั่ง

เงื่อนไขการส่งที่กระจายอยู่หลายที่คือจุดรั่ว

อาการที่เจอบ่อยคือเงื่อนไขการส่งกระจายอยู่สามที่ disabled ของปุ่มคำนวณจากค่าหนึ่ง handler มี if (busy) return อีกชุด และค่า loading ที่ใช้ก็เป็นตัวเดียวกับที่ส่วนอื่นของหน้าใช้ เช่น loading ของตะกร้าที่ส่วนอื่นเซ็ตกลับเป็น false ได้ทุกเมื่อ

// ก่อน: เงื่อนไขแยกกันสองที่ และ loading ใช้ร่วมกับงานอื่นของตะกร้า
<button disabled={cart.loading || !canPay} onClick={() => placeOrder(cart)}>สั่งซื้อ</button>

async function placeOrder(cart: Cart) {
  if (!cart.items.length) return;
  await checkout(cart.id);   // คลิกเร็วสองครั้ง ส่งไปสองรอบ
}

// หลัง: hook เดียว เงื่อนไขเดียว และ ref ที่มีแค่การสั่งซื้อนี้แตะ
function usePlaceOrder(cart: Cart, canPay: boolean) {
  const inFlight = useRef(false);
  const [placing, setPlacing] = useState(false);
  const canSubmit = !cart.loading && canPay && cart.items.length > 0 && !placing;

  async function place() {
    if (!canSubmit || inFlight.current) return;
    inFlight.current = true;
    setPlacing(true);
    try {
      await checkout(cart.id, attemptId(cart));
    } catch (error) {
      inFlight.current = false;   // ล้มเหลวแล้วให้ลองใหม่ได้
      throw error;
    } finally {
      setPlacing(false);          // สั่งซื้อสำเร็จแล้วล็อกไว้ ไม่ปลด ref
    }
  }

  return { disabled: !canSubmit, onClick: place, placing };
}

ใน component ปุ่มรับค่าจาก hook ตรง ๆ <button {...order}> หรือ <button disabled={order.disabled} onClick={order.onClick}> ไม่มีที่ไหนคำนวณเงื่อนไขซ้ำอีก ถ้าวันหนึ่งต้องเพิ่มเงื่อนไข เช่น ต้องยอมรับเงื่อนไขการใช้งานก่อน ก็เพิ่มใน canSubmit บรรทัดเดียว ปุ่มกับ handler เห็นตรงกันทันที

ref กันคลิกที่สองได้ state ทำไม่ได้

placing เป็น state ค่าจะเปลี่ยนหลัง React render รอบใหม่ ระหว่างคลิกแรกกับรอบ render นั้น handler ของคลิกที่สองยังเห็น placing เป็น false อยู่ ส่วน inFlight เป็น ref ค่าเปลี่ยนทันทีที่เซ็ต คลิกที่สองจึงเห็นค่า true และหยุดที่บรรทัดแรก

เราจึงใช้ทั้งสองตัวคู่กัน ref เป็นด่านจริงที่กันคลิกซ้ำ ส่วน state มีไว้ให้หน้าจอรู้ว่าต้องแสดงสถานะกำลังส่ง เช่น ตัวหมุนบนปุ่ม

อีกเรื่องที่ตั้งใจไว้ในตัวอย่างคือ ref ปลดเฉพาะตอนล้มเหลว ถ้าการสั่งซื้อสำเร็จ ref ยังเป็น true ต่อไป ปุ่มกดซ้ำไม่ได้แม้ state จะกลับเป็น false แล้ว หน้าที่ต้องสั่งซื้อได้หลายครั้งในหน้าเดียว เช่น ฟอร์มเพิ่มรายการทีละแถว ให้ปลด ref ใน finally แทน

ฟอร์มแบบ action ของ React 19 ยังต้องมีด่าน

React 19 มี <form action={…}> กับ useActionState ที่ให้ค่า isPending มาเลย และ useFormStatus ที่ปุ่มข้างในฟอร์มใช้อ่านว่ากำลังส่งอยู่หรือไม่ ทั้งหมดนี้ช่วยเรื่องการแสดงสถานะได้มาก แต่ไม่ได้ตัดคำขอที่สองทิ้ง เอกสารของ useActionState เขียนไว้ว่าเมื่อเรียกหลายครั้ง action จะเข้าคิวและรันต่อกันตามลำดับ ผลคือส่งซ้ำได้เหมือนเดิม แค่ไม่พร้อมกัน

function PlaceOrderForm({ cartId }: { cartId: string }) {
  const [attempt] = useState(() => crypto.randomUUID());
  const [state, formAction, isPending] = useActionState(placeOrderAction, null);
  return (
    <form action={formAction}>
      <input type="hidden" name="cartId" value={cartId} />
      <input type="hidden" name="attemptId" value={attempt} />
      <button disabled={isPending}>สั่งซื้อ</button>
      {state?.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

ตัวอย่างนี้ใช้ isPending ปิดปุ่ม และส่ง attemptId ที่สร้างครั้งเดียวต่อการเปิดฟอร์มไปกับคำขอ ถ้ามีคำขอที่สองหลุดเข้าคิวไปได้ server จะเห็น attemptId เดิมและรู้ว่าเป็นคำขอซ้ำ ซึ่งเป็นด่านในหัวข้อถัดไป

ฝั่ง server ต้องรับคำขอซ้ำได้เอง

ด่านฝั่ง browser กันได้แค่คลิกเร็วกับการกด Enter สองแท็บและการลองใหม่หลัง timeout ไปถึง server ได้เสมอ ฝั่ง server จึงต้องมีด่านของตัวเองสองชั้น

  • claim แบบ atomic เปลี่ยนสถานะของข้อมูลด้วยคำสั่งเดียวที่ตรวจสถานะเดิมไปพร้อมกัน เช่น เปลี่ยนตะกร้าจาก open เป็น ordering เฉพาะเมื่อยังเป็น open อยู่ ถ้าสองคำขอมาพร้อมกัน ฐานข้อมูลจะให้ผ่านแค่คำขอเดียว อีกคำขอได้แถวกลับมาศูนย์แถว
  • idempotency key คำขอที่มี key เดิมได้คำตอบเดิม ไม่สร้างคำสั่งซื้อใหม่ เก็บ key ไว้ในคอลัมน์ที่มี unique index จะได้กันซ้ำได้แม้ตรวจพลาด
// lib/orders/place-order.ts ฟังก์ชันฝั่ง server ไม่มี 'use server'
import 'server-only';

export async function placeOrderFor(userId: string, cartId: string, attemptId: string) {
  return db.transaction(async (tx) => {
    const done = await tx.query('select id from orders where attempt_id = $1', [attemptId]);
    if (done.rows[0]) return { orderId: done.rows[0].id };          // คำขอซ้ำ ตอบผลเดิม

    const claimed = await tx.query(
      `update carts set status = 'ordering'
       where id = $1 and user_id = $2 and status = 'open' returning id`,
      [cartId, userId],
    );
    if (!claimed.rowCount) return { error: 'ตะกร้านี้สั่งซื้อไปแล้วหรือหมดอายุ' };

    const order = await tx.query(
      'insert into orders (cart_id, user_id, attempt_id) values ($1, $2, $3) returning id',
      [cartId, userId, attemptId],                                    // attempt_id มี unique index
    );
    return { orderId: order.rows[0].id };
  });
}

server action ที่ฟอร์มเรียกเป็นแค่ไฟล์ 'use server' บาง ๆ ที่อ่านผู้ใช้จาก session แล้วเรียก placeOrderFor ไม่รับ userId จากฟอร์ม เพราะทุก export ของไฟล์ 'use server' คือ endpoint ที่ใครก็ส่งค่าอะไรมาก็ได้ เรื่องนี้มีละเอียดในบท แยก server action และ data access ออกจาก component ส่วนหลักคิดของ idempotency ใช้ได้กับทุกภาษา ถ้าอยากเห็นฝั่ง Laravel ลองอ่าน Idempotency คืออะไร ได้ครับ

rate limit ไม่ได้กันคำขอซ้ำ

ทางที่เรามักนึกถึงก่อนคือจำกัดจำนวนคำขอต่อนาที rate limit มีไว้กันคนส่งคำขอจำนวนมากเกินปกติ แต่คำขอซ้ำสองคำขอที่มาห่างกันไม่กี่มิลลิวินาทีอยู่ในเกณฑ์ปกติเสมอ ตั้งเกณฑ์ให้ต่ำพอจะตัดคำขอที่สองได้ ผู้ใช้ทั่วไปที่กดเร็วหน่อยก็จะโดนตัดไปด้วย ด่านที่ตรงกับปัญหาคือ claim แบบ atomic กับ idempotency key

ตอนแยก component อย่าแยกด่านออกเป็นสองที่

การแยก component ใหญ่เป็นจังหวะที่ด่านแบบนี้รั่วบ่อยที่สุด เช่น ย้ายปุ่มไปเป็น SubmitBar แล้วคำนวณ disabled ใหม่ในนั้นจาก props ที่ได้มา ขณะที่ handler ยังเช็กอีกชุดใน component หลัก สองชุดเริ่มจากค่าเดียวกัน แต่พอมีคนเพิ่มเงื่อนไขในชุดเดียวก็เริ่มไม่ตรงกัน

กฎง่าย ๆ คือ SubmitBar ไม่คำนวณอะไรเอง รับ disabled, onClick และ placing จาก hook ไปใช้ตรง ๆ ตามแบบในบท แยก component ย่อยที่รับ props และส่ง callback ออก และตอนรีวิว ให้ค้นในไฟล์ว่ามีที่ไหนเช็กเงื่อนไขการส่งนอกจาก hook บ้าง ถ้ามี ให้ย้ายเข้าไปรวมกัน

เมื่อไหร่ไม่ต้องมีด่านเต็มชุด

  • คำขอที่ส่งซ้ำแล้วผลเหมือนเดิม เช่น บันทึกค่าตั้งด้วยค่าเดิมซ้ำสองครั้ง ปิดปุ่มระหว่างบันทึกก็พอ ไม่ต้องมี ref หรือ idempotency key
  • ฟอร์มค้นหาหรือกรองข้อมูล คำขอใหม่ควรมาแทนคำขอเก่า จึงใช้การยกเลิกคำขอเก่าด้วย AbortController แทนการกัน
  • ปุ่มที่ทำงานในเครื่องอย่างเดียว เช่น เปิด dialog หรือสลับแท็บ ไม่ต้องมีด่านอะไรเลย

ส่วนคำขอที่สร้างของใหม่ ตัดเงิน หรือส่งข้อความหาคนอื่น ให้มีด่านครบทั้งสองฝั่งเสมอ

จากงานจริง

ในงาน refactor ร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo เราตั้งข้อนี้เป็นกฎตั้งแต่การแยกหน้าชำระเงินในรอบแรก แล้วใช้กับทุก hook ที่คุมการส่งในรอบหลัง ๆ ทั้งฟอร์มยืนยันตัวตน และแถบบันทึกของหน้าตั้งค่า ข้อที่รีวิวตรวจทุกครั้งคือมีที่ไหนเช็กเงื่อนไขการส่งนอก hook หรือไม่ และ ref ปลดตอนล้มเหลวหรือเปล่า ส่วนด่านฝั่ง server แยกเป็นงานของระบบหลังร้านที่รับคำสั่งซื้อ เพราะด่านฝั่ง React ไม่ว่าจะดีแค่ไหนก็กันสองแท็บไม่ได้

อ้างอิง

บทถัดไป

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →