본문 바로가기

이것저것 스터디📚/React - 공식 문서

React 공식문서 -Synchronizing with Effects(Effect와 동기화 하기)

Effect를 사용하면 렌더링 이후 일부 코드를 실행할 수 있고, 컴포넌트를 React 외부의 시스템과 동기화 할 수 있다.

What are Effects and how are they different from events?(Effect란 무엇이며 이벤트와는 어떤게 다른가요?)

- 이벤트 핸들로는 컴포넌트 내부에 있는 중첩된 함수로, 렌더링 코드와 달리 계산만 하는 것이 아니라 별도의 작업(예를들어 입력 필드 업데이트, HTTP POST 요청, 사용자를 다른 화면으로 이동 등)

- 이벤트 핸들러에는 특정 사용자 작업으로 인해 발생하는 "사이드 이펙트(프로그램의 state를 변경함)"가 포함되어 있다.

 

- Effect를 사용하면 특정 이벤트가 아닌 렌더링 자체로 인해 발생하는 사이드 이펙트를 명시할 수 있다.

(예를들어 화면에 표시될 때마다 채팅 서버에 연결해야하는 경우, 특정 이벤트 발생이 없기 때문에, Effect를 사용하면 화면 업데이트 후 커밋이 끝날때 실행된다)


You might not need an Effect(Effect가 필요하지 않을 수도 있습니다)

- Effect는 일반적으로 React 코드에서 벗어나 일부 외부 시스템과 동기화하는 데에 사용된다.(브라우저 API, 서드파티 위젯, 네트워크 등)

- Effect가 다른 state를 기반으로 일부 state만을 조정하는 경우, Effect가 필요하지 않을 수도 있다.


How to write an Effect(Effect 작성 방법)

Step 1: Declare an Effect(Effect를 선언하세요)

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

- 위 예제에서는, 렌더링 중에 DOM 노드를 조작하고 있다.(play or pause)

- React에서 렌더링은 JSX의 순수한 계산이어야 하는데, 위 코드는 DOM 수정과 같은 사이드 이펙트를 포함하고 있다.

- VideoPlayer가 처음 호출될 때 React는 아직 JSX를 반환하지 않았기 때문에, play() 나 pause()를 호출할 DOM 노드가 아직 없는 상태다.

- 이런 경우, 사이드 이펙트를 useEffect로 감싸 렌더링 계산 밖으로 옮길 수 있다.

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

- 위와 같이 useEffect를 사용하면, React가 먼저 화면을 업데이트하고 Effect가 실행되게 할 수 있다.


Step 2: Specify the Effect dependencies(Effect 의존성을 지정하세요)

 

- 기본적으로 Effect는 매번 렌더링 후에 실행된다.

- 하지만 이를 원하지 않는 경우가 있다.

1. 외부 시스템과 동기화가 항상 즉각적인 것은 아니므로 꼭 필요한 경우가 아니라면 동기화를 건너뛰는 것이 좋다.(예. 키 입력 시마다 채팅 서버에 다시 연결하고 싶지 않을 수 있다.)

2. 키 입력 시마다 컴포넌트 페이드인 애니메이션을 발동시키고 싶지 않을 수 있다.(컴포넌트가 처음 나타날 때 한 번만 재생되기를 원하는 경우)

 

- useEffect 호출의 두 번째 인자로 의존성 배열을 지정하여 React가 불필요하게 Effect를 다시 실행하지 않도록 지시할 수 있다.

- 의존성 배열은 여러 개의 의존성을 포함할 수 있고, 모든 의존성 배열의 값들이 이전 렌더링 때와 같으면 Effect를 다시 실행하지 않아도 된다고 React에게 알려줄 수 있다.

- 의존성을 "선택"할 수는 없다. 지정한 의존성들이 Effect 내부의 코드를 기반으로 React가 예상하는 것과 일치하지 않으면 lint 오류가 발생한다.

 

* Why was the ref omitted from the dependency array?(의존성 배열에서 ref가 생략된 이유는 무엇인가요?)

- ref 객체는 안정적인 정체성을 가지고 있다 : React는 렌더링할 때마다 동일한 useRef 호출에서 항상 동일한 객체를 얻을 수 있도록 보장하기 때문에, 즉 절대 변하지 않으므로 그 자체로 Effec가 다시 실행되지 않는다.

- 만약 부모 컴포넌트에서 ref가 전달된 경우 의존성 배열에 이를 지정해야한다. 부모 컴포넌트가 항상 동일한 ref를 전달하는지, 아니면 여러 ref 중 하나를 조건부로 전달하는지 알수 없기 때문이다.

- 즉, 언제나 안정적인 의존성을 배제하는 것은 linter가 객체가 안정적인라는 것을 확인할 수 있을 때만 잘 동작한다.


Step 3: Add cleanup if needed(필요한 경우 클린업을 추가하세요)

 

- 채팅 서버가 나타날 때 채팅 서버에 연결해야하는 ChatRoom 컴포넌트를 만들고 있다고 가정했을 때, cleanup의 필요성을 알 수 있다.

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>;
}

// conneting (1)
// conneting (2)

- 위 예제의 결과는 예상과 달리 콘솔에 conneting이 두 번 출력된다.

- 이는 개발 모드에서 React는 모든 컴포넌트를 최초 마운트 직후에 한 번씩 다시 마운트를 하는데, 첫 번째 연결이 종료되지 않고, 두 번째 연결이 발생했다는 것을 알 수 있다.

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

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

// connecting (1)
// disconneted (1)
// connecting (2)

- cleanup 함수를 사용하면, 컴포넌트가 언마운트될 때, 첫 번째 연결을 해제하고 두 번째 연결을 하는 것을 확인할 수 있다.


How to handle the Effect firing twice in development?(개발 환경에서 두 번씩 실행되는 Effect를 처리하는 방법은 무엇인가요?)

- React는 개발 모드에서 버그를 찾기 위해 컴포넌트를 의도적으로 다시 마운트 한다.

- 즉, 개발자가 신경써야할 내용은 "어떻게 다시 마운트한 후에도 Effect가 잘 작동하도록 수정하는가"이다.

- 일반적인 정답은 Effect가 수행 중이던 작업을 중지하거나 취소할 수 있는 cleanup 함수를 구현하는 것이다.


 

Controlling non-React widgets(React가 아닌 위젯 제어하기)

 

- 동일한 값으로 두 번 호출해도 아무 작업도 수행하지 않는 경우에는, 약간 느릴 수는 있지만, 상용 환경에서는 불필요하게 다시 마운트되지 않기 때문에, cleanup 함수를 사용하지 않아도 된다.

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

// zoomLevel은 동일한 값이기 때문에 두 번 호출해도 아무 작업도 수행하지 않는다.
// 그럼 개발자 모드에서도 의존성 배열의 값이 같으면 useEffect를 실행하지 않는다는 뜻?

- 하지만, 일부 API는 연속으로 두 번 호출하는 것을 허용하지 않는 경우가 있고, 그런 경우에는 cleanup 함수를 사용해야 한다.

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

// 개발 중에는 showModal() 호출 -> close() 호출 -> showModal() 호출
// 상용 환경에서 showModal()을 한 번 호출하는 것과 체감상 동일

Subscribing to events(이벤트 구독하기)

- Effect가 무언가를 구독하는 경우, 클린업 함수는 구독을 취소해야 한다.

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

 

Triggering animations(애니메이션 촉발하기)

 

Effect가 무언가를 애니메이션하는 경우 클린업 함수는 애니메이션을 초기값으로 재설정해야 한다.

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
                          // 애니메이션 촉발
  return () => {
    node.style.opacity = 0; // Reset to the initial value
                            // 초기값으로 재설정
  };
}, []);

Fetching data(데이터 페칭하기)

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

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

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

  startFetching();

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

- 이미 발생한 네트워크 요청을 실행 취소할 수는 없으므로, 클린업 함수에서 더 이상 관련이 없는 패치가 애플리케이션에 계속 영향을 미치지 않도록 해야한다. 

- 위 예제에서는 만약 useId가 "Alice"에서 "Bob"으로 변경되면 클린업은 "Alice" 응답이 "Bob" 이후에 도착하더라도 이를 무시한다.

- 개발 환경에서는 네트워크 탭에 두 개의 페치가 표시되지만 cleanup 함수를 이용해서 state에 영향을 미치지 않고, 상용 환경에서는 요청이 하나만 있다.

- 개발 중인 두 번째 요청이 귀찮은 경우 가장 좋은 방법은 요청을 중복 제거하고 컴포넌트 간에 응답을 캐시하는 솔루션을 사용하는 것이다.

function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

- 위 코드를 사용하면, Back 버튼을 누르는 사용자는 일부 데이터가 캐시되기 때문에 다시 로드될 때까지 기다릴 필요가 없다.

 

* What are good alternatives to data fetching in Effects?(Effect에서 데이터를 페칭하는 것의 대안은 무엇입니까?)

- Effect 내에서 fetch 호출을 작성하는 것은 클라이언트 측에서만 작성된 앱에서 데이터를 패치하는 인기 있는 방법이다.

 

- 단점

- 하지만, Effects는 서버에서 실행되지 않기 때문에, 초기 서버에서 렌더링되는 HTML에는 데이터가 없는 로딩 state만 포함되고, 클라이언트 컴퓨터는 모든 JavaScript를 다운로드하고 앱을 렌더링하고 나서야 비소로 데이터를 로드한다.(이는 효율적이지 못하다.)

- Effect에서 직접 페치하면 "네트워크 워터폴"이 만들어지기 쉽다. 상위 컴포넌트가 렌더링되면 상위 컴포넌트에서 일부 데이터를 페치하고, 하위 컴포넌트를 렌더링한 다음 다시 하위 컴포넌트의 데이터를 페치하기 시작하기 때문에, 모든 데이터를 병렬로 페치하는 것보다 느리다.

- Effect에서 직접 페치하는 것은 일반적으로 데이터를 미리 로드하거나 캐시하지 않음을 의미한다. 컴포넌트가 마운트 해제되었다가 다시 마운트되면 데이터를 다시 페치할 것이다.

 

- 권장되는 방식

- 프레임워크를 사용하는 경우, 빌트인 데이터 페칭 매커니즘을 사용해라

- 클라이언트측 캐시를 사용하거나 직접 구축하는 것을 고려하자.(React Query, useSWR, React Router 6.4+ 등을 사용하거나, 내부적으로는 useEffect를 사용하되, 요청 중복 제거, 응답 캐싱, 네트워크 워터폴 방지(데이터를 미리 로드하거나 라우터에 데이터 요구 사항을 호이스팅 해서) 논리를 추가해서 자체 솔루션을 구축할 수도 있다.)


Not an Effect: Initializing the application(Effect가 아님: 애플리케이션 초기화 하기)

- 애플리케이션이 시작될 때 한 번만 실행되는 로직은 컴포넌트 외부에 넣을 수도 있다.


Not an Effect: Buying a product(Effect가 아님: 제품 구매하기)

 

- 제품의 구매는 사용자가 페이지를 방문할 때 제품을 구매하는 것이 아니기 때문에, Effect가 아닌, 구매 버튼 이벤트 핸들러로 이동시켜야한다.


Putting it all together(한데 모으기)

 

- Effect는 해당 렌더링에서 값을 캡쳐하기 때문에, 아래 예제에서는 cleanup 함수의 유무에 따라, 입력되는 text 값을 표시하는 방법이 달라진다.

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 />}
    </>
  );
}

* Each render has its own Effects(각 렌더링에는 고유한 Effect가 있습니다.)

- 만약 React가 첫 번째 렌더링의 Effect와 두 번째 렌더링을 Effect를 비교해서 동일한 값인 경우, 두 번째 Effect를 건너뛴다.

- 이후 세 번째 렌더링의 Effect가 발생하고 의존성 배열의 값이 달라지는 경우, React의 cleanup 함수는 건너 뛴 두 번째가 아닌 첫 번째의 Effect를 정리한다.


* 참고 : React 공식문서(https://react-ko.dev/learn)