React 공식 문서 읽어보기 (6)

Snoop So·2023년 8월 21일
0

useLayout

얘도 마찬가지로 dependency를 가질 수 있음.
아래와 같이 작성해봤을 경우 클릭 이벤트가 일어날 때마다 'a'가 콘솔에 찍히는 것을 확인할 수 있었다.

import React, {
  useInsertionEffect,
  useRef,
  useLayoutEffect,
  useState,
  useEffect
} from "react";

export default function App() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
    console.log("a");
  }, [tooltipHeight]);

  useEffect(() => {
    console.log("b");
  }, []);

  return (
    <div
      ref={ref}
      className={"test"}
      onClick={() => {
        console.log("c");
        setTooltipHeight((tooltipHeight) => tooltipHeight + 1);
      }}
    >
      {tooltipHeight}
    </div>
  );
}

클라이언트에서만 실행되고 서버 렌더링 중에는 실행되지 않는다. 진짜 돔 생성 직전 이벤트라고 생각하면 됨. 당연하지만 렌더링될 때마다 실행된다.

과도하게 사용하면 안된다. 가급적 useEffect를 사용할 것. useLayoutEffect 내부의 코드와 예약된 모든 state 업데이트는 브라우저가 화면을 다시 그리는 것을 차단함.

툴팁의 위치는 브라우저가 화면을 다시 그리기 전에 이루어져야 함. 브라우저가 화면을 다시 그리기 전에 useLayoutEffect를 호출하여 레이아웃 측정을 수행함.

function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet
                                                         // 아직 실제 height 값을 모릅니다.

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // Re-render now that you know the real height
                              // 실제 높이를 알았으니 이제 리렌더링 합니다.
  }, []);

  // ...use tooltipHeight in the rendering logic below...
  // ...아래에 작성될 렌더링 로직에 tooltipHeight를 사용합니다...
}

순서는 다음과 같음

  1. Tooltip은 초기 tooltipHeight = 0 으로 렌더링됨
  2. React는 이를 Dom에 배치, useLayoutEffect에서 코드를 실행
  3. useLayoutEffect는 툴팁 콘텐츠의 높이를 측정하고 다시 렌더링을 촉발
  4. Tooltip이 실제 tooltipHeight로 다시 렌더링됨
  5. React가 DOM에서 이를 업데이트하면 브라우저에 툴팁이 최종적으로 표시됨.

그러니까 쉽게 말하면 저 훅을 사용하지 않으면 툴팁이 잠깐 0 포지션으로 보였다가 계산 후에 해당 위치로 렌더링 되어 보이니까 이 훅을 쓰라, 이 말임.

하지만 단점에 유의. useLayoutEffect는 브라우저가 다시 그리는 것을 차단한다. 무슨 말인지 좀 더 살펴보자.

React는 브라우저가 화면을 다시 그리기 전에 useLayoutEffect 내부의 코드와 그 안에서 예약된 모든 state 업데이트가 처리되도록 보장함. 그렇기 때문에 결국 렌더링이 지연된다는 것.

useEffect 다시 공부하기

Effect와 동기화하기

렌더링 코드는 순수해야 한다. 이벤트 핸들러에서는 사이드 이펙트가 포하모디어 있다 (프로그램의 state를 변경함) 하지만 이 둘만으로는 충분하지 않음. Chatroom과 같은 컴포넌트는 촉발되는 이벤트가 없음.

Effect를 사용하면 특정 이벤트가 아닌 렌더링 자체로 인해 발생하는 사이드 이펙트를 명시할 수 있음. Effect는 화면 업데이트 후 커밋이 끝날 때 실행된다.

Effect 작성 방법
1. Effect 선언
2. 의존성 명시
3. 필요한 경우 클린업 추가

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

위와 같은 경우는 무한 실행됨.
Effect는 렌더링의 결과로 실행됨. state를 설정하면 렌더링을 촉발함. 그래서 계속 빙글빙글 돈다.
대부분의 경우 Effect는 필요없을 수도 있음.

배열을 넣어주지 않으면 렌더링 할 때마다 매번 실행됨

의존성에 넣어주지 않아도 되는 것 : ref, 설정자 함수. (요것은 몰랐다.)

import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

export function createConnection() {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting...');
    },
    disconnect() {
      console.log('❌ Disconnected.');
    }
  };
}

stict 모드 때문에 두번 실행됨. 이런 현상이 보일 경우 반드시 해제 해주자.

두번 호출하면 안되는 API의 경우에는 해제를 꼭 해주도록 하자.

구독하는 경우도 마찬가지로 취소를 꼭 해주자.

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

Effect가 무엇인가를 애니메이션하는 경우 클린업 함수는 애니메이션을 초기값으로 재설정해주자.

Effect가 무언가를 패치하면 클린업 함수는 패치를 중단하거나 그 결과를 무시해야 함.

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

분석을 보낼때는 두번 보내도록 냅두자. 상용 환경에서는 문제가 없으니 상관 없음.

useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

일부 로직은 애플리케이션이 시작될 때 한 번만 실행되어야 하는데, 이 경우 컴포넌트 외부에 넣으면 된다.

if (typeof window !== 'undefined') { // Check if we're running in the browser.
                                     // 실행환경이 브라우저인지 여부 확인
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

클린업을 해도 Effect를 두 번 실행함으로써 체감상 결과가 달라지는 방법이 없는 경우도 있음

useEffect(() => {
  // 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
  // 🔴 틀렸습니다: 이 Effect는 개발모드에서 두 번 실행되며, 문제를 일으킵니다.
  fetch('/api/buy', { method: 'POST' });
}, []);

이렇게 하지말고 그냥 클릭 이벤트에 넣도록 하자.

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

불필요한 Effect 제거 방법

  • 렌더링을 위해 데이터를 변환하는 경우
  • 사용자 이벤트 처리
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 🔴 Avoid: redundant state and unnecessary Effect
  // 🔴 이러지 마세요: 중복 state 및 불필요한 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}
  • 비효율적이므로 삭제해야 한다.
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  // ✅ 좋습니다: 렌더링 과정 중에 계산
  const fullName = firstName + ' ' + lastName;
  // ...
}

위가 더 좋은 코드

필요한 계산이 있을 경우 useMemo를 사용하는게 효과적이다.

계산이 비싼지 측정하는 방법

아래와 같이 그냥 직접 찍어봐라.
1ms 이상이 된다면 해당 계산은 메모해두는 것이 좋을 수 있음.
단 첫번째 렌더링을 더 빠르게 만들지는 않음. 업데이트 시 불필요한 작업을 건너뛰는 데에만 도움을 줌

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  // ✅ 가장 좋음: 렌더링 중에 모든 값을 계산
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

특정 이벤트에 대한 로직을 절대 effect 안에 넣지 말자!

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  // 🔴 이러지 마세요: Effect 내부에 특정 이벤트에 대한 로직 존재
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

state를 연쇄적으로 쓰는 안좋은 패턴은 절대 쓰지 말자.

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
  // 🔴 이러지 마세요: 오직 서로를 촉발하기 위해서만 state를 조정하는 Effect 체인
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

최악의 경우 위 예시에서 불필요한 렌더링이 세번이나 발생할 수 있음
(setCard -> 렌더링 -> setGoldCount -> 렌더링 -> setRound -> 렌더링 -> setIsGameOver -> 렌더링)

한번만 실행되어야 하는 경우 이렇게 안전하게 작업해두자

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      // ✅ 앱 로드당 한 번만 실행됨
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

혹은 아예 렌더링 전에 실행할 수도 있음

if (typeof window !== 'undefined') { // Check if we're running in the browser.
                                     // 브라우저에서 실행중인지 확인
  // ✅ Only runs once per app load
  // ✅ 앱 로드당 한 번만 실행됨
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

이왕이면 부모가 state와 function을 다 갖고 있는게 좋음

때로는 외부의 일부 데이터를 구독해야 할 수도 있음
이는 리액트가 모르는 사이에 변경될 수 있어 좋지 않음

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  // 이상적이지 않음: Effect에서 수동으로 store 구독
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  // ✅ 좋습니다: 빌트인 훅에서 외부 store 구독
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
               // React는 동일한 함수를 전달하는 한 다시 구독하지 않음
    () => navigator.onLine, // How to get the value on the client
                            // 클라이언트에서 값을 가져오는 방법
    () => true // How to get the value on the server
               // 서버에서 값을 가져오는 방법
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    // 🔴 이러지 마세요: 클린업 없이 fetch 수행
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

클린업을 지정해줘야, 컴포넌트가 언마운트되고 마운트 되는 그 시차가 나는 중간에 상태가 안맞는 일이 발생하지 않으니 주의!

function ChatRoom({ roomId, selectedServerUrl }) { // roomId is reactive
  const settings = useContext(SettingsContext); // settings is reactive
  const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl is reactive
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads roomId and serverUrl
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So it needs to re-synchronize when either of them changes!
  // ...
}

모든 반응형 값은 다시 렌더링할 때 변경할 수 있으므로 반응형 값도 effect에 의존성으로 포함시켜야 함.

즉, 상태 뿐 아니라 변수를 포함해 Effect는 본문의 모든 값에 반응함.

하지만 location.pathname과 같이 변이 가능한 값은 의존성이 될 수 없음. 대신 useSyncExternalStore을 사용하여 외부 변경 가능한 값을 읽고 구독해야 함

ref.current와 같이 변이 가능한 값 또는 이 값으로부터 읽은 것 역시 의존성이 도리 수 없음

0개의 댓글

관련 채용 정보