00. Hooks 정리하기

Hooks이란

클래스형 컴포넌트가 아닌 함수형 컴포넌트에서도 상태 관리 등 생명주기와 관련된 작업을 할 수 있게 해주는 기능이다.
종류로는

useState

가장 기본적인 Hook으로, 함수 컴포넌트 내에서도 가변적인 상태를 가질 수 있게 해준다.

만약 버튼을 눌렀을 때 숫자가 변해야 한다면, 숫자 값을 가지는 상태가 필요할 것이고,
증가 버튼을 눌렀을 때는 숫자가 늘어나며, 감소 버튼을 눌렀을 때는 숫자가 감소해야 할 것이다.

예를 들자면

// file: "UseStateExample.js"
import {useState} from "react"

const UseStateExample = () => {
  /**
   * 초기 값을 0으로 가지는, 상태 생성
   * useState(초기값)을 호출하면, 초기값이 있는 상태와, 상태를 변경하는 함수를 return한다
   * 즉 값은 num으로 들어가고 setNum은 이러한 num 값을 바꿀 때 사용하는 함수이다.
   */
  const [num, setNum] = useState(0);
  return (
    <div>
      <p>현재 num 값은 {num}</p>
      {/*
        버튼을 눌렀을 때 setNum이 호출되도록 하는 함수를 만든다.
      */}
      <button onClick={() => setNum(before => before + 1)}>숫자 1 증가</button>
      <button onClick={() => setNum(before => before - 1)}>숫자 1 감소</button>
    </div>
    
  )
  
}

export default UseStateExample;

이런 식으로 작성하여, 버튼을 눌렀을 때 setNum이 실행되는 함수를 호출하여, num이라는 state가 바뀌도록 한다.

onClick에는 실행시킬 함수를 넣어줘야 한다.
onClick={setNum(before => before + 1)}
위와 같은 코드는 setNum의 실행 결과를 onClick으로 준 것이지 함수를 넘겨준 것이 아님을 주의하자.

useEffect

컴포넌트가 렌더링될 때마다 특정 작업을 수행하도록 한다.

// file: "UseEffectExample.js"
import {useState, useEffect} from "react"

const UseEffectExample = () => {
  /**
   * useEffect는 렌더링 등 조건이 맞았을 때 실행할 함수와, 의존성 배열을 인자로 받는다.
   * 그리고 의존성 배열의 존재 유무와, 값에 따라 조금씩 실행 조건이 달라지게된다.
   * 
   * 첫번째로 만약 의존성 배열을 아예 설정하지 않았다면, 모든 렌더링과 리렌더링 과정마다 실행된다.
   * 두번째로 만약 빈 의존성 배열을 넣었다면, 맨 최초에 렌더링 될 때 1회만 실행된다.
   * 세번째로 만약 의존성 배열을 넣었다면, 최초 렌더링 될 때와 해당 의존성이 변경되어 리렌더링이 동작할 때 실행된다.
   * 세번째에서 중요한 것은 단순히 리렌더링이 되면 동작하는 것이 아니라 의존성으로 넣었던 것이 변경되어 리렌더링이 동작할 때 작동하는 것이다.
   */
  const [message, setMessage] = useState('초기 메세지입니다.');
  const [junk, setJunk] = useState('필요없는 상태입니다.');

  useEffect(() => {console.log("의존성 배열 자체를 전달하지 않았습니다.")});
  useEffect(() => {console.log("빈 의존성 배열을 전달했습니다.")}, []);
  useEffect(() => {console.log("의존성 배열을 전달했습니다")}, [message]);

  return (
    <div>
      <p>{message}</p>
      <p>{junk}</p>
      <div>
        <button onClick={() => {setMessage("메세지를 변경했습니다!")}}>useEffect의 의존 배열로 설정한 메세지를 변경하는 버튼입니다.</button>
        <button onClick={() => {setJunk("필요 없는 상태를 변경했습니다!")}}>의존성으로 넣어주지 않은 상태를 변경하는 버튼입니다.</button>
      </div>
    </div> 
  )
}

export default UseEffectExample;
  • useEffect 초기 화면 실행 시

    최초 렌더링 시 초기 렌더링 시에는 의존성 배열 설정 유무와 상관 없이 모든 useEffect의 함수가 실행된다.

  • 의존성과 관련 없는 상태 변경으로 인한 리렌더링 시

    의존성과 관련 없는 상태 변경으로 인한 리렌더링 시 의존성으로 설정한 것과 관련 없는 상태로 인한 리렌더링 시에는 의존성 배열 자체를 넘겨주지 않은 useEffect의 함수만 실행된다.
    즉 의존성 배열 자체가 비어 있는 useEffect와 변경된 상태와 의존성이 없는 useEffect는 실행되지 않는 것을 볼 수 있다.

  • 의존성으로 설정한 상태 변경으로 인한 리렌더링 시

    의존성으로 설정한 상태 변경으로 인한 리렌더링 시 의존성으로 설정한 것 상태로 인한 리렌더링 시에는 해당 상태에 의존되거나, 의존성 배열 자체를 넘겨주지 않은 useEffect의 함수만 실행된다.
    즉 의존성 배열 자체가 비어 있는 useEffect는 실행되지 않는 것을 볼 수 있다.

useReducer

useState로도 상태를 관리할 수 있지만

  • 다양한 이벤트 등에서 상태를 변경하는 작업이 있을 때
  • 변경이 복잡할 경우

위와 같은 경우 상태의 변화를 파악하기 위해서는 각 이벤트들을 전부 확인해야 한다.
또한 복잡한 상태 수정을 위한 코드가 길어지면 지저분해지는 문제가 있다.

이를 useReducer를 이용하여, 변경 관련 내용을 reducer에 작성하고, 각 이벤트에서는 업데이트 함수를 호출해주기만 하면 되므로,
상태가 어떻게 변하는지에 대한 파악이 용이하고 여러 곳에서 state 수정이 생기더라도 수정 관련 코드를 작성하지 않아도 되기 때문에, 코드가 간결해진다.

// file: "UseReducerExample.js"
import {useState, useEffect, useReducer} from "react"

const UseReducerExample = () => {
/**
 * useReducer는 state가 어떻게 업데이트 되어야 할 지 정의가 되어 있는지를 정의한 Reducer 함수와, 초기 state 값을 인자로 받아서,
 * 현재 state와 state를 새로운 값으로 업데이트 시키는 함수를 반환한다.
 * 
 * 이 때 업데이트 시키는 함수에는 오직 한 개의 인자만이 들어올 수 있다.
 * 
 * Reducer 함수는 반드시 현재 state, 업데이트 함수에 들어온 인자를 요구하는 함수로 작성해야 하며, 최종적으로 변경시킬 값을 return 하는 식이여야 한다.
 * 본 코드는 UseStateExample에서 useState 대신 useReducer를 이용하여 작성한 것이다.
*/

// 상태를 어떻게 업데이트 할 지 정의한 reducer 함수
const reducer = (state, action) => {
    switch(action){
      case 'Increase':
        return state+1
      case 'Decrease':
        return state-1
      default:
        return 0
    }
  }

  // reducer 함수를 useReducer의 첫번째로, 초기화 인자를 2번째 값으로 넘겨주면 현재 state와 업데이트를 호출하는 dispatch 함수가 반환된다.
  const [num, dispatch] = useReducer(reducer, 0)
  return (
    <div>
      <p>현재 num 값은 {num}</p>
      {/* 이렇게 dispatch를 호출할 때 안에 인자를 넣으면 reducer의 2번째 인자로 값이 들어온다.*/}
      <button onClick={() => dispatch('Increase')}>숫자 1 증가</button>
      <button onClick={() => dispatch('Decrease')}>숫자 1 감소</button>
    </div>
  )
}

export default UseReducerExample;

다만 만약 초기화를 할 때 함수를 호출하여서 해야 한다면, 2 번째 인자에 넣는 것은 고려해봐야 할 부분이다.

// file: "UseReducerOptimization.js"
function createInitialState(username) {
  const initialTodos = [];
  for (let i = 0; i < 50; i++) {
    initialTodos.push(
      username + (i + 1)
    );
  }
  return initialTodos;
}

const [optimizer, opt] = useReducer(reducer, createInitialState("a"))

만약 위와 같은 방식으로 초기화를 진행하게 된다면, 매 렌더링 시마다 createInitialState가 호출되기 때문이다.

물론 초기 렌더링 시에만 createInitialState 함수를 실행한 값이 반영이 되겠지만,
이 값을 단순히 초기화에 사용될 값으로만 인식하고, 초기 렌더링 시에만 평가해야 한다라고 인식하지는 않기 때문에,
매 렌더링 시마다 두 번째 인자로 제공된 것을 렌더링 시마다 평가한다.

그래서 2 번째 인자로 함수의 결과를 넘겨주게 되면 매 렌더링 시마다 해당 함수를 호출하는 불필요한 낭비가 발생하고,
이를 막기 위해서는 초기화 함수를 3 번째 인자로 넘겨주는 것이다.

useReducer는 인자로 Reducer 함수, 초기 값, 초기화 함수 이렇게 3가지를 받는다.

따라서 위 코드를 아래와 같이 바꾼다면 함수가 매번 호출되지 않고, 초기 렌더링 시에만 호출되어 불필요한 호출을 줄일 수 있다.

// file: "UseReducerOptimization.js"
function createInitialState(username) {
  const initialTodos = [];
  for (let i = 0; i < 50; i++) {
    initialTodos.push(
      username + (i + 1)
    );
  }
  return initialTodos;
}

/* 
  이렇게 호출하면 createInitalState의 인자로 "a"가 들어가서 실행된 값이 초기 값으로 결정되고,
  초기 렌더링 이후 렌더링이 발생하더라도 다시 호출되지는 않는다.
*/
const [optimizer, opt] = useReducer(reducer, "a", createInitialState)

useMemo

useMemo는 계산 결과를 캐싱할 수 있게 하는 Hook이다.
재렌더링 시마다 원인과 관련이 없어 결과가 똑같음에도 함수 호출이 반복되어 불필요한 연산이 발생할 수 있다.
이를 의존성을 등록하여, 의존성이 바뀌었을 때만 해당 함수를 호출하도록 하여 최적화를 진행할 수 있다.

  • useMemo 사용 전

    // file: "UseMemoOptimizationBefore.js"
    
    // 10000번의 반복문을 돌아가면서 숫자 정보를 담은 간단한 객체를 만드는 함수이다.
    function retNumList(){
      console.log("NumList 갱신이 시작됩니다.")
      const list = [];
      for(let i = 0; i < 10000; i++){
        list.push({
          id: i,
          value: i
        });
      }
      return list;
    }
    
    
    const UseMemoExample = () => {
      // retNumList를 호출하여 배열을 받아온다.
      // useMemo를 사용하지 않았기에, 재렌더링시마다 retNumList가 호출될 것이다.
      const list = retNumList();
    
      // 재렌더링을 만들기 위한 아무 state를 만들어보았다.
      const [input, setInput] = useState('');
    
      return (
        <div>
          {/*
            input을 만들고서, input의 값이 바뀔때마다 상태가 바뀌도록 하였다.
            이를 통해 상태가 바뀌면 재렌더링이 일어나도록 하였다.
          */}
          <input value={input} onChange={(e) => {setInput(before => e.target.value)}}/>
          <ul>
            {/*
              리스트에 있는 내용을 바탕으로 하위 요소를 만들어주었다.
            */}
            {list.map(element => {
              return (
              <li key={element.id}>
                {element.value}
              </li>
              )
            })}
          </ul>
        </div>
          
      )
        
    }
    
    export default UseMemoExample;
    

    위 코드의 경우 useMemo를 이용하지 않았다.

    결과적으로 list랑 전혀 관련이 없는 input의 상태가 바뀌더라도 retNumList가 매번 호출되는 것을 알 수 있다.
    만약 함수가 가볍다면 크게 지장은 없을 수 있겠지만 예시처럼 함수가 작업량이 많아 가볍지 않은 경우에는 상당한 성능 저하가 있을 수 있다.

    실제로 input값이 변할때마다 retNumList내의 console log가 호출되는 것을 통해 함수의 불필요한 호출을 확인할 수 있다.

    useMemo최적화전 초기 렌더링 이후, list와 전혀 상관 없는 input에 값이 입력될 때에도 retNumList가 호출되는 것을 콘솔에서 확인할 수 있다.

  • useMemo 사용 후

    만약 useMemo의 의존성 배열을 아예 넣어주지 않는다면, 쓰지 않은 것과 동일하다. 즉 매 리렌더링마다 호출된다.
    그리고 빈 배열을 넣어주었다면, 초기 렌더링 시에만 호출되고 그 이후에는 호출되지 않는다. 이런 면은 useEffect와 동일하다.

    // file: "UseMemoOptimizationAfter.js"
    
    const UseMemoExample = () => {
    /**
     * useMemo는 계산 결과를 캐싱할 수 있게 해주는 Hook이다
     * 만약 이를 쓰지 않는다면 재렌더링시마다 똑같음에도 함수 호출이 반복되어 불필요한 연산이 발생할 수 있다.
     * 이를 의존성을 등록하여, 의존성이 바뀌었을 때만 호출하도록 하여 최적화를 할 수 있다.
     */
    const [input, setInput] = useState('');
    
    // retNumList와 관련은 없지만 useMemo 설명을 위한 의존성으로 등록할 state
    const [dependency, setDependency] = useState(0);
    
    // useMemo의 두 번째 인자로 의존성으로 넣어준 배열 내의 값들이 바뀌면 첫 번째 인자로 넣어준 함수가 실행된다.
    // 예시를 위해서 관련은 없지만 dependency를 의존성으로 등록했고, 이것이 바뀔 때마다 retNumList가 재실행되어 list가 갱신된다.
    const list = useMemo(() => retNumList(), [dependency]);
      
    return (
      <div>
        <input value={input} onChange={(e) => {setInput(e.target.value)}}/>
        <div>
          <p>
            dependency를 호출한 횟수 : {dependency}
          </p>
          <button onClick={() => setDependency(num => num+1)}>이걸 누르면 memo의 dependency가 수정되어 list가 재계산됩니다.</button>
        </div>
        <ul>
          {list.map(element => {
            return (
            <li key={element.id}>
              {element.value}
            </li>
            )
          })}
        </ul>
      </div> 
    )
    }
    

    중복되는 retNumList 코드는 생략했다.

    이번에는 list를 useMemo를 호출하여 받았다.
    useMemo에는 첫 번째로 계산 시 실행할 함수와 의존성 배열을 넣어주었다.
    이를 통해 의존성 배열 내의 값들 중 변한 것이 있을 때마다 함수가 호출되어 list가 갱신된다.

    그렇기 때문에 기존의 input 값이 바뀌어 재렌더링이 일어나더라도 의존성과는 상관없는 값이기 때문에 retNumList는 호출되지 않는다.
    다만 의존성으로 등록한 dependency의 경우 바뀔 때마다 재호출되게 된다.

    본 예시에서는 retNumList와 관련 없는 값을 의존성으로 등록하였지만,
    실제로 함수에 관련있는 의존성 있는 값들을 의존성으로 넣은 useMemo를 호출한다면 최소한으로 함수를 호출하여 최적화를 할 수 있다.

    useMemo최적화후 input이 5번이나 변경되었지만 retNumList는 dependency를 변경했을 때인 2번만 호출되는 것을 확인할 수 있다.

useCallback

useCallback은 함수 자체를 캐싱하는 것이다.
useMemo랑 헷갈릴텐데 useMemo는 함수의 실행 결과를 캐싱하는 것이고,
useCallback은 함수를 새로 만들지 않고 캐싱하는 것이다.

React에서 컴포넌트의 렌더링이 일어날 때마다 함수를 새로 만들게 된다.
실행하는 것도 아니라 그렇게 성능에 영향이 있는지 의문을 가질 수 있지만,
자식 컴포넌트와 관련이 있을 때 진가가 드러난다.

기본적으로 부모 컴포넌트에서 리렌더링이 일어날 경우 하위의 자식 컴포넌트들도 리렌더링이 일어나게 된다.
React.memo를 사용한다면 자식 컴포넌트는 전혀 상관이 없을 때, 자식 컴포넌트는 리렌더링의 대상에서 제외되지만,
함수의 경우 기본적으로 리렌더링이 될 떄마다 새로 만들어지고, 즉 주소값 등이 달라지기 때문에,
함수를 자식 컴포넌트의 props로 넘기는 경우, 리렌더링이 되면 새로 만들어지면서 다른 함수로 인식하여 자식 컴포넌트 또한 리렌더링이 일어나게 된다.
이를 막는 것이 핵심이다.

  • useCallback 사용 전

    // file: "useCallback 사용 전"
    const UseCallbackParentExample = () => {
      // dependency랑 관련이 없는 dummy State로, useCallback의 함수가 input으로 인해 리렌더링이 일어나더라도 재생성되지 않는 것을 확인하기 위함
      const [input, setInput] = useState('');
    
      // useCallback 설명을 위한 의존성으로 등록할 state
      const [dependency, setDependency] = useState(0);
    
      function noneCacheFunction() {
        console.log("자식이 갱신됩니다! dependency는 " + dependency + "입니다")
      }
    
      return (
        <div>
          <input value={input} onChange={(e) => {setInput(e.target.value)}}/>
          <div>
            <p>
              dependency를 호출한 횟수 : {dependency}
            </p>
            <button onClick={() => setDependency(num => num+1)}>이걸 누르면 dependency가 수정되어 자식 컴포넌트가 갱신됩니다.</button>
            {/*
              useCallback을 사용하지 않은 함수를 props로 넘겨주었다.
              이렇게 될 경우 부모에서 리렌더링이 일어나면 함수가 항상 다시 만들어져
              props가 변경된 것으로 인지해 자식 컴포넌트 또한 리렌더링이 일어날 것이다.
            */}
            <UseCallbackChildExample onEvent={noneCacheFunction}/>
          </div>
        </div>
      )
    }
    

    위 코드의 경우 useCallback을 사용하지 않았으므로, 부모 컴포넌트의 리렌더링 원인과 상관없이 항상 리렌더링 시마다 새로운 함수가 생성되게 된다.
    그렇기 때문에 props가 변경됐다고 판단하여 자식 컴포넌트 또한 항상 리렌더링 되게 된다.

    useCallback 사용 전 function과 전혀 관련 없는 input이 바뀔 떄도 자식이 리렌더링 되는 것을 볼 수 있다.

  • useCallback 사용 후

    const UseCallbackParentExample = () => {
      const [input, setInput] = useState('');
    
      // useCallback 설명을 위한 의존성으로 등록할 state
      const [dependency, setDependency] = useState(0);
    
      // dependency 라는 의존성이 바뀌기 전까지는 cacheFunction을 새로 만들지 않고 기존에 있는 것을 이용한다.
      const cacheFunction = useCallback(() => {
        console.log("자식이 갱신됩니다! dependency는 " + dependency + "입니다")
      }, [dependency])
    
      return (
        <div>
          <input value={input} onChange={(e) => {setInput(e.target.value)}}/>
          <div>
            <p>
              dependency를 호출한 횟수 : {dependency}
            </p>
            <button onClick={() => setDependency(num => num+1)}>이걸 누르면 dependency가 수정되어 자식 컴포넌트가 갱신됩니다.</button>
            {/*
              자식 컴포넌트의 props로 dependency를 의존하는 함수를 넘겨주었다.
              input은 관련 없기에 input으로 인한 리렌더링 시에는 재호출되지 않지만,
              dependency로 인한 변경 시에는 자식 컴포넌트 또한 리렌더링 될 것이다.
            */}
            <UseCallbackChildExample onEvent={cacheFunction}/>
          </div>
        </div>
      )
    

    위 코드의 경우 useCallback을 이용하여 함수를 만들었다.

    의존성으로 dependency를 넣어주었기 때문에 dependency가 변경될 때에만 함수를 새로이 생성한다.
    그렇기에 input이 변하여 리렌더링이 발생하더라도 cacheFunction은 새로 생성되지 않는다,
    자식 컴포넌트 또한 cacheFunction이 동일하여 props가 변경되지 않았기 때문에 리렌더링이 발생하지 않는다.

    useCallback 사용 후 input이 변하더라도 자식 컴포넌트는 리렌더링 되지 않고, 오직 dependency가 변했을 때에만 리렌더링 되는 것을 볼 수 있다.

    useCallback 또한 다른 것들과 마찬가지로
    의존성 배열을 생략했을 경우는 매 렌더링 시마다,
    의존성 배열이 비어 있을 경우는 초기 렌더링시에만,
    의존성 배열 내 값이 있을 경우는 해당 의존성이 변경 됐을 때에만 새로 함수를 생성하게 된다.

useRef

useRef는 크게 두 가지 용도로 쓰인다.

  1. 렌더링과 상관없는 로컬 변수로 사용할 때
  2. DOM을 조작할 때

다만 useRef는 주의할 점이 있다.

ref는 렌더링과 관련 있어서는 안된다.

useRef는 값이 변경되더라도 리렌더링을 일으키지 않기 때문에, useRef 값이 렌더링과 관련될 경우,
Input 값에 따라 Output이 동일한 것이 보장되지 않기 때문에 컴포넌트가 순수하게 유지되지 않는다.

그렇기 때문에 useRef의 값은 바뀌어도 리렌더링이 되지 않는 만큼 렌더링과 관련된 것이라면 useState를 사용해야 하는 등 주의해야 한다.

  • 렌더링과 상관없는 로컬 변수

    // file: useRefExample.js
    import {useRef, useState} from "react"
    
    const UseRefExample = () => {
      /**
      * useRef는 값이 변해도 리렌더링이 되지 않는다.
      * 따라서 렌더링과 상관없는 값인 경우는 state가 아니라 useRef로 선언하여 관리한다
      * 렌더링과 상관이 없다는 말은 클릭 이벤트와 같은, 특정 상황의 경우 발발하는 등 기본적으로 렌더링 되는 것이 아닌 것을 의미한다.
      * 
      * useRef로 선언한 값을 렌더링 범위 내에서 읽으려고 하거나, 쓰는 행위는 옳지 않다.
      */
      const num = useRef(0);
    
      function doSomething(){
        // 특정 이벤트가 발발했을 때 ref를 넘겨서 사용하는 것은 괜찮다.
        console.log(num)
    
        num.current += 1
      }
    
      return (
        <div>
          {/* num.current = 10 과 같은 ref 값 수정을 렌더링 내에서 하는 것은 올바르지 않다. */}
          {/* num.current와 같은 ref 값 읽는 행위를 렌더링 내에서 하는 것은 올바르지 않다. */}
    
          <button onClick={() => doSomething()}>useRef  콘솔에 출력</button>
        </div> 
      )
    }
    
    export default UseRefExample;
    

    위 예시처럼 useRef 생성 시 초기 값을 넣고서 이를 사용할 수 있다.

    주석으로 작성된 위 코드 예시처럼 렌더링 범위 내에서 useRef의 값을 읽거나 쓰는 행위가 있어서는 안된다.
    다만 특정 Event 발생 시 같은 경우에는 사용할 수 있다.

    본 예시에서도 click시 실행할 이벤트 함수 내에서 useRef를 사용함으로써 렌더링과는 상관 없도록 사용하였다.

  • DOM 조작

    // dom 조작을 위해 ref를 생성할 경우에는 초기값을 null로 설정한다.
    const dom = useRef(null);
    
    function focus(){
      // ref객체.current.focus()를 하면 ref객체가 가리키고 있는 DOM에 focus가 간다.
      dom.current.focus();
    }
    
    return (
      <div>
        {/* 
            조작하고자 하는 DOM의 ref 속성에 useRef로 만들었던 ref객체를 전달한다.
            DOM 노드를 화면에 그린 후 ref 객체의 current를 DOM 노드로 설정한다.
            본 예시에서는 input이 될 것이다.
        */}
        <input ref={dom}/>
        <button onClick={() => focus()}>input element로 focus가 옮겨집니다.</button>
      </div> 
    )
    

    ref를 DOM 조작으로 사용할 때에는 초기값을 null을 주고서,
    조작하고자 했던 DOM의 ref 값으로 useRef를 통해 만든 ref객체를 넘겨준다.

    이렇게 되면 ref객체의 current 값이 해당 DOM으로 설정되어, ref객체.current.focus()를 실행할 경우 해당 DOM으로 focus가 간다.


© 2025. Na2te All rights reserved.