[React 19] 공식문서 톺아보기 - React Hook (1)

Study• 수십명이 읽음
렌더링 최적화를 위한 useCallback, 상태를 공유하는 useContext, 폼 액션을 지원하는 useActionState 등에 대해 공부하였습니다.

useActionState

폼 액션의 결과를 기반으로 State를 업데이트할 수 있도록 도와주는 Hook

const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);

폼제출에 특화되어있다

서버액션fn 과 초기값initialState을 넣게 되면,

  • 상태변경을 감지할수 있는 폼 액션formAction을 리턴 받는다.
  •  폼 액션formAction을 사용하여 제출하게 될경우 stateisPending이 자동으로 업데이트된다.

tanstack-query의 useMutation 과 비슷한 기능을 제공한다.

서버액션 함수를 사용해야 한다

  • fn은 두가지를 인자로 받게 되는데 prevState, formData 이는 폼제출을 더 용이하게 해준다.
    • prevState : 이전 상태값을 가진다. 아무런 입력이 없었을때는 initialState를 나타낸다.
    • formData : 현재 폼 데이터를 나타낸다. <input name="email"> → formData.get('email')로 접근할 수 있다.

점진적 향상 지원

  • 자바스크립트가 꺼진상태나 아직 로드중일때도 제출이 가능하다.
    // 서버가 생성하는 HTML (순수 HTML), js가 로드되기전 동작
    <form action="/_next/action" method="POST">
      <input name="email">
      <button type="submit">가입</button>
    </form>
    // 서버가 생성하는 HTML (순수 HTML), js가 로드되기전 동작
    <form action="/_next/action" method="POST">
      <input name="email">
      <button type="submit">가입</button>
    </form>
    // React가 DOM을 인수(하이드레이션)하면서 변환
    <form action={serverAction}>  {/* 프로그래매틱 폼 처리 */}
      <input name="email">
      <button type="submit">가입</button>
    </form>
    // React가 DOM을 인수(하이드레이션)하면서 변환
    <form action={serverAction}>  {/* 프로그래매틱 폼 처리 */}
      <input name="email">
      <button type="submit">가입</button>
    </form>
    // 내부적으로 이런 로직이 동작
    if (navigator.onLine && isJSLoaded) {
      event.preventDefault();
      fetchAction(formData);
    } else {
      // 기본 폼 제출 유지
    }
    // 내부적으로 이런 로직이 동작
    if (navigator.onLine && isJSLoaded) {
      event.preventDefault();
      fetchAction(formData);
    } else {
      // 기본 폼 제출 유지
    }

하이드레이션하면서 DOM의 속성이 바뀌게 되는데 이때 리렌더링이 발생할까?

속성만 변경된 경우는 리렌더링이 발생하지 않는다.

"같은 타입의 두 React DOM 엘리먼트를 비교할 때, React는 두 엘리먼트의 속성을 확인하여, 동일한 내역은 유지하고 변경된 속성들만 갱신합니다."

리액트 공식문서 재조정 에 대한 설명

  • 폼액션 결과가 url에 반영되어야할 경우 (예를들면 검색결과 /search?q=야구) permalink에 바뀔 url을 넣어서 새로고침없이 반영할 수 있다.
    • 상태와 url 동기화
    • 브라우저 히스토리 스택에 상태 저장

폼 액션 vs 폼 제출

// 일반적인 폼 제출
<form action="/api/submit" method="POST">
  <input name="email">
  <button>제출</button>
</form>
// 일반적인 폼 제출
<form action="/api/submit" method="POST">
  <input name="email">
  <button>제출</button>
</form>
  • 기본 HTML 동작
  • 페이지 새로고침 발생
// 폼 액션
<form action={serverAction}>
  <input name="email">
  <button>제출</button>
</form>
// 폼 액션
<form action={serverAction}>
  <input name="email">
  <button>제출</button>
</form>
  • React/Next.js의 고급 기능
  • 현재 페이지 유지하면서 상태만 업데이트

useCallback

useCallback은 리렌더링 간에 함수 정의를 캐싱해 주는 Hook

const cachedFn = useCallback(fn, dependencies);
const cachedFn = useCallback(fn, dependencies);
  • fn: 캐싱할 함숫값
    • React는 첫 렌더링에서 이 함수를 반환한다. (호출하는 것이 아니다.)
    • 다음 렌더링에서 dependencies 값이 이전과 같다면 React는 같은 함수를 다시 반환한다.
  • dependencies: fn 내에서 참조되는 모든 반응형 값의 목록
    • 반응형 값은 props와 state, 그리고 컴포넌트 안에서 직접 선언된 모든 변수와 함수를 포함한다.

React 공식 문서에서 말하는 useCallback를 써야하는 기준

  1. 페이지내에서 미세한 상호작용을 하는 경우
  • 대부분의 상호작용이 (페이지 전체나 전체 부문을 교체하는 것처럼) 굵직한 경우, 보통 memoization이 필요하지 않다. 반면에 앱이 (도형을 이동하는 것과 같이) 미세한 상호작용을 하는 그림 편집기 같은 경우, memoization이 매우 유용할 수 있다.
  1. 의존성 배열이 자주 바뀌지 않는 함수 (의존성 배열이 자주바뀌어 그때마다 재선언되면 의미가 없어진다.)
  2. 렌더링 시간 기준 10ms 이상 소요될 경우
  3. 반복 실행 빈도가 높을 경우

사용예시1 - memo로 감싼 컴포넌트에 props로 함수를 넘길때

function ProductPage({ productId, referrer, theme }) {
  // theme이 바뀔때마다 리렌더링이 되어 재선언된다.
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }

  return (
    <div className={theme}>
      {/* ShippingForm의 props는 theme와 관계가 없음에도 매번 리렌더링이 된다. */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
function ProductPage({ productId, referrer, theme }) {
  // theme이 바뀔때마다 리렌더링이 되어 재선언된다.
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }

  return (
    <div className={theme}>
      {/* ShippingForm의 props는 theme와 관계가 없음에도 매번 리렌더링이 된다. */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
  • theme를 바뀔 경우 ProductPage이 리렌더링되면서 handleSubmit이 재선언되고 ShippingForm도 리렌더링 된다.
  • 이때 ShippingFormtheme를 props로 가지고 있지 않기에 최적화가 가능하다.
    • ShippingFormmemo로 감싸고 handleSubmituseCallback으로 감싸면 theme가 바뀌어도 ShippingForm은 리렌더링 되지 않는다.
function ProductPage({ productId, referrer, theme }) {
  // useCallback를 통해 리렌더링 간에 함수를 캐싱한다.
  const handleSubmit = useCallback(
    (orderDetails) => {
      post('/product/' + productId + '/buy', {
        referrer,
        orderDetails,
      });
    },
    [productId, referrer], // 이 의존성이 변경되지 않는 한 다시 계산하지 않는다.
  );

  return (
    <div className={theme}>
      {/* ShippingForm은 같은 props를 받게 되고 theme값이 변하더라도 리렌더링을 건너뛸 수 있다. */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}
function ProductPage({ productId, referrer, theme }) {
  // useCallback를 통해 리렌더링 간에 함수를 캐싱한다.
  const handleSubmit = useCallback(
    (orderDetails) => {
      post('/product/' + productId + '/buy', {
        referrer,
        orderDetails,
      });
    },
    [productId, referrer], // 이 의존성이 변경되지 않는 한 다시 계산하지 않는다.
  );

  return (
    <div className={theme}>
      {/* ShippingForm은 같은 props를 받게 되고 theme값이 변하더라도 리렌더링을 건너뛸 수 있다. */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

useEffect안에서만 함수를 사용할때 내부에 선언하는게 좋을까? 아니면 외부에 useCallback을 이용해서 선언하는게 좋을까?

useEffect 내부에 선언하는게 더 좋다.

  1. useEffect가 실행될때마다 함수가 재선언되지만, 함수 선언은 무거운 연산이 아니다.
  2. 외부 의존성이 필요없고 해당 useEffect에서만 사용된다는 것을 바로 알 수 있다.

만약 다른곳에서 사용되거나 함수 로직이 길어지게 될 경우는 외부에 선언하는게 더 좋다.

사용예시2 - 커스텀 훅에서 함수를 반환하는 경우

function Parent() {
  const { increment } = useCounter(); // 매번 새 함수 생성
  return <Child onClick={increment} />;
}
function Parent() {
  const { increment } = useCounter(); // 매번 새 함수 생성
  return <Child onClick={increment} />;
}
  • 훅 사용자가 별도 최적화 없이도 안정적인 함수 참조 보장한다.

    • 만약 incrementuseCallback을 이용해서 선언하지 않았다면 memoChild를 감쌌다고 하더라도, Parent가 리렌더링될때마다 매번 increment가 새로 생성되므로 함수 객체의 참조가 변경되어 Child는 props가 바뀌었다고 인식한다. 따라서 같은 함수임에도 리렌더링이 발생한다.
    • 이러한 최적화에 대한 처리를 사용하는 쪽에서 매번 신경쓰지 않기 위해 훅안에서 useCallback을 사용하면 좋다.

꼭 useCallback을 쓰지 않아도 됩니다

function Report({ item }) {
  const handleClick = () => {
    sendReport(item);
  };

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
}
function Report({ item }) {
  const handleClick = () => {
    sendReport(item);
  };

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
}
  • 위 컴포넌트를 최적화 할때 memo vs handleClickuseCallback vs 그냥두기

    • 세가지 모두 item이 바뀌게 되면 리렌더링이 발생한다.
    • 그러나 부모컴포넌트에서 item이 아닌 다른 이유로 리렌더링이 발생하게 된다면 memo로 감싸면 리렌더링을 막을 수 있다.

번외) children 과 리렌더링

// React.memo를 사용할 때
const MemoA = React.memo(A);

// 방식1: MemoA가 리렌더링되면 B도 무조건 리렌더링
function Parent({item}) {
  return (
    <MemoA item={item} /> // A 안에 <B />
  );
}

// 방식2: MemoA가 children을 prop으로 받으면
function Parent({item}) {
  return (
    <MemoA>
      <B item={item} /> {/* B는 MemoA의 렌더링 영향에서 자유로워질 수 있음 */}
    </MemoA>
  );
}
// React.memo를 사용할 때
const MemoA = React.memo(A);

// 방식1: MemoA가 리렌더링되면 B도 무조건 리렌더링
function Parent({item}) {
  return (
    <MemoA item={item} /> // A 안에 <B />
  );
}

// 방식2: MemoA가 children을 prop으로 받으면
function Parent({item}) {
  return (
    <MemoA>
      <B item={item} /> {/* B는 MemoA의 렌더링 영향에서 자유로워질 수 있음 */}
    </MemoA>
  );
}
  • A에서는 item이 쓰이지 않고 자식 컴포넌트인 B에서만 사용된다면, A에서 B를 children으로 분리하고 A를 memo로 최적화하자.
    • item값 변화에 대해서 A는 자유로워진다.
    • B는 A의 렌더링에서 자유로워진다.

useContext

useContext는 컴포넌트에서 Context를 읽고 구독할 수 있는 Hook

// 정의할때
const ThemeContext = createContext(defaultValue);
// 정의할때
const ThemeContext = createContext(defaultValue);
// 값을 사용할때
const value = useContext(ThemeContext);
// 값을 사용할때
const value = useContext(ThemeContext);
  • ThemeContextcreateContext로 생성한 Context이다. Context 자체는 정보를 담고 있지 않으며, 컴포넌트에서 제공하거나 읽을 수 있는 정보의 종류를 나타낸다.
  • value : 트리에서 호출하는 컴포넌트 상위의 가장 가까운 ThemeContext.Provider에 전달된 값으로 결정된다.
    • Provider가 없으면 반환된 값은 해당 Context에 대해 createContext에 전달한 defaultValue가 된다.
    • 반환된 값은 항상 최신 상태로 값이 변경되면 즉시 반영된다.

중첩 provider 구조

  • 컴포넌트 내의 useContext() 호출은 동일한 컴포넌트에서 반환된 Provider에 영향을 받지 않는다. 해당하는 <Context.Provider>는 useContext() 호출을 하는 컴포넌트 상위에 배치되어야 전달하는 값을 제대로 반영한다.
const value = useContext(ThemeContext)

return (
    <ThemeContext.Provider value="dark">
      <Form />
    </ThemeContext.Provider>
)
const value = useContext(ThemeContext)

return (
    <ThemeContext.Provider value="dark">
      <Form />
    </ThemeContext.Provider>
)
  • 즉, 위와 같이 컨텍스트가 중첩으로 감싸는 구조일때 value값은 "dark"가 아니라 상위 컴포넌트면서 가장가까운 Provider에 영향을 받는다.
<ThemeContext.Provider value='dark'>
  ...
  <ThemeContext.Provider value='light'>
    <Footer />
  </ThemeContext.Provider>
  ...
</ThemeContext.Provider>
<ThemeContext.Provider value='dark'>
  ...
  <ThemeContext.Provider value='light'>
    <Footer />
  </ThemeContext.Provider>
  ...
</ThemeContext.Provider>
  • 가장 가까운 Provider가 제공하는 값을 참조한다는 점에서 동일한 값에 대해 정반대 값을 쓴다거나 영향을 받을때 오버라이딩을 하는 식으로 중첩구조를 활용할 수 있을것 같다.
  • 이러한 경우를 제외하고 다른 값을 사용하고 싶을때는 중첩되게 Provider를 사용하여 Context 값의 범위를 혼란스럽게 만드는것보다는 여러개의 Context로 분리해서 사용하는것이 더 알기쉬운 구조를 만드는것 같다.

Context 와 memo

  • memo로 감싸더라도 내부에서 context를 호출하고 있고 값이 변경되었다면 리렌더링이 발생한다.
    • memo가 방지하는 것: 부모 컴포넌트의 리렌더링 전파
    • memo가 막지 못하는 것: Context 값 변화로 인한 리렌더링
  • Context는 주로 "자주 변경되지 않는 값"에 사용하면 좋다. 자주 바뀌는 값에 대해서 Context를 사용하면 어디서 리렌더링을 유발하는지 예측하기 어렵고 이를 구독하고 있는 모든 컴포넌트가 리렌더링이 된다.

사용방법1 - 싱글톤으로 선언

// 여러 파일에서 createContext()를 호출하면 다른 Context 인스턴스 생성
// FileA.js
const ThemeContext = createContext();

// FileB.js
const ThemeContext = createContext(); // 다른 인스턴스!
// 여러 파일에서 createContext()를 호출하면 다른 Context 인스턴스 생성
// FileA.js
const ThemeContext = createContext();

// FileB.js
const ThemeContext = createContext(); // 다른 인스턴스!
// contexts/ThemeContext.js
const ThemeContext = createContext('light'); // 단일 인스턴스 생성
export default ThemeContext;

// App.js
import ThemeContext from './contexts/ThemeContext';
// contexts/ThemeContext.js
const ThemeContext = createContext('light'); // 단일 인스턴스 생성
export default ThemeContext;

// App.js
import ThemeContext from './contexts/ThemeContext';
  • Context를 사용하는 쪽에서 createContext를 선언하지 않고 따로 선언하고 불러와서 사용하는 방식으로 관리해야 중복선언을 막아 동일한 인스턴스 내에서 값을 공유할 수 있다.
// .eslintrc.js
rules: {
  'no-restricted-imports': [
    'error',
    {
      patterns: [
        {
          group: ['**/!(contexts)/*.js'],
          importNames: ['createContext'],
          message: 'Context는 contexts/ 디렉토리에서만 생성하세요.'
        }
      ]
    }
  ]
}
// .eslintrc.js
rules: {
  'no-restricted-imports': [
    'error',
    {
      patterns: [
        {
          group: ['**/!(contexts)/*.js'],
          importNames: ['createContext'],
          message: 'Context는 contexts/ 디렉토리에서만 생성하세요.'
        }
      ]
    }
  ]
}
  • 린트 룰로 contexts 폴더아래가 아닌곳에서 createContext를 호출하려고할때 에러를 줄수도 있다.

사용방법2 - 커스텀 훅과 결합

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('ThemeProvider 없음!');
  return context;
}
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('ThemeProvider 없음!');
  return context;
}
  • 사용할때마다 useContext로 값을 불러오는것보다 위와같은 선언을 통해 훅으로 값을 불러오고, Provider로 감싼 범위 밖에서 사용할때 에러를 반환하는것이 좋다.

객체와 함수를 전달할때 렌더링 최적화

function MyApp() {
  const [currentUser, setCurrentUser] = useState(null);

  function login(response) {
    storeCredentials(response.credentials);
    setCurrentUser(response.user);
  }

  return (
    <AuthContext.Provider value={{ currentUser, login }}>
      <Page />
    </AuthContext.Provider>
  );
}
function MyApp() {
  const [currentUser, setCurrentUser] = useState(null);

  function login(response) {
    storeCredentials(response.credentials);
    setCurrentUser(response.user);
  }

  return (
    <AuthContext.Provider value={{ currentUser, login }}>
      <Page />
    </AuthContext.Provider>
  );
}
  • 위의 경우 MyApp이 리렌더링 될때마다 login 은 재선언되고 이로 인해서 useContext(AuthContext) 를 호출하는 모든 컴포넌트도 같이 리렌더링이 된다.
import { useCallback, useMemo } from 'react';

function MyApp() {
  const [currentUser, setCurrentUser] = useState(null);

  const login = useCallback((response) => {
    storeCredentials(response.credentials);
    setCurrentUser(response.user);
  }, []);

  const contextValue = useMemo(
    () => ({
      currentUser,
      login,
    }),
    [currentUser, login],
  );

  return (
    <AuthContext.Provider value={contextValue}>
      <Page />
    </AuthContext.Provider>
  );
}
import { useCallback, useMemo } from 'react';

function MyApp() {
  const [currentUser, setCurrentUser] = useState(null);

  const login = useCallback((response) => {
    storeCredentials(response.credentials);
    setCurrentUser(response.user);
  }, []);

  const contextValue = useMemo(
    () => ({
      currentUser,
      login,
    }),
    [currentUser, login],
  );

  return (
    <AuthContext.Provider value={contextValue}>
      <Page />
    </AuthContext.Provider>
  );
}
  • 따라서 useCallback을 통해 login 함수가 재선언되지 않도록 최적화한다.

  • value에 전달하는 객체도 매 렌더링마다 새로 생성되지 않도록 전달하는 객체를 useMemo로 감싼다.

    • _여러 값을 Context로 공유할때는 useMemo로 감싸고 객체로 전달하거나 각각을 Context로 분할해야한다.

context 사용시 유의사항

  • value가 없는 Provider가 있는지 확인
  • 싱글톤을 적용했는지 체크
  • 호출하는 곳과 Provider가 같은 컴포넌트내에 있는지 체크 (Provider가 더 상위에 존재)
  • value로 전달하는 객체가 렌더링 최적화가 되어있는지
  • 자주 바뀌지 않는 값으로 설정

useDebugValue

useDebugValue는 React DevTools에서 커스텀 훅에 라벨을 추가할 수 있게 해주는 Hook

useDebugValue(value, format?)
useDebugValue(value, format?)
  • value: React DevTools에 표시하고 싶은 값
  • format: 포맷팅 함수 (예시 : value ⇒ 값 : ${value} )

사용예시

import { useDebugValue } from 'react';

function useOnlineStatus() {
  // ...
  useDebugValue(isOnline ? 'Online' : 'Offline');
  // ...
}
import { useDebugValue } from 'react';

function useOnlineStatus() {
  // ...
  useDebugValue(isOnline ? 'Online' : 'Offline');
  // ...
}

react-devtools-usedebugvalue.png

  • devTools에서는 OnlineStatus : 'Online' 와 같이 나타난다.
    • OnlineStatus는 훅에서 use가 빠진 이름 (위 예시에서 훅의 이름이 useOnlineStatus 이므로 OnlineStatus 로 표기)

useDeferredValue

useDeferredValue는 일부 UI 업데이트를 지연시킬 수 있는 Hook

const deferredValue = useDeferredValue(value, initialValue?)
const deferredValue = useDeferredValue(value, initialValue?)
  • value: 지연시키려는 값
    • 렌더링마다 선언되는 값이 아니어야 한다.
  • initialValue: 컴포넌트 초기 렌더링 시 사용할 값
    • 생략하면 초기 렌더링 동안 useDeferredValue는 값을 지연시키지 않는다. 이는 대신 렌더링할 value의 이전 버전이 없기 때문이다.
  • deferredValue : 첫 렌더링에서 ‘지연된 값’은 initialValue이고 업데이트가 발생하면 React는 먼저 이전 값으로 리렌더링을 시도하고 그 다음 백그라운드에서 다시 새 값으로 리렌더링을 시도한다.

지연되는 과정

  1. 초기 렌더링: deferredValue = 초기값 → 화면 표시
  2. 새 값 입력: setValue('새값') 호출
  3. React 동작:
    • deferredValue는 이전 값(초기값)을 유지한 채로
    • 백그라운드에서 새 값('새값') 계산 준비
    • 계산 완료후 React가 새 값으로 컴포넌트를 자연스럽게 교체
    • 만약 valuedeferredValue를 하나의 컴포넌트에서 동시에 사용하게 된다면 하나의 상태 변경이 두 가지 다른 타이밍의 렌더링을 유발할 수 있다. (이러한 과정은 우선순위에 따른 ui업데이트를 하는 동시성 기능과 연관되어있다.)

콘텐츠가 오래되었음을 표시하기

const deferredValue = useDeferredValue(value, initialValue?)
const deferredValue = useDeferredValue(value, initialValue?)
  • valuedeferredValue의 값이 다르다는 것을 통해 현재 백그라운드에서 계산중이라는 것을 인지할 수 있다.

주의 사항

  • deferredValue는 백그라운드에서 ui와 로직을 계산하고 완료되었을때 값이 바뀐다.

    • 즉, 값이 바뀌는 과정을 보여주고 싶지 않을때 사용한다.
    • 리렌더링을 방지하는 목적이 아니다. 실제로 value의 값이 변했기에 리렌더링은 발생하고 deferredValue를 props로 받는 컴포넌트에 대해서만 렌더링을 지연시킨다.
    • useEffect 의존성 배열에 포함되어있다면 Effect의 재실행 기준은 value이 아니라 deferredValue 기준으로 실행된다.
  • useDeferredValue의 목적은 렌더링 우선순위 조정을 하는 것이다.

    1. 긴급 업데이트(입력)는 즉시 반영
    2. 비긴급 업데이트(결과 표시)는 나중에 처리
  • ui 업데이트만 지연시킬뿐 네트워크 요청에 대한 최적화가 적용되는건 아니다.

    • 검색어 입력에 따른 api 요청이 있다고 할때 ‘나무’를 입력하게 되면 초기값에서 중간 입력에 대한 결과를 생략하고 '나무'에 대한 결과를 보여주지만 실제로는 ‘ㄴ’, ‘나’, ‘남’, ‘나무’ 에 대한 네가지 요청을 모두 하게 된다.
      • 단, 새로운 값 변경이 되면 그전 백그라운드 계산은 중단되고 새값으로 다시 계산한다.
    • 네트워크 요청도 최적화하는 방법
      1. 디바운스와 결합하여 요청한다.
      2. useTransitionuseDeferredValue 를 함께 적용한다.

useEffect

useEffect는 외부 시스템과 컴포넌트를 동기화하는 Hook

useEffect(setup, dependencies?)
useEffect(setup, dependencies?)
  • setup: React는 컴포넌트가 DOM에 추가된 이후에 설정 함수를 실행한다. 의존성 변화에 따라 리렌더링이 될경우, 클린업 함수를 실행하고 새로운 값으로 설정함수를 실행한다. 컴포넌트가 DOM에서 제거되었을때도 클린업 함수를 실행한다.
  • dependencies : 설정 함수의 코드 내부에서 참조되는 모든 반응형 값들이 포함된 배열로 구성된다. 반응형 값에는 props와 state, 모든 변수 및 컴포넌트 body에 직접적으로 선언된 함수들이 포함된다. 의존성을 생략할 경우, Effect는 컴포넌트가 리렌더링될 때마다 실행된다.

실행순서

  1. 컴포넌트가 마운트 될때 설정 함수가 실행된다.
  2. 의존성이 변경된 컴포넌트가 리렌더링 될 때마다 아래 동작을 수행한다.
    • 먼저 정리 함수가 오래된 props와 state와 함께 실행된다.
    • 이후, 설정 함수가 새로운 props와 state와 함께 실행된다.
  3. 컴포넌트가 언마운트 되고나서 정리 함수가 마지막으로 실행된다.

엄격모드와 정리함수

  • React는 첫 번째 설정 함수가 실행되기 이전에 개발 모드에만 한정하여 한 번의 추가적인 설정 + 정리 사이클을 실행한다.
    • 이는 정리 로직이 설정 로직을 완벽히 “반영”하고 설정 로직이 수행하는 작업을 중단하거나 취소할 수 있는지를 확인하는 테스트이다.
    • 설정 함수가 한 번 호출될 때와 [설정 → 정리 → 설정] 순서로 호출될 때의 차이가 없어야 한다.
  • 정리 함수는 설정 함수가 여러번 실행되었을때 문제가 발생하는 경우에만 필요하다.
    • 예를들면 구독이벤트, 애니메이션 초기설정 등이 있다.
    • 멱등성을 가지고 있는 경우에는 정리 함수가 없어도 된다.

의존성 배열 최적화하기

  • Effect에서 사용하는 모든 반응형 값은 의존성으로 선언되어야 한다.

    • 값이 변할때 렌더링에 영향을 주지 않는 ref라고해도 부모 컴포넌트로부터 전달받은 값인 경우, 자식 컴포넌트에서는 해당 값이 변경될지 안될지 알 수 없으므로 의존성 배열에 추가해야한다.
  • 상수를 사용하는 경우

    • 컴포넌트 밖에 선언함으로써 리렌더링이 될때 변경되지 않음을 증명하여 의존성에서 제거할 수 있다.
  • 기존 state 값을 이용해서 업데이트 시키는 경우

    • 업데이터 함수를 통해 의존성에서 제거할 수 있다.
    • setCount(count + 1) 대신 setCount(prev ⇒ prev + 1) 으로 사용한다.
  • 객체를 선언하고 Effect내에서 사용하는 경우

    useEffect(() => {
      const options = {
        serverUrl: serverUrl,
        roomId: roomId,
      };
      const connection = createConnection(options);
      connection.connect();
      return () => connection.disconnect();
    }, [roomId]);
    useEffect(() => {
      const options = {
        serverUrl: serverUrl,
        roomId: roomId,
      };
      const connection = createConnection(options);
      connection.connect();
      return () => connection.disconnect();
    }, [roomId]);
    • 리렌더링 될때마다 새로 생성되기 때문에 객체 선언은 Effect 내에서 선언하거나 useMemo로 감싸서 객체를 의존성으로 사용하는 것을 피할 수 있다.
  • 컴포넌트 내에 선언된 함수를 Effect내에서 사용하는 경우

    • 리렌더링 될때마다 함수가 새로선언되므로 Effect내에 선언하거나, useCallback으로 감싸서 최적화를 해야합니다.

실험기능) useEffectEvent를 이용하여 원하는 반응형값을 의존성에서 제거하기

const onVisit = useEffectEvent((visitedUrl) => {
  logVisit(visitedUrl, shoppingCart.length);
});

useEffect(() => {
  onVisit(url);
}, [url]);
const onVisit = useEffectEvent((visitedUrl) => {
  logVisit(visitedUrl, shoppingCart.length);
});

useEffect(() => {
  onVisit(url);
}, [url]);
  • 위와 같이 useEffectEvent로 감싼 로직에서 사용되는 반응형 값은 의존성에 추가하지 않아도 된다. 그 외의 반응형값이 Effect에서 사용됨에도 의존성에 없는 경우는 린트가 이를 감지한다.
  • // eslint-ignore-next-line react-hooks/exhaustive-deps 주석을 추가하여 의존성을 제거하는 방법은 지양해야한다.
    • 주석의 경우 코드가 수정되어 다른 반응형 값이 추가되어도 감지를 못하는 반면, useEffectEvent는 해당 부분에서 사용하는 반응형 값만 린트에서 벗어나기 때문에 다른 반응형 값이 추가되어도 린트의 도움을 받을 수 있다.

useId

useId는 접근성 어트리뷰트에 전달할 수 있는 고유 ID를 생성하기 위한 Hook

const id = useId();
const id = useId();
  • useId는 컴포넌트 인스턴스의 수명주기와 연관된 고유ID를 생성하기 위해 만들어졌다.
  • 이 값을 key에 지정하면 안된다. 매 렌더링마다 새로운 값을 반환하여 매번 전체 리스트를 리렌더링 한다.
  • 직접 id값을 쓰지않고, 중복되지 않는 값이 필요할때 쓴다.

사용예시 1 - 접근성 속성연결

function InputField() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>이메일</label>
      <input id={id} type='email' />
    </>
  );
}
function InputField() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>이메일</label>
      <input id={id} type='email' />
    </>
  );
}

사용예시 2 - 중복되지 않는 DOM ID 생성

function CheckboxList() {
  const id = useId();
  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          <input id={`${id}-${item.id}`} type='checkbox' />
          <label htmlFor={`${id}-${item.id}`}>{item.label}</label>
        </div>
      ))}
    </div>
  );
}

// ---

<>
  <CheckboxList />
  <CheckboxList />
</>;
function CheckboxList() {
  const id = useId();
  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          <input id={`${id}-${item.id}`} type='checkbox' />
          <label htmlFor={`${id}-${item.id}`}>{item.label}</label>
        </div>
      ))}
    </div>
  );
}

// ---

<>
  <CheckboxList />
  <CheckboxList />
</>;
  • 같은 컴포넌트가 여러 번 렌더링될 때 ID 충돌을 방지한다. (id는 고유해야하므로)

사용예시 3 - 서버/클라이언트 일관성 보장

export default function Page() {
  return (
    <div>
      {/* 항상 같은 위치에 렌더링 */}
      <CheckboxList /> {/* ":r1:1" (서버/클라이언트 일치) */}
    </div>
  );
}
export default function Page() {
  return (
    <div>
      {/* 항상 같은 위치에 렌더링 */}
      <CheckboxList /> {/* ":r1:1" (서버/클라이언트 일치) */}
    </div>
  );
}
  • useId()가 생성하는 고유 ID는 컴포넌트의 트리 구조와 렌더링 순서에 따라 결정되므로 서버와 클라이언트 간 동일한 트리 구조가 유지되면 같은 ID가 생성된다.
    • 다른 트리 구조면 다른 id가 리턴된다.
  • 중복을 피하기 위해 단지 랜덤한 값을 생성하는 다른 것을 이용하면 서버와 클라이언트에서 다른 값이 나와 하이드레이션 오류가 발생한다.

useImperativeHandle

useImperativeHandle은 ref로 노출되는 핸들을 사용자가 직접 정의할 수 있게 해주는 Hook

useImperativeHandle(ref, createHandle, dependencies?)
useImperativeHandle(ref, createHandle, dependencies?)
  • ref: 부모 컴포넌트의 prop으로 받은 ref
  • createHandle: 인수가 없고 노출하려는 ref 핸들을 반환하는 함수이다.
  • dependencies: createHandle 코드 내에서 참조하는 모든 반응형 값을 나열한 목록이다.
    • 리렌더링으로 인해 일부 의존성이 변경되거나 이 인수를 생략한 경우 createHandle함수가 다시 실행되고 새로 생성된 핸들이 ref에 할당된다.

사용예시 - 노출하고자하는 메서드만 노출한다.

function MyInput({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => {
    return {
      focus() {
        inputRef.current.focus();
      },
      scrollIntoView() {
        inputRef.current.scrollIntoView();
      },
    };
  }, []);

  return <input ref={inputRef} />;
}
function MyInput({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => {
    return {
      focus() {
        inputRef.current.focus();
      },
      scrollIntoView() {
        inputRef.current.scrollIntoView();
      },
    };
  }, []);

  return <input ref={inputRef} />;
}
  • 부모 컴포넌트가 focusscrollAndFocusAddComment 메서드를 호출할 수 있다. 그 외 DOM 노드의 전체 엑세스 권한은 없다.
  • 메서드의 이름을 반드시 DOM 메서드와 일치하게 선언하지 않아도 된다.
읽어주셔서 감사합니다