การใช้ useMemo ที่ถูกต้อง image

การใช้ useMemo ที่ถูกต้อง

เราเชื่อว่าเกินครึ่งหนึ่งของนักพัฒนาที่ใช้ React ยังคงใช้ useMemo hook แบบผิด ๆ เราจะอธิบายว่าการใช้แบบไหนที่ผิด และวิธีการใช้งานที่ถูกต้องจริง ๆ เป็นอย่างไร

Aug 18,2024
Supakorn Netsuwan🪶

Technical

React

Next.js

Thai

Table of contents

Prerequistion

ก่อนที่จะเข้าเรื่องเนื้อหา useMemo เราคิดว่าคุณจำเป็นต้องรู้จักพื้นฐานการ Re-render ก่อนสักนิด ถ้าหากคุณยังไม่รู้จักกระบวนการดังกล่าว เราแนะนำให้อ่านเพิ่มเติมจากสรุปดังต่อไปนี้

การ Re-render

สาเหตุที่เราจำเป็นต้อง Re-render component เกิดจาก

  1. State / Props ของ Component นั้นเองมีการเปลี่ยนแปลง
  2. Parent มีการ Re-render
  3. Hook มีการเปลี่ยนแปลง
  4. มีการเรียกใช้ Context แล้ว Context มีการเปลี่ยนแปลง

ซึ่งกระบวนการดังกล่าวก็จะเป็นไปตามหลักการ ของ React. จากการ Re-render นั้น จะทำให้เกิดการแสดงผล UI ที่เปลี่ยนแปลงบนหน้าจอของผู้ใช้งาน ถ้าหากเราฝืนการทำงานตามกฏของ​ React ผลลัพธ์ที่ได้ออกมาจะก่อให้เกิดการแสดงผลที่ไม่ถูกต้อง หรือ เกิดปัญหาในเรื่องของ Performance

ขอแนะนำ React lifecycle timeline by Jules blom สำหรับผู้ที่อยากศึกษา Lifecycle แบบเจาะลึก

การ Re-render เป็นอย่างไร?

สมมิตว่าเรามีโค้ด React ซึ่งเป็นโค้ดของโปรแกรมนับเลขดังต่อไปนี้

import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);

  const decrement = () => setCount(count - 1);

  return (
    <div>
      <h2>Counter: {count}</h2>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}

export default Counter;

จากโค้ดดังกล่าว ถ้าหากเรากดปุ่ม Increment หรือ Decrement จะทำให้เกิดการ Re-render เพื่อแสดงค่าตัวแปรที่เปลี่ยนแปลงไป.


ต่อมาหลังจากกดปุ่ม Increment หรือ Decrement แล้วจะทำให้เกิดการเรียกใช้ Handler function นั้น ๆ และไล่ทำงานจนจบ Scope ของมัน สุดท้ายก็จะทำให้เกิดการ Re-render ของ Component.


ในกระบวนการ Re-render นี้แหละที่จะมีการเปลี่ยนแปลงของ State จากการที่เราเคยเรียก setCount(count+1) เพื่อเปลี่ยนค่า State. React จะทำการเปรียบเทียบโดยใช้หลักการที่เรียกว่า ผ่านการเช็คจาก Object.is() ในการเปรียบเทียบ State ว่าควร Re-render หรือไม่ ดังนั้นภายหลังจากที่มันอัปเดต State แล้วมันคือการที่บอกให้ตัวแปรชี้ตำแหน่งของข้อมูลไปยังตำแหน่งใหม่ใน Memory นั่นแหละ เท่ากับว่าการ Re-assign ใหม่ก็ไม่แตกต่างจากการ Initialize ตัวแปรใหม่ในต่ละรอบทีเกิดการ Re-render

สำหรับ React life cycle นั้นจะมีลำดับการทำงานคร่าว ๆ ดังต่อไปนี้

  1. JavaScript Initialization
  2. State & Hooks initialization
  3. Return JSX
  4. Update DOM node
  5. Unset DOM ref
  6. useLayoutEffect cleanups
  7. Attach ref
  8. useLayoutEffect executes
  9. DOM paint
  10. useEffect cleanups
  11. useEffect executes

สรุปสั้น ๆ จะทำการ Re-calculate DOM ใหม่ทั้งหมดสำหรับ Component ที่ต้องถูก Re-render. โดยให้มองว่าในแต่ละ state จะ Re-assign ค่าที่มัน Reference ไปเป็นค่าที่อัปเดตใหม่ แล้วจึงแสดงผล (ในหัวข้อของ React Life cycle ยังมีรายละเอียดอื่น ๆ อีกมากมายที่เรายังไม่พูดถึง แต่เราขอละไว้ในที่นี้ก่อน)

ทำความรู้จักกับ useMemo

สาเหตุที่ React มี hook สำหรับจัดการประสิทธิภาพอย่าง useMemo, useCallback, useDeferredValue, useTransition เนื่องจาก React มีการทำงานแบบ ที่ทำให้ React ต้อง Re-render ทุกครั้งที่มีการเปลี่ยนแปลงของ State หรือ Props ทำให้เกิดปัญหาเรื่องประสิทธิภาพของการทำงาน และ เพื่อลดปัญหานี้ React จึงมี hook ที่ช่วยในการจัดการประสิทธิภาพของการทำงาน โดยในแต่ละ hook จะมีวิธีการใช้งานและเหตุผลที่ต้องใช้แตกต่างกัน

useMemo คืออะไร

Built-in hook ที่ช่วยให้ตัวแปรสามารถรักษาตำแหน่งอ้างอิงใน Memory ระหว่างการ Re-render แต่ละ Cycle ได้. จากนิยามดังกล่าวถ้าพูดให้ยาวมากขึ้นก็คือ useMemo ช่วย Cache ตำแหน่งของข้อมูลที่ตัวแปรถือไว้อยู่ได้ ทำให้ React สามารถข้ามขั้นตอนการ Re-initialize ตัวแปรใหม่ในแต่ละ Cycle ได้

useMemo กับการ Re-render

ตามที่เราได้เขียนไว้ในหัวข้อ ของการ Re-render ก่อนหน้านี้ ว่าการ Re-render ใหม่ในแต่ละรอบมันก็คือการที่ React ต้องสร้างและกำหนดตัวแปรใหม่ทุกครั้ง ดังนั้นเราจึงมักจะใช้ useMemo เพื่อป้องกันการ Re-initialize ตัวแปรใหม่ในแต่ละ Cycle โดยการ Cache ตำแหน่งของข้อมูลที่ตัวแปรถือไว้อยู่นั่นเอง

การที่เราสามารถป้องกันการ Re-calculate หรือ Re-initialize ตัวแปรใหม่ในแต่ละ Cycle นั้นจะมีประโยชน์ดังต่อไปนี้

  1. ช่วยให้เราใช้ทรัพยากรในการทำงานลง ลดการใช้งานของ Memory
  2. ป้องกันการเกิด Re-render ของ Child component ที่เรียกใช้ตัวแปรดังกล่าวได้ (เดี๋ยวจะมีการพูดถึงต่อไป)
  3. เพิ่มประสิทธิภาพของการทำงานของเว็บไซต์เราได้

useMemo ใช้งานอย่างไร

const cachedValue = useMemo(calculateValue, dependencies);
ชื่อประเภทคำอธิบาย
calculateValueanyค่าของตัวแปรนั้น ๆ ที่จะถูก Cache โดยที่เราควรจะกำหนดไว้เป็นจำพวก Non-primitive data types เช่น Object, Array และ Function ที่ return ค่าใด ๆ
dependenciesany[]Array ที่มีสมาชิกเป็นตัวแปรที่ต้องการดูว่ามีการเปลี่ยนแปลง ถ้าหากตัวแปรเหล่านี้มีการเปลี่ยนแปลง จะทำให้ตัวแปรที่เรียกใช้ useMemo ทำการ Re-calculate

วิธีการใช้งาน useMemo ที่ถูกต้อง

เราได้รู้จัก useMemo กันไปแล้วว่าควรใช้ตอนไหน และ ใช้อย่างไร ใช้เพื่ออะไร แต่จุดที่ยากที่สุดคือ Practical implementation เพราะเราเคยเห็นนักพัฒนาหลาย ๆ คนยังคงใช้งานแบบผิด ๆ ทำให้เกิดปัญหาในเรื่องของประสิทธิภาพของการทำงาน และ แทนที่จะช่วยให้ดียิ่งขึ้นมันกลับทำให้หลาย ๆ อย่างแย่ลงไปมากกว่าเดิม

สิ่งที่อาจเกิดขึ้นจากการใช้ useMemo แบบผิด ๆ

  1. ประสิทธิภาพแย่ลงกว่าเดิม
  2. Maintainability ลดลง
  3. เกิด Bug ในเว็บไซต์ของเรา

การใช้งานเพื่อ Cache expensive calculation

ในกรณีที่เรามีการเก็บข้อมูลที่เกิดจากการคำนวณทางคณิตศาสตร์ซับซ้อนจำพวกการ Loop, Aggregate หรือการจัดการข้อมูล Array ที่มีจำนวนมาก ๆ ในทุกครั้งที่มีการ Re-render ข้อมูลเหล่านี้ก็จะเกิดการ Re-calculate ใหม่ จึงนำมาใช้ useMemo เพื่อ Cache ข้อมูลเหล่านี้ไว้

const expensiveFunction = () => {
  let sum = 0;
  while (sum < 1000000000) sum += 1;
  return sum;
};

const App = () => {
  const [count, setCount] = useState(0);

  // useMemo จะไม่ถูก re-calculate
  const expensiveValue = useMemo(() => {
    return expensiveFunction();
  }, []);

  return (
    <div>
      {expensiveValue}
      <button onClick={() => setCount((prev) => prev + 1)}>
        Re-render
      </button>
      <span>{count}</span>
    </div>
  );
};

ต่อมาเราก็จะเกิดคำถามที่ว่า แล้วเราจะรู้ได้อย่างไรว่าข้อมูลของเรามันเป็น Expensive calculation หรือไม่ เราจะทราบได้อย่างไร?

สำหรับคำตอบนี้ ถ้าเราไม่ได้มีการ Loop หรือคำนวณ Array ที่มีขนาดสมาชิกใหญ่กว่าหลายพันตัวมันก็คงไม่ได้มีความจำเป็นต้องใช้ useMemo ส่วนถ้าถามว่าเราจะตรวจสอบเป็น Report ออกมาได้อย่างไร ก็สามารถทำได้โดยการใช้ตัวอย่างดังต่อไปนี้ได้

  // วิธีที่ 1
  console.time("Time spent on calculation");
  // คำนวณค่าค่า
  console.timeEnd("Time spent on calculation");
// วิธีที่ 2
  let time = performance.now()
  // คำนวณค่าค่า
  time = performance.now() - time;
  console.log("Time spent on calculation",time)

ดังนั้นในหลาย ๆ กรณีเราไม่จำเป็นต้องใช้ useMemo ก็ได้ถ้าข้อมูลไม่ได้ใหญ่มากจริง ๆ

การใช้งานเพื่อป้องกันการ Re-render

สมมติว่าเรามี Component อยู่ 3 ส่วนได้แก่

  1. FirstComponent
  2. SecondComponent
  3. App

และมี Code ตัวอย่างดังต่อไปนี้

import { useState } from "react";

const SecondComponent = () => {
  console.log("Second component has been re-rendered");
  return (
    <div>
      <h2>This is Second component</h2>
    </div>
  );
};

const FirstComponent: React.FC<React.PropsWithChildren> = ({
  children,
}) => {
  console.log("First component has been re-rendered");
  return (
    <div>
      <h2>This is First component</h2>
      <div>{children}</div>
    </div>
  );
};

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <button
          onClick={() => setCount((prev) => prev + 1)}
        >
          Increase
        </button>
        <p>{count}</p>
        <FirstComponent>
          <SecondComponent />
        </FirstComponent>
      </div>
    </>
  );
};

เมื่อใดก็ตามที่เรากดปุ่ม Increase จะทำให้เกิดการเพิ่มขึ้นของจำนวน count ผ่านการเปลี่ยนแปลงของ State -> ก่อให้เกิดการ Re-render ของ Component -> ส่งผลให้ Object ที่อยู่ภายใต้ App component จะถูก Re-create ทั้งหมด ดังนั้น Child components ที่อยู่ภายใต้ App นี่จึงเป็นเหตุผลว่าถ้าหากเราสังเกตที่ Console ตามตัวอย่างด้านล่างถัดไป เราก็จะพบว่ามันมีการ Execute code ที่อยู่ภายใต้ของแต่ละ Component ทุกครั้งที่เรากดปุ่ม!

CONSOLE

: First component has been re-rendered: Second component has been re-rendered

ซึ่งคุณเองก็คงจะคิดแบบเดียวกันว่า ในเมื่อมันไม่ใช่ส่วนที่มีการเปลี่ยนแปลง มันไม่ควรถูก Re-render แล้วเราจะป้องกันได้อย่างไร? สำหรับในส่วนนี้เราต้องเข้าใจก่อนว่าเงื่อนไขที่มันจะป้องกันการ Re-render ได้นั้นจะแบ่งออกเป็นกรณีดังต่อไปนี้

  1. Component ที่ไม่มีการส่ง Props ผ่านไป
  2. Component ที่มีการส่ง Props ผ่านไป (Code ตัวอย่างเป็นกรณีนี้)

เพราะกรณีทั้งสองนั้นจะมีการป้องกันการ Re-render ที่แตกต่างกันไปครับ

1. Component ที่ไม่มีการส่ง Props ผ่านไป

สำหรับในกรณีนี้ เราจะขอไม่ลงลึกมากแต่จะยกตัวอย่างผ่าน ๆ เนื่องจากมันไม่ได้เกี่ยวข้องโดยตรง โดยเราป้องกันการ Re-render ได้ด้วยการเพิ่ม อย่าง React.memo() ตามตัวอย่างดังต่อไปนี้

const FirstComponent: React.FC<React.PropsWithChildren> = ({
  children,
}) => {
  console.log("First component has been re-rendered");
  return (
    <div>
      <h2>This is First component</h2>
      <div>{children}</div>
    </div>
  );
};

const MemoFirstComponent = React.memo(FirstComponent);

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <button
          onClick={() => setCount((prev) => prev + 1)}
        >
          Increase
        </button>
        <p>{count}</p>
        <MemoFirstComponent />
      </div>
    </>
  );
};

2. Component ที่มีการส่ง Props ผ่านไป

และนี่คือกรณีที่ useMemo จะเข้ามามีบทบาทเนื่องจากว่าการใช้ React.memo เพียงอย่างเดียวกับ Child component จะไม่สามารถป้องกันการ Re-render เนื่องจากว่าในการ Re-render นั้น React จะทำการเปรียบเทียบ Props ที่ส่งผ่านมาด้วย Object.is() และ ในทุก ๆ ครั้งที่ Component ถูก Re-render จะทำให้ Reference ที่ชี้ไปยัง Object เปลี่ยนเป็นตำแหน่งใหม่ที่แตกต่างจากเดิม ส่งผลให้การส่งผ่าน props ที่เป็นพวก Non-primitive (ซึ่ง Function component เองก็เป็น) ถูกเข้าใจผิดว่ามันเปลี่ยนไปจากเดิมทำให้ Child component ปลายทางทำการ Re-render ตัวเองตามกันไป


คุณคงจะคิดว่าจาก Code ที่แสดงนั้นมีการส่ง props ไปอย่างไร เราไม่ได้ส่งอะไรไปเลยนะ... จริง ๆ แล้วเรากำลังถูก เนื่องจากการเขียน JSX ในรูปดังต่อไปนี้

<FirstComponent>
  <SecondComponent />
</FirstComponent>

นั้นไม่ได้แตกต่างจากการเขียนแบบนี้

<FirstComponent children={<SecondComponent />} />

ทำให้จริง ๆ แล้วการที่เราแนบ Children ไปด้วยภายในก็ไม่แตกต่างจากการส่ง Prop children ที่เป็น Object ไปอยู่ดีนั่นเอง เราจึงใช้ useMemo ในการ Cache component ที่ถูกส่งไปเป็น Prop children เพื่อไม่ให้ React เข้าใจผิดในส่วนนี้

เราสามารถปรับ Code เป็นดังต่อไปนี้เพื่อป้องกันการ Re-render ที่ไม่จำเป็น

const SecondComponent = () => {
  console.log("Second component has been re-rendered");
  return (
    <div>
      <h2>This is Second component</h2>
    </div>
  );
};

const FirstComponent: React.FC<React.PropsWithChildren> = ({
  children,
}) => {
  console.log("First component has been re-rendered");
  return (
    <div>
      <h2>This is First component</h2>
      <div>{children}</div>
    </div>
  );
};

const MemoFirstComponent = React.memo(FirstComponent);

const App = () => {
  const [count, setCount] = useState(0);

  const secondComponent = useMemo( 
    () => <SecondComponent />,
    [] 
  );

  return (
    <>
      <div>
        <button
          onClick={() => setCount((prev) => prev + 1)}
        >
          Increase
        </button>
        <p>{count}</p>
        <FirstComponent>
          <SecondComponent />
        </FirstComponent>
        <MemoFirstComponent>
          {secondComponent}
        </MemoFirstComponent>
      </div>
    </>
  );
};

ซึ่งเมื่อคุณดูจากตัวอย่างข้างต้นแล้วก็อาจจะดูขัด ๆ กับ Mental model ที่เรามีต่อ React ไปสักหน่อย

ข้อผิดพลาดที่คนส่วนใหญ่ไม่ทราบ

การใช้ Non primitive data types ที่ dependencies

การใช้ Object หรือ Array ใน Dependency arrays นั้นจะไม่ได้ช่วยการ Cache ข้อมูลเนื่องจากเมื่อมีการ Re-render แล้ว React จะทำการเทียบว่าข้อมูลก่อน-หลัง มีการแปลี่ยนแปลงไปหรือไม่ แต่เนื่องจากว่า Object จะมี Reference ที่เปลี่ยนแปลงใหม่ตลอดเวลาทำให้การใช้ useMemo ในกรณีนี้จึงไม่ส่งผลช่วยอะไรทั้งสิ้น

const profile = {
  fname: "Supakorn",
  lname: "Netsuwan",
  age: 20,
};

const data = useMemo(
  () => ({
    address: {
      road: "xxxx",
      district: "xxxx",
    },
    profile,
  }),
  [profile] // // ควรเปลี่ยนไปเป็น Primitive data type
);

Component ที่มีการส่ง Props จำพวก Primitive data type

ถ้าหากมีการส่ง Props ผ่านไปที่ Child component ที่คุณต้องการแต่ว่าเป็น Primitive data types คุณไม่จำเป็นต้องใช้ useMemo กับข้อมูลเหล่านั้นที่ถูกส่งผ่านไปก็ได้ เช่น

type ProfileType = {
  fname: string;
  lname: string;
  age: number;
};

const Profile: React.FC<ProfileType> = ({
  fname,
  lname,
  age,
}) => {
  console.log("Profile component has been re-rendered");
  return (
    <div>
      <h2>User Profile</h2>
      <div>
        <p>
          Name: {fname} {lname}
        </p>
        <p>Age: {age}</p>
      </div>
    </div>
  );
};

const MemoProfile = React.memo(Profile);

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount((prev) => prev + 1)}>
        Increase
      </button>
      <p>{count}</p>
      <MemoProfile fname="Supakorn" lname="Netsuwan" age={20} />
    </div>
  );
};

จากตัวอย่างดังกล่าวคุณสามารใช้เพียง React.memo() กับ Child component ที่ถูกเรียกก็เพียงพอแล้ว เพราะทุกครั้งที่มีการ Re-render เราส่ง Primitive data type ไปซึ่งผลลัพธ์ของการเช็คจากตัว React เองมันสามารถรู้ได้ว่าข้อมูลที่ส่งผ่านมาไม่ได้มีการเปลี่ยนแปลงจากการใช้ Object.is()