[모던 리액트] 3장 리액트 훅 깊게 살펴보기

Minhyuk Song·2024년 12월 16일

모던 리액트

목록 보기
3/3

3.1 리액트의 모든 훅 파헤치기

  • 이하 내용에서 렌더링은 렌더와 커밋 단계를 모두 포함하는 개념을 지칭합니다.
  • 함수형 컴포넌트는 매번 함수를 실행해 렌더링을 수행한다

3.1.1 useState

import { useState } from "react";

const [state, setState] = useState(initialState);
  • 함수형 컴포넌트 내부에서 상태를 정의하고, 관리할 수 있게 해주는 훅

  • 리액트의 렌더링과 상태

    export default function App() {
      const [, setState] = useState(0);
      let state = "hello";
    
      function handleButtonClick() {
        state = "hi";
        setState();
      }
    
      return (
        <main>
          <h1>{state}</h1>
          <button onClick={handleButtonClick}>hi</button>
        </main>
      );
    }
    • 리액트의 렌더링은 함수형 컴포넌트에서 반환한 결과물인 return의 값을 비교해 다른 경우에 커밋 단계 실행
    • 렌더링 시 함수를 실행하고, state가 초기화되므로 return의 결과물이 동일하여 렌더링 X
  • useState의 내부 구현 생각해보기

    const MyReact = function(){
        const global = {}
        let index = 0
    
        function useState(initialState) {
            // 애플리케이션 최초 접근 시, 전체 states 배열 초기화
            if (!global.states) {
                global.states = []
            }
    
            // state: 현재 상태값 확인, 없으면 초깃값 설정
            const currentState = global.states[index] || initialState
            global.states[index] = currentState
    
            // setter: index를 기억하는 즉시실행함수
            const setState = (function () {
                let currentIndex = index
    
                return function (value) {
                    // 상태 업데이트
                    global.states[currentIndex] = value
                    // 이후 컴포넌트 렌더..
                }
            })()
        }
    
        // 각 state마다 새로운 index 할당
        index += 1
    
        return [currentState, setState]
    
    }
    • 클로저를 활용해 함수형 컴포넌트를 매번 실행할 때 state 값을 유지하고 사용, 외부에 값을 노출시키지 않고 리액트 내에서만 사용 가능
  • 게으른 초기화(lazy initialization)

    const [count, setCount] = useState(() => Number.parseInt(window.localStorage.getItem(cacheKey)));
    • useState(함수)
    • useState의 초깃값이 복잡하거나 무거운 연산을 포함할 경우 사용
      • localStorage, sessionStorage에 대한 접근, 배열에 대한 접근(map, filter, find), 초깃값 계산을 위해 함수 호출이 필요한 경우
    • 오로지 state가 처음 만들어질 때 사용하며, 리렌더링 시 이 함수의 실행은 무시

3.1.2 useEffect

useEffect(() => {}, [props, state]);
  • 렌더링 때마다 의존성에 있는 컴포넌트의 값들을 활용해 동기적으로 부수 효과를 만드는 메커니즘

  • 어떤 상태값과 함께 실행(props, state)되는지가 중요

  • 첫 번째 인수로 부수 효과가 포함된 함수를, 두 번째 인수로 의존성 배열을 전달

  • 클린업 함수

    const [counter, setCounter] = useState(0);
    
    function handleClick() {
      console.log("event");
      setCounter((prev) => prev + 1);
    }
    
    // 렌더링마다 실행
    useEffect(() => {
      console.log("Effect 1");
    
      // 클린업 함수
      return () => {
        console.log("cleanup 1");
      };
    });
    
    // counter가 변경될 때마다 실행
    useEffect(() => {
      console.log("Effect 2");
      function addMouseEvent() {
        console.log(counter);
      }
    
      window.addEventListener("click", addMouseEvent);
    
      // 클린업 함수
      return () => {
        console.log("cleanup 2: ", counter);
        window.removeEventListener("click", addMouseEvent);
      };
    }, [counter]);
    
    return (
      <>
        <h1>{counter}</h1>
        <button onClick={handleClick}>+</button>
      </>
    );
    • 실행 결과

      // 초기 렌더링
      Effect 1
      Effect 2
      
      -
      // 리렌더링 (이벤트 발생)
      event
      cleanup 1
      cleanup 2: 0
      Effect 1
      Effect 2
      1
    • useEffect는 이전의 클린업 함수를 실행한 뒤에 콜백 실행

    • 함수형 컴포넌트 리렌더링 시, 의존성 변화가 있었을 이전의 값을 기준으로 실행되어, 이전 상태를 청소 해 주는 개념

  • 의존성 배열

    • []: 초기 렌더링 이후 실행

    • 값을 주지 않은 경우: 컴포넌트 렌더링 이후 매번 실행

      useEffect(() => {
        console.log("컴포넌트 렌더링");
      });
      • useEffect 사용의 의미

        // 직접 실행
        function Component() {
          console.log("렌더링됨");
        }
        
        // useEffect 사용
        function Component() {
          useEffect(() => {
            console.log("렌더링됨");
          });
        }
        1. useEffect는 클라이언트 사이드에서 실행되는 것을 보장
        2. useEffect컴포넌트의 렌더링이 완료된 이후에 실행되지만, 직접 실행은 컴포넌트가 렌더링되는 도중에 실행. 이 작업은 함수형 컴포넌트의 반환을 지연시키는 행위로, 성능에 악영향을 미칠 수 있다.
  • useEffect의 구현

    const MyReact = function () {
      // 컴포넌트에서 훅 실행 순서 보장
      const global = {};
      let index = 0;
    
      function useEffect(callback, dependencies) {
        const hooks = global.hooks;
    
        // 이전 훅 정보가 있는지 확인
        let previousDependencies = hooks[index];
    
        // 얕은 비교로 값을 비교해 변경 확인 ⭐️⭐️
        let isDependenciesChanged = previousDependencies
          ? dependencies.some((value, idx) => !Object.is(value, previousDependencies))
          : true;
    
        // 변경 됐으면 콜백 함수 실행
        if (isDependenciesChanged) {
          callback();
        }
    
        // 현재 의존성 다시 훅에 저장
        hooks[index] = dependencies;
    
        index += 1;
      }
    
      return { useEffect };
    };
    • 의존성 배열의 이전 값과 현재 값을 얕은 비교(Object.is)해 하나라도 변경 사항이 있으면 callback으로 선언한 부수 효과 실행
  • useEffect 사용 시 주의할 점

    • eslint-disable-line react-hooks/exhaustive-deps 주석은 최대한 자제하라
      • 해당 룰은 useEffect 인수 내부에서 사용하는 값 중 의존성 배열에 포함돼 있지 않은 값이 있을 때 경고를 발생
      • 빈 배열을 의존성 배열로 하는 경우
        useEffect(() => {
          console.log(props);
        }, []); // eslint-disable-line react-hooks/exhaustive-deps
        • 이 경우, 부수 효과가 state, props와 같은 어떤 값의 변경과 별개로 작동함을 의미
        • 정말로 의존성 []가 필요한지 생각해보고, 부모 컴포넌트에서 실행되는 것을 고려해보기
      • 특정 값을 사용하지만 해당 값의 변경 시점을 피하려면 메모이제이션 활용하거나 적당한 실행 위치를 다시 고민해봐야 한다.
  • useEffect의 첫 번째 인수에 함수명을 부여하라.

    useEffect(() => {
      function logActiveUser() {
        logging(user.id);
      }
    }, [user.id]);
    • useEffect의 코드가 복잡하고 많아질 경우, 기명 함수로 사용해 useEffect 목적을 파악하기 쉽도록 한다.
    • 이를 통해 useEffect의 목적을 명확히 하고 그 책임을 최소한으로 좁힌다.
  • 거대한 useEffect를 만들지 마라.

    • 렌더링 시 의존성이 변경될 때마다 부수 효과를 실행하므로 크기가 커질수록 애플리케이션 성능에 악영향
    • 가능한 한 useEffect는 간결하고 가볍게 유지하는 것이 좋다.
    • 적은 의존성 배열을 사용하는 여러 개의 useEffect로 분리
    • 의존성 배열에 여러 변수가 들어가야 하는 상황이라면 최대한 메모이제이션하여 정제한 내용들만 useEffect에 담아두기
  • 불필요한 외부 함수를 만들지 마라.

    useEffect(() => {
      const controller = new AbortController()(async () => {
        const result = await fetchInfo(id, { signal: controller.signal });
        setInfo(await result.json());
      })();
    
      return () => controller.abort();
    }, [id]);
    • useEffect 내에서 사용할 부수 효과라면 내부에서 만들어서 정의하는 편이 좋다.
  • 왜 useEffect의 콜백 인수로 비동기 함수를 바로 넣을 수 없을까?

    function Component() {
      useEffect(() = {
        let shoudlIgnore = false
    
        async function fetchData() {
          const response = await fetch('http://some.website.com')
          const result = await response.json()
    
          if (!shouldIgnore) {
            setData(result)
          }
        }
    
        fetchData()
    
        return () => {
          // setter 실행을 막을 수 있다.
          shouldIgnore = true
        }
      })
    
    }
    • useEffect의 인수로 비동기 함수가 사용 가능하다면 비동기 함수의 응답 속도에 따라 결과가 이상하게 나타날 수 있다. (useEffect의 경쟁 상태)
    • useEffect 내부에서 비동기 함수를 선언해 실행하거나, 즉시 실행 비동기 함수를 만들어 사용 가능
    • 비동기 함수가 내부에 존재하면 클린업 함수에서 이전 비동기 함수에 대한 처리를 추가하는 것이 좋다.
    • 비동기 useEffect는 state의 경쟁 상태를 야기할 수 있고 cleanup 함수의 실행 순서도 보장할 수 없기 때문에 개발자 편의 상 비동기 함수를 인수로 받지 않는다.

3.1.3 useMemo

const memoizedValue = useMemo(() => expensiveComputation(a, b), [a, b]);
  • 비용이 큰 연산에 대한 결과를 저장(메모이제이션)해두고 저장된 값을 반환하는 훅
  • 렌더링 발생 시 의존성 배열의 값이 변경된 경우 함수를 실행하여 값을 기억하고, 아닌 경우 이전에 저장해 둔 값을 반환

3.1.4 useCallback

  • 렌더링마다 특정 함수를 새로 만들지 않고 재사용해 불필요한 리소스나 리렌더링을 방지

    const ChildComponent = memo(({ name, onChange }) => {
      // 렌더링 확인
      useEffect(() => {
        console.log(`rendering: child ${name}`);
      });
    
      return (
        <>
          <h1>{name}</h1>
          <button onClick={onChange}>toggle</button>
        </>
      );
    });
    
    export default function App() {
      const [status1, setStatus1] = useState(false);
      const [status2, setStatus2] = useState(false);
    
      const toggle1 = () => {
        setStatus1(!status1);
      };
    
      const toggle2 = useCallback(() => {
        setStatus2(!status2);
      }, [status2]);
    
      return (
        <>
          <ChildComponent name="1" onChange={toggle1} />
          <ChildComponent name="2" onChange={toggle2} />
        </>
      );
    }
    • useCallback을 사용하면 해당 의존성이 변경됐을 때만 함수가 재생성
  • useCallback의 구현

    export function useCallback(callback, args) {
      currentHook = 8;
      return useMemo(() => callback, args); // 함수를 값으로 반환
    }
  • useCallback과 useMemo의 차이

    export default function App() {
      const [status1, setStatus1] = useState(false);
      const [status2, setStatus2] = useState(false);
    
      const toggle1 = useMemo(() => {
        return () => setStatus1(!status1); // 반환한 함수 값 자체를 메모
      }, [status1]);
    
      const toggle2 = useCallback(() => {
        // 인수로 받은 함수를 메모
        setStatus2(!status2);
      }, [status2]);
    
      return (
        <>
          <ChildComponent name="1" onChange={toggle1} />
          <ChildComponent name="2" onChange={toggle2} />
        </>
      );
    }

3.1.5 useRef

  • DOM에 접근하거나 렌더링을 발생시키지 않고 원하는 상태값을 저장

  • useState vs. useRef

    • 공통점

      • 컴포넌트 내부에서 렌더링이 일어나도 변경 가능한 상태값을 저장
    • 차이점

      • useRef는 반환값인 객체의 current 값에 접근/변경 가능

      • 값이 변해도 렌더링을 발생시키지 않는다.

        export default function UseRef() {
          const count = useRef(0);
        
          function handleClick() {
            console.log("current: ", count.current); // 0 1 2 3
            count.current += 1;
          }
        
          // 그대로
          return (
            <>
              <button onClick={handleClick}>{count.current}</button>
            </>
          );
        }
  • 함수 외부 값 선언 vs. useRef

    • 함수 외부에 값을 선언하면
      • 컴포넌트가 렌더링되지 않아도 값이 존재
      • 컴포넌트가 여러 번 생성될 때 가리키는 값이 동일
    • useRef
      • 컴포넌트가 렌더링 될 때만 생성
      • 컴포넌트 인스턴스가 여러 개라도 각각 별개의 값을 가리킨다.
  • 사용

    • DOM에 접근
      • useRef의 최초 기본값은 return문에 정의해 둔 DOM이 아니라 useRef로 넘겨받은 인수, 즉 undefined
    export default function UseRef() {
      const inputRef = useRef();
    
      // 렌더링 실행 이전이므로 undefined
      console.log(inputRef.current); // undefined
    
      // 렌더링 실행 이후
      useEffect(() => {
        console.log(inputRef.current); // <input type="text">
      }, [inputRef]);
    
      return <input ref={inputRef} type="text" />;
    }
  • 렌더링을 발생시키지 않고 원하는 상태값 저장

    • usePrevious 훅: useState의 이전 값 저장

      function usePrevious(value) {
        const ref = useRef();
      
        // value가 변경되면 그 값을 ref에 저장
        useEffect(() => {
          ref.current = value;
        }, [value]);
      
        return ref.current;
      }
      
      export default function Component() {
        const [counter, setCounter] = useState(0);
        const previousCounter = usePrevious(counter);
      
        function handleClick() {
          setCounter((prev) => prev + 1);
        }
      
        return (
          <>
            <p>
              {counter} {previousCounter}
            </p>
            <button onClick={handleClick}>Increase</button>
          </>
        );
      }
  • useRef 구현

    useRef(initialValue) {
    	currentHook = 5
      return useMemo(() => ({ current: initialValue }), [])
    • useMemo(값, [])을 통해 리렌더 시에도 동일한 객체를 바라보도록 구현

3.1.11 훅의 규칙

  1. 최상위에서만 훅을 호출. 반복문이나 조건문, 중첩된 함수 내에서 훅을 실행할 수 없다. 이를 통해 컴포넌트가 렌더링때마다 항상 동일한 순서로 훅이 호출되는 것을 보장.
  2. 훅을 호출할 수 있는 것은 리액트 함수형 컴포넌트, 혹은 사용자 정의 훅의 두 가지 경우.
  • 훅의 규칙의 의미
    • 훅에 대한 정보는 컴포넌트 내에서 객체 기반 링크드 리스트로 저장된다. 이렇게 고정된 순서로 이전 값에 대한 비교와 실행이 가능.
    • 훅의 순서가 깨지거나 보장되지 않을 경우 리액트 코드는 에러 발생
    • 따라서 훅은 절대 조건문, 반복문 등에 의해 리액트에서 예측 불가능한 순서로 실행해서는 안 되며, 컴포넌트 최상단에 선언

3.2 사용자 정의 훅과 고차 컴포넌트

  • 리액트에서 재사용 로직을 관리하는 방법 두 가지: 사용자 정의 훅(custom hook), 고차 컴포넌트(higher order component)

3.2.1 사용자 정의 훅

  • use로 시작하여, 내부에 리액트 훅을 사용하고, 리액트 훅의 규칙을 따라야 한다.

    function useFetch<T>(url: string, { method, body }: { method: string; body?: XMLHttpRequestBodyInit }) {
      // 응답 결과
      const [result, setResult] = useState<T | undefined>();
      // 요청 중 여부
      const [isLoading, setIsLoading] = useState<boolean>(false);
      // 2xx 3xx으로 정상 응답인지 여부
      const [ok, SetOk] = useState<boolean | undefined>();
      // HTTP status
      const [status, setStatus] = useState<number | undefined>();
    
      useEffect(() => {
        const abortController = new AbortController();
    
        (async () => {
          setIsLoading(true);
    
          const response = await fetch(url, {
            method,
            body,
            signal: abortController.signal,
          });
    
          setOk(response.ok);
          setStatus(response.status);
    
          if (response.ok) {
            const apiResult = await response.json();
            setResult(apiResult);
          }
    
          setIsLoading(false);
        })();
    
        return () => {
          abortController.abort();
        };
      }, [url, method, body]);
    
      return { ok, result, isLoading, status };
    }
    export default function App() {
      // data fetching
      const { isLoading, result, status, ok } = useFetch<Array<Todo>>("https://jsonplaceholder.typicode.com/todos", {
        method: "GET",
      });
    
      useEffect(() => {
        if (!isLoading) {
          console.log("fetchResult >> ", status);
        }
      }, [status, isLoading]);
    
      return (
        <>
          {ok
            ? (result || []).map(({ userId, title }, index) => (
                <div key={index}>
                  <p>{userId}</p>
                  <p>{title}</p>
                </div>
              ))
            : null}
        </>
      );
    }

3.2.2 고차 컴포넌트

  • 컴포넌트의 렌더링 결과물에 영향을 미치는 공통된 작업을 처리

  • with으로 시작하는 이름

  • 부수 효과를 최소화하도록 인수로 받는 컴포넌트의 props를 임의로 수정, 추가, 삭제하지 말아야 한다.

  • 여러 개의 고차 컴포넌트로 컴포넌트를 감쌀 경우 복잡성이 커질 수 있으므로 고차 컴포넌트는 최소한으로 사용하는 것이 좋다.

  • 자바스크립트의 일급 객체, 함수의 특징을 사용하므로 자바스크립트 환경에서 사용 가능

  • React.memo

    • props의 변화가 없는 경우 컴포넌트의 렌더링을 방지하기 위한 고차 컴포넌트
    • 렌더링 이전에 props를 비교해 이전 props와 같다면 렌더링 자체를 생략하고 이전에 기억해 둔 컴포넌트 반환
  • 고차 컴포넌트 사용해보기

    interface LoginProps {
      loginRequired?: boolean;
    }
    // 고차 컴포넌트: 컴포넌트를 받아 컴포넌트를 반환
    function withLoginComponent<T>(Component: ComponentType<T>) {
      // 인수로 받는 props는 그대로 사용
      return function (props: T & LoginProps) {
        const { loginRequired, ...restProps } = props;
    
        if (loginRequired) {
          return <>로그인이 필요합니다</>;
        }
    
        return <Component {...(restProps as T)} />;
      };
    }
    
    const Component = withLoginComponent((props: { value: string }) => {
      return <h3>{props.value}</h3>;
    });
    
    export default function App() {
      // 로그인 관련 정보
      const isLogin = true;
    
      return <Component value="text" loginRequired={isLogin} />;
    }

3.2.3 사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야 할까?

  • 중복된 로직을 분리해 컴포넌트의 크기를 줄이고 가독성을 향상

  • 사용자 훅이 필요한 경우

    • 단순히 동일한 로직으로 값을 제공하거나. 특정한 훅을 사용하려면 사용
    • 장점
      • 컴포넌트 내부에 미치는 영향을 최소화해 개발자가 훅을 원하는 방향으로만 사용
      • 부수 효과가 비교적 제한적
  • 고차 컴포넌트를 사용해야 하는 경우

    • 애플리케이션 관점에서 컴포넌트를 감추고 공통 컴포넌트를 노출하려는 경우: 에러 바운더리, 로그인 처리
    • 렌더링의 결과물에도 영향을 미치는 공통 렌더링 로직 처리.
    • 복잡성이 크게 증가하므로 신중하게 사용
  • 고차 컴포넌트를 사용해야 하는 경우

    • 애플리케이션 관점에서 컴포넌트를 감추고 공통 컴포넌트를 노출하려는 경우: 에러 바운더리, 로그인 처리
    • 렌더링의 결과물에도 영향을 미치는 공통 렌더링 로직 처리.
    • 복잡성이 크게 증가하므로 신중하게 사용

React 리스트 렌더링 시 key의 역할

Consider providing a default key for dynamic children · Issue #1342 · facebook/react

Preserving and Resetting State – React

  • key prop은 리액트의 재조정 고정에서 중요한 역할
    1. key는 성능보다는 고유한 값에 관한 것
      • key는 리액트가 리스트의 어떤 item이 변경, 추가, 삭제되었는지 인식하도록 함. 이는 특히 item의 순서가 변경될 수 있는 동적인 리스트의 경우 중요
      • 랜덤하게 배정되거나 변경되는 값은 리스트 item의 고유값이 될 수 없다.
    2. 재정렬과 성능
      • 리액트는 DOM을 새롭게 만드는 대신 현재 요소를 재배치할 수 있고, 이를 통해 불필요한 리렌더를 피한다.
    3. 부모 리렌더를 막는 것은 아님
      • key가 리스트 각 요소의 업데이트를 최적화하는 건 맞지만, 부모의 상태가 변경되었을 때 리렌더링을 막지는 않는다. key의 역할은 리스트 item 자체의 렌더링 과정을 최적화하는 것이지, 부모의 리렌더를 막는 것은 아니다.

리액트에서 상태 보존과 재설정

  • 상태는 상태를 정의한 컴포넌트 내부가 아닌 렌더 트리에 묶여 있다.
  • 즉, 상태는 리액트가 가지고 있고, 렌더 트리 상에서 컴포넌트의 위치에 따라 보유한 상태를 컴포넌트와 연결

같은 위치의 같은 컴포넌트는 상태를 보존한다.

import { useState } from "react";

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={(e) => {
            setIsFancy(e.target.checked);
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = "counter";
  if (hover) {
    className += " hover";
  }
  if (isFancy) {
    className += " fancy";
  }

  return (
    <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}>
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>Add one</button>
    </div>
  );
}

같은 위치에 다른 컴포넌트는 상태를 재설정한다.

import { useState } from "react";

export default function App() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? <p>See you later!</p> : <Counter />}
      <label>
        <input
          type="checkbox"
          checked={isPaused}
          onChange={(e) => {
            setIsPaused(e.target.checked);
          }}
        />
        Take a break
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = "counter";
  if (hover) {
    className += " hover";
  }

  return (
    <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}>
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>Add one</button>
    </div>
  );
}

같은 위치에 있는 상태 재설정하기

  • 기본적으로 리액트는 같은 위치에 있는 컴포넌트 상태를 보존
  • 같은 위치에 있는 상태를 재설정하고 싶다면?
    1. 컴포넌트를 다른 위치에 렌더링
    2. key를 활용해 상태 재설정
      • key는 리액트가 컴포넌트를 구분할 수 있도록 하는 장치
      • 리액트는 기본적으로 부모 아래에서 컴포넌트를 구분하기 위해 “첫 번째 counter”, “두 번째 counter”처럼 순서를 사용하지만, key는 “특정한 key를 가진 카운터” 인식
      • 특정한 key를 가진 카운터는 렌더 트리 어디에서 나타나더라도 리액트가 컴포넌트를 인식할 수 있다.
  • 제거된 컴포넌트의 상태를 유지하고 싶다면?
    • 모두 렌더하되 CSS로 필요한 UI만 보여주기
      • 숨기는 트리가 크다면 느려질 수 있음
    • 상태 끌어올리기
    • 다른 소스에 저장하기
      • localStorage
profile
어제보다 더 나은 오늘을 만들 수 있게

0개의 댓글