ไทย

เจาะลึก hook useDeferredValue ของ React เรียนรู้วิธีแก้ปัญหา UI กระตุก เข้าใจ concurrency เปรียบเทียบกับ useTransition และสร้างแอปที่เร็วยิ่งขึ้นสำหรับผู้ใช้ทั่วโลก

useDeferredValue ของ React: สุดยอดคู่มือสร้าง UI ที่ลื่นไหลไม่สะดุด

ในโลกของการพัฒนาเว็บสมัยใหม่ ประสบการณ์ของผู้ใช้ (user experience) คือสิ่งสำคัญที่สุด อินเทอร์เฟซที่รวดเร็วและตอบสนองได้ดีไม่ใช่สิ่งที่ฟุ่มเฟือยอีกต่อไป แต่เป็นสิ่งที่ผู้ใช้คาดหวัง สำหรับผู้ใช้ทั่วโลกที่ใช้อุปกรณ์และสภาพเครือข่ายที่หลากหลาย UI ที่ช้าและกระตุกอาจเป็นตัวตัดสินระหว่างลูกค้าที่จะกลับมาใช้บริการกับลูกค้าที่หายไป และนี่คือจุดที่ฟีเจอร์ concurrent ของ React 18 โดยเฉพาะ hook useDeferredValue เข้ามาเปลี่ยนเกม

หากคุณเคยสร้างแอปพลิเคชัน React ที่มีช่องค้นหาสำหรับกรองรายการขนาดใหญ่, ตารางข้อมูลที่อัปเดตแบบเรียลไทม์, หรือแดชบอร์ดที่ซับซ้อน คุณน่าจะเคยเจอกับอาการ UI ค้างที่น่ากลัว เมื่อผู้ใช้พิมพ์ข้อความ แอปพลิเคชันทั้งหมจะหยุดตอบสนองไปชั่วขณะ สิ่งนี้เกิดขึ้นเพราะการเรนเดอร์แบบดั้งเดิมใน React นั้นเป็นการทำงานแบบปิดกั้น (blocking) เมื่อมีการอัปเดต state จะเกิดการ re-render และจะไม่มีอะไรเกิดขึ้นได้จนกว่าการ re-render นั้นจะเสร็จสิ้น

คู่มือฉบับสมบูรณ์นี้จะพาคุณเจาะลึก hook useDeferredValue เราจะสำรวจปัญหาที่มันช่วยแก้ไข, วิธีการทำงานเบื้องหลังร่วมกับเอนจิ้น concurrent ใหม่ของ React, และวิธีที่คุณจะสามารถนำไปใช้เพื่อสร้างแอปพลิเคชันที่ตอบสนองได้อย่างน่าทึ่งและรู้สึกรวดเร็ว แม้ว่าจะต้องทำงานหนักก็ตาม เราจะครอบคลุมตัวอย่างการใช้งานจริง, รูปแบบการใช้งานขั้นสูง, และแนวทางปฏิบัติที่ดีที่สุดที่สำคัญสำหรับผู้ใช้ทั่วโลก

ทำความเข้าใจปัญหาหลัก: UI ที่ถูกปิดกั้น (The Blocking UI)

ก่อนที่เราจะเข้าใจถึงคุณค่าของวิธีแก้ปัญหา เราต้องเข้าใจตัวปัญหาให้ถ่องแท้เสียก่อน ใน React เวอร์ชันก่อน 18 การเรนเดอร์เป็นกระบวนการที่ทำงานตามลำดับ (synchronous) และไม่สามารถขัดจังหวะได้ ลองนึกภาพถนนเลนเดียว: เมื่อรถคันหนึ่ง (การเรนเดอร์) เข้าไปในเลน จะไม่มีรถคันอื่นผ่านไปได้จนกว่ารถคันนั้นจะถึงปลายทาง นี่คือวิธีการทำงานของ React ในอดีต

ลองพิจารณาสถานการณ์คลาสสิก: รายการสินค้าที่สามารถค้นหาได้ ผู้ใช้พิมพ์ในช่องค้นหา และรายการสินค้าหลายพันรายการด้านล่างจะถูกกรองตามข้อมูลที่ผู้ใช้ป้อน

การใช้งานทั่วไป (ที่มักจะช้า)

นี่คือหน้าตาของโค้ดในโลกก่อน React 18 หรือโค้ดที่ไม่ได้ใช้ฟีเจอร์ concurrent:

โครงสร้างของ Component:

ไฟล์: SearchPage.js

import React, { useState } from 'react'; import ProductList from './ProductList'; import { generateProducts } from './data'; // ฟังก์ชันที่สร้าง array ขนาดใหญ่ const allProducts = generateProducts(20000); // สมมติว่ามีสินค้า 20,000 รายการ function SearchPage() { const [query, setQuery] = useState(''); const filteredProducts = allProducts.filter(product => { return product.name.toLowerCase().includes(query.toLowerCase()); }); function handleChange(e) { setQuery(e.target.value); } return (

); } export default SearchPage;

ทำไมมันถึงช้า?

มาลองไล่ตามการกระทำของผู้ใช้กัน:

  1. ผู้ใช้พิมพ์ตัวอักษรหนึ่งตัว เช่น 'a'
  2. อีเวนต์ onChange ทำงาน และเรียกฟังก์ชัน handleChange
  3. setQuery('a') ถูกเรียก ซึ่งเป็นการกำหนดให้มีการ re-render คอมโพเนนต์ SearchPage
  4. React เริ่มต้นการ re-render
  5. ในระหว่างการเรนเดอร์ บรรทัด const filteredProducts = allProducts.filter(...) จะถูกประมวลผล นี่คือส่วนที่ใช้ทรัพยากรสูง การกรอง array ที่มีสมาชิก 20,000 รายการ แม้จะใช้การตรวจสอบ 'includes' แบบง่ายๆ ก็ยังต้องใช้เวลา
  6. ในขณะที่การกรองกำลังดำเนินอยู่ main thread ของเบราว์เซอร์จะถูกใช้งานทั้งหมด ทำให้ไม่สามารถประมวลผล input ใหม่จากผู้ใช้, ไม่สามารถอัปเดตช่อง input ให้แสดงผล, และไม่สามารถรัน JavaScript อื่นๆ ได้ UI จึงอยู่ในสถานะ ถูกปิดกั้น (blocked)
  7. เมื่อการกรองเสร็จสิ้น React จะดำเนินการเรนเดอร์คอมโพเนนต์ ProductList ซึ่งอาจเป็นการทำงานที่หนักเช่นกันหากต้องเรนเดอร์ DOM nodes หลายพันโหนด
  8. สุดท้าย หลังจากงานทั้งหมดนี้ DOM ก็จะถูกอัปเดต ผู้ใช้จะเห็นตัวอักษร 'a' ปรากฏในช่อง input และรายการสินค้าก็จะอัปเดตตาม

หากผู้ใช้พิมพ์เร็ว—เช่น พิมพ์คำว่า "apple"—กระบวนการปิดกั้นทั้งหมดนี้จะเกิดขึ้นสำหรับ 'a', จากนั้น 'ap', 'app', 'appl', และ 'apple' ผลลัพธ์คือความหน่วงที่เห็นได้ชัด ซึ่งช่อง input จะกระตุกและพยายามตามการพิมพ์ของผู้ใช้ให้ทัน นี่เป็นประสบการณ์ผู้ใช้ที่แย่ โดยเฉพาะบนอุปกรณ์ที่มีประสิทธิภาพต่ำซึ่งพบได้ทั่วไปในหลายพื้นที่ของโลก

ขอแนะนำ Concurrency ของ React 18

React 18 ได้เปลี่ยนกระบวนทัศน์นี้ไปโดยสิ้นเชิงด้วยการนำเสนอ concurrency ซึ่งไม่ใช่สิ่งเดียวกับการทำงานแบบขนาน (parallelism - การทำหลายอย่างในเวลาเดียวกัน) แต่หมายถึงความสามารถของ React ในการ หยุดพัก, ทำงานต่อ, หรือยกเลิกการเรนเดอร์ ได้ ถนนเลนเดียวตอนนี้มีเลนแซงและมีผู้ควบคุมการจราจรแล้ว

ด้วย concurrency, React สามารถแบ่งการอัปเดตออกเป็นสองประเภท:

ตอนนี้ React สามารถเริ่มการเรนเดอร์แบบ "transition" ที่ไม่เร่งด่วน และหากมีการอัปเดตที่เร่งด่วนกว่า (เช่น การกดแป้นพิมพ์อีกครั้ง) เข้ามา มันสามารถหยุดการเรนเดอร์ที่ใช้เวลานานนั้นไว้ชั่วคราว จัดการการอัปเดตที่เร่งด่วนก่อน แล้วจึงกลับมาทำงานต่อได้ ซึ่งช่วยให้ UI ยังคงโต้ตอบได้ตลอดเวลา hook useDeferredValue คือเครื่องมือหลักในการใช้ประโยชน์จากความสามารถใหม่นี้

`useDeferredValue` คืออะไร? คำอธิบายโดยละเอียด

โดยแก่นแท้แล้ว useDeferredValue คือ hook ที่ให้คุณบอก React ได้ว่าค่าบางอย่างในคอมโพเนนต์ของคุณนั้นไม่เร่งด่วน มันจะรับค่าเข้าไปและคืนค่าใหม่ที่เป็นสำเนาของค่านั้น ซึ่งจะ "ตามหลัง" อยู่เล็กน้อยหากมีการอัปเดตที่เร่งด่วนเกิดขึ้น

Syntax การใช้งาน

hook นี้ใช้งานง่ายอย่างไม่น่าเชื่อ:

import { useDeferredValue } from 'react'; const deferredValue = useDeferredValue(value);

แค่นั้นเอง คุณส่งค่าเข้าไป แล้วมันจะให้ค่าเวอร์ชันที่ถูกหน่วงเวลา (deferred version) กลับมา

การทำงานเบื้องหลัง

มาไขความลับเบื้องหลังกัน เมื่อคุณใช้ useDeferredValue(query) นี่คือสิ่งที่ React ทำ:

  1. การเรนเดอร์ครั้งแรก (Initial Render): ในการเรนเดอร์ครั้งแรก deferredQuery จะมีค่าเหมือนกับ query เริ่มต้น
  2. เกิดการอัปเดตเร่งด่วน: ผู้ใช้พิมพ์ตัวอักษรใหม่ state ของ query อัปเดตจาก 'a' เป็น 'ap'
  3. การเรนเดอร์ที่มีลำดับความสำคัญสูง: React จะทำการ re-render ทันที ในระหว่างการ re-render ครั้งแรกที่เร่งด่วนนี้ useDeferredValue จะรู้ว่ามีการอัปเดตเร่งด่วนกำลังดำเนินการอยู่ ดังนั้นมันจึงยังคงคืนค่าก่อนหน้า คือ 'a' คอมโพเนนต์ของคุณจะ re-render อย่างรวดเร็ว เพราะค่าของช่อง input กลายเป็น 'ap' (จาก state) แต่ส่วนของ UI ที่ขึ้นอยู่กับ deferredQuery (รายการที่ทำงานช้า) ยังคงใช้ค่าเก่าและไม่จำเป็นต้องคำนวณใหม่ ทำให้ UI ยังคงตอบสนองได้ดี
  4. การเรนเดอร์ที่มีลำดับความสำคัญต่ำ: ทันทีหลังจากการเรนเดอร์เร่งด่วนเสร็จสิ้น React จะเริ่มการ re-render ครั้งที่สองที่ไม่เร่งด่วนในเบื้องหลัง ในการเรนเดอร์ *ครั้งนี้* useDeferredValue จะคืนค่าใหม่คือ 'ap' การเรนเดอร์ในเบื้องหลังนี้คือสิ่งที่กระตุ้นให้เกิดการกรองข้อมูลที่ใช้ทรัพยากรสูง
  5. ความสามารถในการขัดจังหวะ (Interruptibility): นี่คือส่วนสำคัญที่สุด หากผู้ใช้พิมพ์ตัวอักษรอีกตัว ('app') ในขณะที่การเรนเดอร์ลำดับความสำคัญต่ำสำหรับ 'ap' ยังคงดำเนินอยู่ React จะทิ้งการเรนเดอร์เบื้องหลังนั้นไปแล้วเริ่มใหม่ทั้งหมด โดยจะให้ความสำคัญกับการอัปเดตเร่งด่วนใหม่ ('app') ก่อน แล้วจึงกำหนดเวลาการเรนเดอร์เบื้องหลังใหม่ด้วยค่า deferred ล่าสุด

สิ่งนี้ช่วยให้มั่นใจได้ว่างานที่ใช้ทรัพยากรสูงจะทำกับข้อมูลล่าสุดเสมอ และจะไม่ขัดขวางผู้ใช้จากการป้อนข้อมูลใหม่ เป็นวิธีที่มีประสิทธิภาพในการลดลำดับความสำคัญของการคำนวณที่หนักหน่วงโดยไม่ต้องใช้ตรรกะ debouncing หรือ throttling ที่ซับซ้อนด้วยตนเอง

การนำไปใช้จริง: แก้ไขการค้นหาที่ช้าของเรา

มาปรับปรุง (refactor) ตัวอย่างก่อนหน้านี้โดยใช้ useDeferredValue เพื่อดูการทำงานจริงกัน

ไฟล์: SearchPage.js (ปรับปรุงแล้ว)

import React, { useState, useDeferredValue, useMemo } from 'react'; import ProductList from './ProductList'; import { generateProducts } from './data'; const allProducts = generateProducts(20000); // คอมโพเนนต์สำหรับแสดงรายการ, ทำ memoized เพื่อประสิทธิภาพ const MemoizedProductList = React.memo(ProductList); function SearchPage() { const [query, setQuery] = useState(''); // 1. หน่วงค่า query ค่านี้จะตามหลัง state 'query' อยู่เล็กน้อย const deferredQuery = useDeferredValue(query); // 2. การกรองที่ใช้ทรัพยากรสูงตอนนี้ขับเคลื่อนด้วย deferredQuery // เรายังห่อด้วย useMemo เพื่อการปรับปรุงประสิทธิภาพเพิ่มเติม const filteredProducts = useMemo(() => { console.log('Filtering for:', deferredQuery); return allProducts.filter(product => { return product.name.toLowerCase().includes(deferredQuery.toLowerCase()); }); }, [deferredQuery]); // จะคำนวณใหม่เมื่อ deferredQuery เปลี่ยนแปลงเท่านั้น function handleChange(e) { // การอัปเดต state นี้เป็นแบบเร่งด่วนและจะถูกประมวลผลทันที setQuery(e.target.value); } return (

{/* 3. input ถูกควบคุมโดย state 'query' ที่มีความสำคัญสูง ทำให้รู้สึกทันที */} {/* 4. รายการถูกเรนเดอร์โดยใช้ผลลัพธ์จากการอัปเดตที่ถูกหน่วงเวลาและมีความสำคัญต่ำ */}
); } export default SearchPage;

การเปลี่ยนแปลงในประสบการณ์ผู้ใช้

ด้วยการเปลี่ยนแปลงง่ายๆ นี้ ประสบการณ์ของผู้ใช้จะเปลี่ยนไปอย่างสิ้นเชิง:

ตอนนี้แอปพลิเคชันให้ความรู้สึกที่เร็วขึ้นและเป็นมืออาชีพมากขึ้นอย่างเห็นได้ชัด

`useDeferredValue` vs. `useTransition`: แตกต่างกันอย่างไร?

นี่เป็นหนึ่งในข้อสงสัยที่พบบ่อยที่สุดสำหรับนักพัฒนาที่กำลังเรียนรู้ Concurrent React ทั้ง useDeferredValue และ useTransition ถูกใช้เพื่อทำเครื่องหมายการอัปเดตว่าไม่เร่งด่วน แต่จะถูกนำไปใช้ในสถานการณ์ที่แตกต่างกัน

ข้อแตกต่างที่สำคัญคือ: คุณควบคุมโค้ดส่วนไหนได้?

`useTransition`

คุณจะใช้ useTransition เมื่อคุณสามารถควบคุมโค้ดที่ทำให้เกิดการอัปเดต state ได้ มันจะให้ฟังก์ชันที่มักเรียกว่า startTransition มาให้คุณเพื่อใช้ห่อการอัปเดต state ของคุณ

const [isPending, startTransition] = useTransition(); function handleChange(e) { const nextValue = e.target.value; // อัปเดตส่วนที่เร่งด่วนทันที setInputValue(nextValue); // ห่อการอัปเดตที่ช้าด้วย startTransition startTransition(() => { setSearchQuery(nextValue); }); }

`useDeferredValue`

คุณจะใช้ useDeferredValue เมื่อคุณไม่สามารถควบคุมโค้ดที่อัปเดตค่านั้นได้ ซึ่งมักจะเกิดขึ้นเมื่อค่ามาจาก props, จากคอมโพเนนต์แม่, หรือจาก hook อื่นๆ ที่มาจากไลบรารีภายนอก

function SlowList({ valueFromParent }) { // เราควบคุมไม่ได้ว่า valueFromParent ถูกตั้งค่ามาอย่างไร // เราแค่รับค่ามาและต้องการหน่วงการเรนเดอร์ตามค่านั้น const deferredValue = useDeferredValue(valueFromParent); // ... ใช้ deferredValue เพื่อเรนเดอร์ส่วนที่ทำงานช้าของคอมโพเนนต์ }

สรุปการเปรียบเทียบ

ฟีเจอร์ `useTransition` `useDeferredValue`
สิ่งที่มันห่อ (wraps) ฟังก์ชันอัปเดต state (เช่น startTransition(() => setState(...))) ค่า (value) (เช่น useDeferredValue(myValue))
จุดควบคุม (Control Point) เมื่อคุณควบคุม event handler หรือตัวกระตุ้นการอัปเดต เมื่อคุณได้รับค่า (เช่น จาก props) และไม่สามารถควบคุมแหล่งที่มาของมันได้
สถานะการโหลด (Loading State) มี `isPending` ที่เป็น boolean มาให้ในตัว ไม่มี flag ในตัว แต่สามารถสร้างได้จาก `const isStale = originalValue !== deferredValue;`
การเปรียบเทียบ (Analogy) คุณเป็นผู้ควบคุมการเดินรถ ที่ตัดสินใจว่ารถไฟขบวนไหน (การอัปเดต state) จะออกเดินทางไปบนรางที่ช้า คุณเป็นผู้จัดการสถานี ที่เห็นค่ามาถึงโดยรถไฟและตัดสินใจพักค่านั้นไว้ที่สถานีสักครู่ก่อนที่จะแสดงบนป้ายประกาศหลัก

กรณีการใช้งานขั้นสูงและรูปแบบต่างๆ

นอกเหนือจากการกรองรายการง่ายๆ useDeferredValue ยังปลดล็อกรูปแบบการใช้งานที่มีประสิทธิภาพหลายอย่างสำหรับการสร้างอินเทอร์เฟซผู้ใช้ที่ซับซ้อน

รูปแบบที่ 1: การแสดง UI ที่ "ล้าสมัย" (Stale) เพื่อเป็นการตอบสนอง

UI ที่อัปเดตช้าเล็กน้อยโดยไม่มีการตอบสนองทางภาพอาจทำให้ผู้ใช้รู้สึกว่ามันมีบั๊ก พวกเขาอาจสงสัยว่าข้อมูลที่ป้อนเข้าไปถูกรับรู้หรือไม่ รูปแบบที่ดีคือการให้สัญญาณเล็กน้อยว่าข้อมูลกำลังอัปเดต

คุณสามารถทำได้โดยการเปรียบเทียบค่าดั้งเดิมกับค่าที่ถูกหน่วงเวลา ถ้าค่าทั้งสองต่างกัน แสดงว่ามีการเรนเดอร์เบื้องหลังที่รอดำเนินการอยู่

function SearchPage() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); // boolean นี้บอกเราว่ารายการกำลังตามหลัง input อยู่หรือไม่ const isStale = query !== deferredQuery; const filteredProducts = useMemo(() => { // ... การกรองที่ใช้ทรัพยากรสูงโดยใช้ deferredQuery }, [deferredQuery]); return (

setQuery(e.target.value)} />
); }

ในตัวอย่างนี้ ทันทีที่ผู้ใช้พิมพ์ isStale จะกลายเป็น true รายการจะจางลงเล็กน้อยเพื่อบ่งชี้ว่ากำลังจะอัปเดต เมื่อการเรนเดอร์ที่ถูกหน่วงเวลาเสร็จสิ้น query และ deferredQuery จะเท่ากันอีกครั้ง isStale จะกลายเป็น false และรายการจะกลับมามีความทึบเต็มที่พร้อมกับข้อมูลใหม่ นี่คือสิ่งที่เทียบเท่ากับ flag isPending จาก useTransition

รูปแบบที่ 2: การหน่วงการอัปเดตบนกราฟและการแสดงผลข้อมูล

ลองนึกภาพการแสดงผลข้อมูลที่ซับซ้อน เช่น แผนที่ภูมิศาสตร์หรือกราฟทางการเงิน ที่ re-render ตามแถบเลื่อนที่ผู้ใช้ควบคุมสำหรับช่วงวันที่ การลากแถบเลื่อนอาจทำให้เกิดอาการกระตุกอย่างรุนแรงหากกราฟต้อง re-render ทุกๆ พิกเซลของการเคลื่อนไหว

ด้วยการหน่วงค่าของแถบเลื่อน คุณสามารถมั่นใจได้ว่าตัวควบคุมแถบเลื่อนเองยังคงราบรื่นและตอบสนองได้ดี ในขณะที่คอมโพเนนต์กราฟที่ทำงานหนักจะ re-render อย่างนุ่มนวลในเบื้องหลัง

function ChartDashboard() { const [year, setYear] = useState(2023); const deferredYear = useDeferredValue(year); // HeavyChart เป็นคอมโพเนนต์ที่ทำ memoized และมีการคำนวณที่หนัก // มันจะ re-render ต่อเมื่อค่า deferredYear หยุดนิ่งแล้วเท่านั้น const chartData = useMemo(() => computeChartData(deferredYear), [deferredYear]); return (

setYear(parseInt(e.target.value, 10))} /> Selected Year: {year}
); }

แนวทางปฏิบัติที่ดีที่สุดและข้อผิดพลาดที่พบบ่อย

แม้ว่า useDeferredValue จะทรงพลัง แต่ก็ควรใช้อย่างรอบคอบ นี่คือแนวทางปฏิบัติที่ดีที่สุดที่ควรปฏิบัติตาม:

ผลกระทบต่อประสบการณ์ผู้ใช้ทั่วโลก (Global UX)

การนำเครื่องมืออย่าง useDeferredValue มาใช้ไม่ใช่แค่การปรับปรุงทางเทคนิคเท่านั้น แต่ยังเป็นความมุ่งมั่นที่จะสร้างประสบการณ์ผู้ใช้ที่ดีขึ้นและครอบคลุมสำหรับผู้ใช้ทั่วโลก

บทสรุป

hook useDeferredValue ของ React เป็นการเปลี่ยนแปลงกระบวนทัศน์ในการที่เราเข้าถึงการปรับปรุงประสิทธิภาพ แทนที่จะต้องพึ่งพาเทคนิคที่ต้องทำด้วยตนเองและมักจะซับซ้อน เช่น debouncing และ throttling ตอนนี้เราสามารถบอก React แบบ declaratively ได้ว่าส่วนไหนของ UI ของเรามีความสำคัญน้อยกว่า ทำให้มันสามารถจัดตารางการเรนเดอร์ได้อย่างชาญฉลาดและเป็นมิตรกับผู้ใช้มากขึ้น

ด้วยการทำความเข้าใจหลักการสำคัญของ concurrency, การรู้ว่าเมื่อใดควรใช้ useDeferredValue เทียบกับ useTransition, และการใช้แนวทางปฏิบัติที่ดีที่สุด เช่น memoization และการให้การตอบสนองแก่ผู้ใช้ คุณสามารถกำจัดอาการ UI กระตุกและสร้างแอปพลิเคชันที่ไม่เพียงแค่ใช้งานได้ แต่ยังน่าใช้ ในตลาดโลกที่มีการแข่งขันสูง การมอบประสบการณ์ผู้ใช้ที่รวดเร็ว, ตอบสนองได้ดี, และเข้าถึงได้ คือฟีเจอร์ที่ดีที่สุด และ useDeferredValue ก็เป็นหนึ่งในเครื่องมือที่ทรงพลังที่สุดในคลังอาวุธของคุณเพื่อให้บรรลุเป้าหมายนั้น