Published on

리액트 공식문서 스터디 7-2 주차

Authors
  • avatar
    Name
    junyeol kim

You Might Not Need an Effect

  • Effect는 React 패러다임(선언적 렌더링의 기본 규칙)에서 잠깐 벗어나, non-React 위젯, 네트워크, 브라우저 DOM 같은 외부 시스템컴포넌트를 동기화하기 위한 탈출구이다.

  • 만약 이런 외부 시스템이 전혀 관여하지 않고, 단지 propsstate가 바뀔 때 그에 맞춰 컴포넌트의 state를 업데이트하고 싶은 것뿐이라면, 그런 경우에는 Effect를 쓸 필요가 없다.

  • 이렇게 불필요한 Effect를 제거하면, 코드 흐름더 쉽게 따라갈 수 있고, 렌더 사이클이 줄어들어 더 빠르게 실행되며, 동기화 문제·의존성 배열 실수 같은 에러 발생 가능성도 줄어든다.

How to remove unnecessary Effects

  • Effects필요하지 않는 대표적인 두 가지 Case를 알아보자

    • Rendering을 위해 transform data 할 때 굳이 Effects를 쓸 필요가 없다.

      • 불필요한 리렌더링을 유발할 수 있는 패턴이기 때문이다.
    • User events를 처리하는 데에는 Effect가 필요없다.

      • Effect의 실행시점에 사용자의 행동을 정확하게 파악할 수 없기 때문이다.
  • 그럼 언제 Effeects를 사용해야할까?

    • jQuery 위젯, 브라우저 API, 서버 데이터 같은 외부 시스템과 React state를 동기화할 때 필요하고, 단순 렌더링용 데이터 변환이나 사용자 이벤트 처리에는 웬만하면 쓰지 말라는 것이다 🤔

Updating state based on props or state

  • 어떤 state를 기반으로 다른 state를 업데이트하는 관점에서 Effect와 state를 불필요하게 사용하지 말고, 기존 props·state에서 계산 가능한 값렌더 단계에서 바로 계산하는 것이 더 효율적이다.

  • 이렇게 하면 연쇄적인 업데이트로 인한 불필요한 리렌더링을 피할 수 있고, 코드가 더 단순해지며, 여러 state 값이 서로 안 맞아서 생기는 동기화 버그도 줄어든다.

Caching expensive calculations

  • 기존 props 또는 state로 항상 계산 가능한 값은, 새 state + Effect로 동기화하지 말고 렌더 단계에서 바로 계산하는 것이 좋다.

  • 그 계산이 expensive하다면, Effect가 아니라 useMemo 훅을 사용해 결과를 캐싱하는 것이 좋다.

  • useMemo 안에 넘기는 callback 함수는 렌더링 과정에서 실행되기 때문에, side effect 없는 pure calculation 이어야 한다.

  • React 19에서는 React Compiler가 이런 메모이제이션을 자동으로 해 주기 때문에, 원칙만 잘 지키면 수동 useMemo 사용이 훨씬 줄어들어 더 편리해졌다.

How to tell if a calculation is expensive?

  • console.time / console.timeEnd로 계산 시간을 재서 여러 번 합산했을 때 1ms 이상이면 메모이제이션을 고려해볼 만한, 즉 expensive calculation일 가능성이 높다.​

  • useMemo첫 렌더빠르게 만드는 게 아니라, 이후 렌더에서 의존성이 안 바뀐 경우 불필요한 재계산을 건너뛰게 해 주는 것이다.

  • 개발 환경Strict Mode 등으로 렌더가 두 번 일어나서 수치가 부정확하니, 프로덕션 빌드CPU Throttling을 사용해 실제 유저 환경과 비슷한 조건에서 측정하는 게 좋다.

Resetting all state when a prop changes

  • 아래 예시 코드는 ProfilePage 컴포넌트에서 userId를 props로 받고, 댓글 입력값을 comment state로 관리하는 상황이다. 예시로 프로필 A에서 프로필 B로 이동 시 comment가 그대로 남아, 잘못된 사용자 프로필에 댓글을 달 위험이 있다.
export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('')

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('')
  }, [userId])
  // ...
}
  • 위 코드는 userId가 바뀔 때마다 Effect로 comment를 비우지만,

    • 먼저 이전 comment 값으로 한 번 렌더되고, Effect 실행 후 다시 렌더되어 비효율적이며,
    • ProfilePage 안에 state가 여러 개 있으면 모든 하위 컴포넌트마다 이런 Effect를 반복해서 작성해야 해 구조가 복잡해진다.​
  • 그래서 아래 코드처럼 Profile에 key={userId}를 주어, React가 각 userId를 서로 다른 컴포넌트 인스턴스로 인식하게 만들어야 한다. 이렇게 하면 userId가 바뀔 때마다 Profile과 그 아래 모든 state가 자동으로 리셋되고, 댓글 입력값도 자연스럽게 초기화된다. ​

export default function ProfilePage({ userId }) {
  return <Profile userId={userId} key={userId} />
}

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('')
  // ...
}

​ - 그렇기에 결론적으로, props 변화에 따라 컴포넌트 안의 여러 state를 한 번에 초기화하고 싶을 때는, Effect로 각각의 state를 비우기보다 key를 사용해서 컴포넌트 인스턴스를 갈아끼우는 방식으로 전체 state를 리셋하는 것이 더 단순하고 효율적이다. 이렇게 하면 불필요한 리렌더와 중복 초기화 코드를 줄이면서도, userId마다 완전히 독립적인 상태를 보장할 수 있다. ​

Adjusting some state when a prop changes

  • 이번에는 전체 state 초기화가 아닌 일부 state를 재설정하거나 초기화하는 경우를 알아보자

  • 아래 코드처럼 Effect에서 prop 변경시 state를 조정하는 방식은 비효율적이라고 볼 수 있다.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false)
  const [selection, setSelection] = useState(null)

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null)
  }, [items])
  // ...
}
  • 위 코드 대신, 아래 코드처럼 “선택된 아이템 전체”를 state로 들고 있지 말고, ID만 state로 저장해 두고 렌더 단계에서 selection을 계산하는 방식이 가장 단순하고 안전하다. ​
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
  // ...
}
  • 이렇게 하면 items가 바뀌어도 Effect로 selection을 리셋하거나 조정할 필요 없이, 항상 현재 items와 selectedId를 기준으로 selection이 자동으로 일관되게 계산된다.

Sharing logic between event handlers

예시 코드

  • 이벤트 핸들러 간 로직을 공유할 때는, 그 로직이 “컴포넌트가 화면에 표시됐기 때문에” 실행되어야 하는지, 아니면 “사용자 이벤트(클릭 등) 때문에” 실행되어야 하는지를 기준으로 Effect에 둘지 이벤트 핸들러에 둘지를 결정해야 한다.

  • 예시 코드에서는 알림이 “페이지가 렌더링되었다”는 사실 때문에 실행되는 것이 아니라 “사용자가 버튼을 눌렀다”는 상호작용 때문에 실행되어야 하므로, Effect가 아니라 각 버튼의 이벤트 핸들러에서 공유 함수를 호출하는 방식이 맞다.

Sending a POST request

예시 코드
  • POST 요청 로직을 어디에 둘지 결정할 때 가장 중요한 점은, 그 로직이 “컴포넌트가 화면에 표시됐기 때문에” 실행되어야 하는지, 아니면 “사용자 상호작용(버튼 클릭 등) 때문에” 실행되어야 하는지 기준을 세우는 것이다.

  • 예시 코드에서는 analytics 이벤트처럼 폼이 표시될 때 한 번 실행돼야 하는 로직은 Effect 안에 두고, 회원가입 /api/register 요청처럼 사용자가 버튼을 클릭했을 때만 실행돼야 하는 로직은 Effect가 아닌 이벤트 핸들러 내부에 두어야 한다.

Chains of computations

예시 코드
  • 여러 state를 서로 Effect로 체이닝해서 업데이트하면 불필요한 리렌더링이 많이 발생하고, 이전 state를 복원할 때 체인이 다시 트리거되는 등 구조가 취약해지므로 피하는 것이 좋다.

  • 이 예시에서는 isGameOver처럼 렌더링 중에 계산 가능한 값은 렌더링에서 직접 계산하고, place card와 관련된 연속적인 state 변경(card, goldCardCount, round, 알림 표시)은 하나의 이벤트 핸들러 안에서 한 번에 처리하도록 구조를 바꾸는 것이 더 효율적이고 요구사항 변화에도 유연하다.​

Initializing the application

예시 코드

  • 앱이 로드될 때 한 번만 실행되어야 하는 로직을 단순히 최상위 컴포넌트의 Effect에 넣으면, 개발 모드에서 Effect가 두 번 실행되기 때문에 인증 토큰 무효화 같은 문제가 생길 수 있어 지양해야 한다.

  • 이런 종류의 초기화 로직은 “컴포넌트 마운트당 한 번”이 아니라 “앱 로드당 한 번”만 실행되어야 하므로, 최상위 스코프에 didInit 같은 변수를 두고 실행 여부를 추적하거나, 아예 모듈 초기화 단계에서 한 번만 실행되도록 분리해 App.js나 엔트리 포인트에 배치하는 것이 권장된다.​

Notifying parent components about state changes

예시 코드

  • 자식 컴포넌트의 state 변경을 부모에게 알릴 때, Effect 안에서 부모의 onChange를 호출하면 자식이 먼저 렌더링된 뒤 부모가 다시 렌더링되는 두 번의 패스가 생겨 비효율적이므로, 하나의 이벤트 흐름 안에서 자식과 부모 state를 함께 업데이트하는 것이 좋다.

  • 예시처럼 updateToggle 함수 안에서 setIsOn과 onChange를 동시에 호출하거나, 아예 Toggle의 state를 없애고 isOn을 부모로부터 완전히 제어받도록 “state 끌어올리기”를 적용하면, 두 컴포넌트가 한 번의 렌더링 패스로 동기화되고 관리해야 할 state도 줄어든다.

Passing data to the parent

예시 코드

  • React에서는 데이터 흐름이 기본적으로 부모 → 자식 방향이기 때문에, 자식이 Effect 안에서 부모의 state를 직접 갱신하게 만들면 데이터의 출발점을 추적하기 어려워져 흐름이 복잡해진다.

  • 이 예시에서는 자식과 부모가 같은 데이터를 필요로 하므로, 데이터를 가져오는 책임을 부모 컴포넌트로 올리고(useSomeAPI를 부모에서 호출), 그 결과를 props로 자식에게 내려보내도록 “state 끌어올리기”를 적용하면, 데이터가 항상 부모에서 자식으로만 내려가서 흐름이 단순하고 예측 가능하게 유지된다.​

Subscribing to an external store

예시 코드

  • 기존 Effect 기반 패턴

    • 예시 코드에서는 navigator.onLine 값을 읽어와 isOnline state로 옮기고, online/offline 이벤트 리스너를 Effect에서 수동으로 등록,해제하는 useOnlineStatus 훅을 먼저 보여준다.

    • 이 방식은 “외부 저장소 → React state”로 일일이 옮겨 적는 구조라, 변경 가능한 데이터를 수동으로 동기화해야 해서 실수 가능성이 크고 유지보수가 어렵다는 단점이 있다.

  • useSyncExternalStore 패턴

    • 위와 같은 방식을 useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)으로 바꾸면, subscribe에서 이벤트 리스너 등록/해제를 정의하고 navigator.onLine을 스냅샷 함수로 바로 읽게 되어, 외부 저장소를 React가 공식적으로 지원하는 방식으로 subscribe 할 수 있다.

Fetching data

예시 코드
  • 많은 앱에서 검색 결과 같은 데이터를 가져올 때 useEffect를 사용하며, SearchResults 예제처럼 query와 page 값에 맞춰 results를 서버 데이터와 계속 동기화한다. 이 경우 핵심 요구사항은 “사용자가 어떻게 여기까지 왔든, 이 컴포넌트가 보이는 동안 현재 query/page에 해당하는 데이터가 항상 맞게 떠 있어야 한다”이기 때문에, 특정 클릭 이벤트가 아니라 Effect에서 fetch를 트리거하는 것이 적절한 선택이 된다.

  • 단순 구현으로는 “race condition” 이라는 문제가 생긴다. 사용자가 "hello"를 빠르게 입력하면 "h", "he", "hel" 등 여러 요청이 거의 동시에 날아가는데, "hello" 요청보다 "hel" 응답이 더 늦게 도착하면 오래된 결과가 마지막에 setResults를 호출해서 화면이 잘못된 검색어의 결과로 덮어쓰인다. 이를 막기 위해 Effect 안에서 let ignore = false 같은 플래그를 두고, fetch 응답에서 if (!ignore) setResults(json)으로 체크한 뒤 정리 함수에서 ignore = true로 바꿔 주면, 이전 렌더에서 시작된 모든 요청 응답은 무시되고 “마지막 렌더에서 만든 요청의 응답만 유효”하게 된다.

  • 같은 패턴을 여러 곳에 복사하기보다는, useData(url)처럼 커스텀 훅으로 추상화하는 편이 좋다. 이 훅 안에서 fetch, 정리, 에러 처리, 로딩 상태 관리까지 한 번에 처리해 두면, 화면 컴포넌트는 const results = useData(url)처럼 선언적으로 사용할 수 있고, 나중에 프레임워크 내장 데이터 패칭이나 다른 방식으로 변경할 때도 호출부는 거의 건드리지 않고 내부 구현만 교체하면 된다.