Custom Hooks

커스텀 훅을 알기 전에 기본적으로 Hook의 사용 규칙에 대해 아는게 좋습니다.

  • 훅은 무조건 use로 시작해야 합니다.
  • 훅은 컴포넌트의 최상단에 위치해야 합니다.
  • 최상위 레벨에서만 훅을 호출해야 합니다.
    • 조건문이나 반복문 내부에서 Hook을 호출하면 안됩니다.
    • 조건부 return문(early return) 이후에 Hook을 호출하면 안됩니다.
    • 이벤트 핸들러에서 Hook을 호출하면 안됩니다.
    • useMemo, useReducer, useEffect에 전달된 함수 내부에서 Hook을 호출하면 안됩니다.
    • try/catch/finally 블록 내부에서 Hook을 호출하면 안됩니다.
  • 오직 React 컴포넌트 또는 커스텀 훅에서만 호출해야 합니다.

훅의 규칙 핵심 원리

React는 훅이 항상 동일한 순서로 호출된다고 가정하고, 각 훅의 상태를 내부적으로 인덱스로 관리합니다. 이런 상황에서 조건문이나 반복문 내부에서 호출한다면 그 순서가 바뀌게 되고, 상태와 이펙트가 꼬이며 에러가 발생할 수 있습니다. 그래서 순서가 보장되도록 조건문이나 반복문 내부에서 훅을 호출하면 에러가 발생하지 않지만, 의미가 없는 코드이기에 고려하지 않아도 됩니다. 

이런 이유로 훅은 컴포넌트의 최상단에 위치해야 하고 최상위 레벨에서 호출해야 하는 것입니다.

 

그리고 커스텀 훅은 state 자체를 공유하는 것이 아니라 state 저장 로직을 공유하도록 해야 합니다.

이는 커스텀 훅 구현 코드를 보면 이해하기 쉬울 거 같습니다.

 

커스텀 훅은 로직 재사용성을 극대화하는 React의 핵심 기능입니다. React 19에서도 해당 기능이 강화되어 깔끔하고 확장 가능한 코드 구조를 구축할 수 있습니다.

이러한 커스텀 훅은 상태를 공유하지 않고 로직만 재사용해야 하며, React 훅의 규칙을 따릅니다.

 

예시 코드를 작성하며 설명드리겠습니다.

<예시 코드 1>

import { useState } from 'react';

function ueFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e) => {
    setValue(e.target.value);
  }
  
  return {value, onChange: handleChange};
}

export default function From () {
  const nameProps = useFormInput('Join');
  const emailProps = useFormInput('JoinMain@naver.com');
  
  return (
    <form>
      <input {...nameProps} />
      <input {...emailProps} />
    </form>
  );
}

해당 코드는 input에 들어갈 상태와 이벤트 핸들러를 훅으로 묶어서 바로 input 태그에 props로 내려줄 수 있도록 객체를 반환하는 훅입니다.

import { useState } from 'react';

function ueFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e) => {
    setValue(e.target.value);
  }
  
  return [value, handleChange];
}

export default function From () {
  const [name, handleNameChange] = useFormInput('Join');
  
  return (
    <form>
      <input value={name} onChange={handleNameChange} />
    </form>
  );
}

만약에 값을 return 값을 배열로 보낸다면 그 순서를 기억하고 원하는 변수를 정의해 사용할 수도 있습니다.

 

 

<예시 코드 2>

import { useState } from 'react';

export default fucntion Counter () {
  const [count, setCount] = useState(0);
  
  const handlePlusClick = () => setCount(count => count + 1)
  const handleMinusClick = () => setCount(count => count - 1)
  
  return (
    <div>
      <p>Count : {count}</p>
      <button onClick={handlePlusClick}>+ 버튼</button>
      <button onClick={handleMinusClick}>- 버튼</button>
    </div>
  )
}

그럼 지금까지 애용하던 Counter 컴포넌트에는 어떻게 적용할 수 있을까요?

useState와 이벤트 핸들러 함수를 묶어서 useCounter 훅을 구현해 봅시다.

 

import {useState} from 'react';

function useCounter (initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  increment = () => setCount(prev => prev + 1);
  decrement = () => setCount(prev => prev - 1);

  return {count, increment, decrement};
}

export default function Counter () {
  const { count, increment } = useConter(0);
  
  return (
    <div>
      <p>Count : {count}</p>
      <button onClick={increment}>+ 버튼</button>
    </div>
  );
}

useCounter 훅을 구현해 적용한 코드입니다.

하지만 모든 중복 로직을 커스텀 훅으로 만드는 것은 좋은 방법이 아닙니다. 중복 로직을 커스텀 훅으로 만들었을 때, 데이터 흐름을 명확하게 이해하는 데 도움이 되고, 목적을 전달하는 데 도움이 된다고 판단될 경우 커스텀 훅으로 구현하는 것이 좋습니다.

 

커스텀 Hook으로 로직 재사용하기 – React

The library for web and native user interfaces

ko.react.dev

 

 

실무에서는 어떻게 활용하는게 제일 좋을까요?? 

  • 기능을 직관적으로 표현하여 의도 명확성을 높이자 (ex: useLocalStorage, useDebounde)
  • 단일 책임 원칙을 지키자! 한 훅은 하나의 기능만 수행하도록 설계해야합니다.
  • TypeScript의 제네릭 타입을 활용해 타입을 지원하여 타입 안정성을 확보하자
  • 불필요한 리렌더링 방지를 위해 메모이제이션을 적극 활용하자

핵심적으로 이렇게 4가지를 잘 생각해서 커스텀 훅을 고려해야 합니다. 그 사례들에 대해 알아볼까요?

 

1. 로컬 스토리지를 연동한 useLocalStorage

function useLocalStorage (key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringfy(value));
  }, [key, value]);
  
  return [value, setValue];
}

 

2. 이전 상태 추적 usePrevious

function usePrevious (value) {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = value;
  });
  
  return ref.current;
}

 

3, 디바운싱 적용 useDebounce

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouned;
}

 

이렇게 여러 작업 로직들을 커스텀 훅으로 묶어서 편리하게 사용할 수 있습니다.

 

추가적으로 필수적인 TypeScript를 이용해 구현하는 방법도 작성하고자 합니다. 주로 TypeScript 기반으로 개발을 해서 이번 React 포스팅 과정에서 중간중간에 TypeScript를 사용한 코드도 있습니다..ㅋㅋ 다 작성한 이후에 통일할려고 합니다. js 기반인 React 공식문서를 보며 정리해서 그런지 섞이게 되네요ㅠㅠ 어쨌든 타입 안정성을 보장하는 TyprScript를 이용한 방법도 커스텀 훅을 만드는 과정에서 꼭 설명해야겠다는 생각이 들어 작성합니다.

 

1. TypeScript useLocalStorage

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.getItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue] as const;
}

// 사용 예시
export default Example () {
  const [name, setName] = useLocalStorage<string>('name', '홍길동');
  return (
    <input value={name} onChange={e => setName(e.target.value)} />
  );
}

제네릭 타입을 이용해 값에 대한 타입을 정의할 수 있도록 했습니다. 그리고 return 값에 as const를 붙여 튜플 타입으로 반환하여 구조분해 할당 측면에서 안전해집니다.

 

2. TypeScript useDebounce

import {useState, useEffect} from 'react';

function useDebounce<T>(value: T, delay: number) {
  const [debounced, setDebounced] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debounced;
}

export function Example () {
  const [text, setText] = useState('');
  const debouncedText = useDebounce(text, 500);
  
  return (
    <div>
      <input value={text} onChange={e => setText(e.tartget.value)} />
      <p>디바운스된 값: {debouncedText}</p>
    </div>
  );
}

 

3. TypeScript useToggle

import { useState, useCallback } from 'react';

type UseToggleReturn = [
  boolean,
  () => void,
  () => void,
  () => void
];


function useToggle(initialValue = false): UseToggleReturn {
  const [state, setState] = useState<boolean>(initialValue);
  
  const toggle = useCallback(() => setState(s => !s), []);
  const setTrue = useCallback(() => setState(true), []);
  const setFalse = useCallback(() => setState(false), []);
  
  return [state, toggle, setTrue, setFalse];
}

export default function Example() {
  const [on, toggle, turnOn, turnOff] = useToggle();

  return (
    <div>
      <p>{on ? 'ON' : 'OFF'}</p>
      <button onClick={toggle}>Toggle</button>
      <button onClick={turnOn}>ON</button>
      <button onClick={turnOff}>OFF</button>
    </div>
  );
}

컴포넌트가 리렌더링 될때마다 함수를 새로 생성하는 것을 방지하고자 useCallback을 사용했습니다.

 

이렇게 타입스크립트를 적용한 예시 코드까지 확인해 봤습니다! 커스텀 훅을 구현할 때 도움이 되면 좋겠네요 :)

다음 포스팅부터 React 19에 새로 생긴 훅이나 기능들을 본격적으로 다루지 않을까 싶습니다. 읽어주셔서 감사합니다!

useRef

useRef를 간단하게 이야기 하자면 화면을 바꾸지 않는 데이터라고 할 수 있습니다. useRef는 값이 변경되어도 화면이 리렌더링되지 않습니다. React에서는 document를 이용해 DOM 요소에 접근하는 방식을 비추천하기 때문에 document를 이용하지 않고 DOM 요소에 쉽게 접근을 하기 위해 useRef를 사용합니다.

 

document 객체 사용을 지양하는 이유는 document를 이용하면 DOM을 직접 조작하여 화면을 변경할 수 있는데, 이렇게 되면 화면 변경에 대해서 React를 통해 화면을 바꾸는 것과 document로 DOM을 직접 조작하여 바꾸는 것 이렇게 두 가지로 나뉘어 관리하기가 매우 어려워집니다.

 

하지만 document를 이용하지 않는다면, 원하는 요소를 focus하고 스크롤을 옮기는(scrollIntoView) 등의 편리한 작업을 하기 어렵워집니다. 그래서 React에서 useRef를 제공하는 것입니다. document.getElementById나 document.querySelector 등을 이용해 요소를 가져오는 것을 원하는 요소의 ref 속성에 useRef로 정의한 값을 넣어주고 .current로 그 요소를 바로 가져올 수 있도록 해 줍니다.

 

그런데 useRef를 사용하면 왜 하면이 리렌더링 되지 않을까요? useRef는

const idRef = useRef(null)

이런식으로 사용하는데, 이때 null은 내부 속성인 current에 null을 넣어주는 것입니다. 즉 객체인 것이죠 useRef의 값이 바뀌어도 객체 내부 메소드인 current가 변경되는 것이기 때문에 참조가 바뀌지 않고, 얕은 복사를 이용하는 React 특성상 감지하지 못하는 것입니다.

 

그리고 useRef는 이렇게 요소를 가져오기 위한 방법 뿐만 아니라 컴포넌트가 언마운트되기 전까지 값을 보존하기 때문에 직접 값을 넣어줘서 로깅용으로 사용하거나 UI와 무관한 값을 저장 및 라이브러리를 사용할 때 사용하기도 합니다.

 

사용 예시들을 보면 쉽게 이해하실 수 있습니다.

 

1. DOM 요소 포커싱

import { useRef } from 'react';

function LoginForm () {
	const emailRef = useRef(null);
    
    const handleSubmit = () => {
    	if (!emailRef.current.value.includes('@')) {
        	emailRef.current.focus(); // 유효성 검사 실패 시 포커싱
            return;
        }
        // ...
    }
    
    return (
    	<form>
          <input ref={emailRef} type='email' />
          <button>로그인</button>
        </form>
    )
}

 

2. 애니메이션 / 미디어 제어

function VideoPlayer () {
	const videoRef = useRef(null);
    
    const play = () => videoRef.current.play();
    const pause = () => videoRef.current.pause();
    
    return (
    <div>
      <video ref={videoRef} src="video.mp4" />
      <button onClick={play}>재생</button>
      <button onClick={pause}>일시정지</button>
    </div>
    )
}

 

3. 이전 state 값 추적

fucntion Counter () {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef(0);
  
  useEffect(() => {
    prevCountRef.current = count; // 이전 값 저장
  }, [count]);
  
  return (
    <div>
      현재 값: {count}, 이전 값: {prevCountRef.current}
    </div>
  );
}

 

4. 라이브러리 연동

DOM을 직접 조작하는 외부 라이브러리 (D3.js, Chart.js, Fabric.js 등)와 통합할 때 필수적으로 사용합니다.

function Chart() {
  const chartRef = useRef(null);
  
  useEffect(() => {
    //D3.js로 차트 생성
    const svg = d3.select(chartRef.current);
    svg.append("circle").attr("r", 50);
  },[]);
  
  return <svg ref={chartRef} />
}

 

 

그리고 React 라이프사이클 동안 값을 유지하는 useRef를 이용해 클로저 문제를 해결하기도 합니다.

 

클로저 문제

  • 함수형 컴포넌트에서 비동기 콜백(setTimeout, setInterval 등)을 사용할 때,
  • 콜백이 만들어진 시점의 state, props, 변수 값이 캡처되어 나중에 실행해도 그 값만 참조하게 됩니다.
  • 즉, 최신 값이 아닌 옛날 값을 참조하며, 원하는 작업을 수행할 수 없는 문제가 발생하는 것입니다.
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setTimeout(() => {
    alert(count)
  }, 1000);
  
  return () => clearTimeout(timer); // 메모리 누수 방지를 위해 언마운트시 clear
},[]);

위와 같이 코드를 작성하면 클로저 문제가 발생합니다.

※ 사실 count를 의존성 배열에 추가하면 클로저 문제가 발생하지 않지만, 사용 방식을 쉽게 보여주고자 해당 코드에서 useRef를 활용해 클로저 문제를 해결하는 방식을 보여드리겠습니다. 이후 실질적으로 useRef를 사용해야 해결할 수 있는 클로저 문제 발생 코드 사례도 작성할 예정입니다.

 

const [count, setCount]  useState(0);
const countRef = useRef(count);

// count의 값이 변경될 때마다 countRef에 최신값 반영
useEffect(() => {
  countrRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setTimeout(() => {
    alert(countRef.current); // countRef를 이용해 최신값 불러오기
  }, 1000);
  
   return () => clearTimeout(timer);
},[]);

클로저 문제가 발생했을 경우 위와 같이 countRef를 만들고 최신 값을 계속 갱신하여 setTimer에서 countRef의 current를 불러오면 됩니다. 하지만 해당 코드의 경우 setTimeout이 있는 useEffect의 의존성 배열에 count를 추가하면 해결되는 간단한 문제여서 실질적으로 useRef가 필요한 클로저 문제 발생 코드와 어떻게 해결하면 되는지에 대한 사례도 바로 작성하겠습니다.

 

 

클로저 문제 발생 코드

import { useState, useEffect } from 'react';

function App () {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const handleResize = () => {
      console.log('창 크기 변경된 횟수 :', count); // 클로저 문제 발생 (최초 count만 불러와짐)
    }
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div>
      <p> Count: {count} </p>
      <button onClick={() => setCount(count + 1)}> + </button>
    </div>
  );
}

해당 코드는 이벤트 핸들러가 최초 count만 기억하고 불러오기 때문에 count가 변화되어도 창 크기 변경 시 항상 0만 출력이 됩니다. 의존성 배열에 count를 넣으면 해결되겠지만, count가 변경될 때마다 이벤트 등록이 계속 발생하며 성능 저하 및 메모리 누수 위험이 있습니다 그렇기에 이런 경우 useRef를 이용합니다.

 

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

export default function App () {
  const [count, setCount] = useState(0);
  const countRef = useRef(count); // 이벤트 핸들러에서 count 값을 불러오기 위해 countRef 정의
  
  useEffect(() => {
    countRef.current = count;
  }, [count]); // count 변경 시마다 업데이트
  
  useEffect(() => {
    const handleResize = () => {
      console.log('창 크기 변경 횟수:', countRef.current); // countRef를 이용해 count 값 호출
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => window.removwEventListener('resize', handleResize);
  }, []);
  
  return (
    <div>
      <p> Count: {count} </p>
      <button onClick={() => setCount(count + 1)}> + </button>
    </div>
  );
}

countRef.current 값은 컴포넌트 생명주기 동안 유지되기에 countRef를  count와 동기화하여 count가 바뀔 때마다 countRef.current 값을 갱신해 줍니다. 그리고 해당 countRef.current를 이벤트 핸들러 내에서 사용하여 클로저 문제를 해결할 수 있습니다.

 

WebSocket 연결 과정에서도 해당 방식을 이용할 수 있습니다.

export deafult function Chat () {
  const [message, setMessage] = useState('');
  const messageRef = useRef('');
  
  useEffect(() => {
    messageRef.current = message;
  }, [message]);
  
  useEffect(() -> {
    const socket = new WebSocket(''wss://...');
    socket.onmessage = (event) => {
      console.log('받은 메시지:', messageRef.current);
    };
    
    return () => socket.close();
  }, []);
  
  ...
}

 

 

ref.current 사용 시 주의점 (feat. side effect)

 

그리고 ref.current는 렌더링 중에 읽으면 안됩니다! React는 컴포넌트가 순수 함수처럼 동작하길 원합니다. 순수 함수란 동일한 입력에 대해 동일한 결과를 반환하는 함수를 말합니다. 즉 컴포넌트에 동일한 입력(props, state, context)가 주어지면 동일한 결과(JSX)를 반환하기를 원하는 것이죠. 하지만 렌더링 중에 ref.current를 읽거나 쓰게 되면 컴포넌트가 동일한 입력에도 매번 다른 결과를 내거나 외부 상태를 바꿀 가능성이 있습니다.

 

그렇기에 ref.current는 이벤트 핸들러 혹은 useEffect 내부에서 사용해야 합니다.

useEffect나 이벤트 핸들러는 렌더링과 별개로 실제 DOM이 만들어진 뒤 실행하기 때문에 side effect를 안전하게 처리할 수 있기 때문입니다.

 

side effect에 대해서도 자세하게 설명하면 좋을거 같습니다.

side effect는 함수가 본래의 목적 이외에 외부 세계에 영향을 주거나, 외부 상태에 의존하거나, 변경하는 모든 행위를 의미합니다. 즉, 함수가 동일한 입력을 받아도 외부 요인에 따라 결과가 달라지거나 함수 실행이 외부에 영향을 미친다면 해당 함수는 side effect가 존재한다고 이야기합니다.

이러한 side effect는 함수형 프로그래밍에서 기본적으로 피하거나 최소화해야 합니다. React에서는 side effect를 컴포넌트의 렌더링 로직과 명확하게 분리해야 하며, useEffect 내부에서 side effect를 다루는 것으로 실현합니다.

 

 

React 19, ref 콜백 클린업 함수

React 19에서는 ref 콜백 함수에서 클린업 함수를 반환할 수 있는 기능이 추가됐습니다.

이를 이용해 DOM의 노드가 마운트될 때 필요한 리소스(이벤트 리스너, 옵저버 등)를 설정하고, 언마운트되거나 노드가 변경될 때 자동으로 정리(cleanup)하는 작업을 더욱 간편하게 처리할 수 있게 되었습니다.

 

기존 ref 콜백은 어떻게 동작했는가?

- 마운트 하는 경우: ref 콜백이 DOM 노드를 인자로 받아 호출합니다.

- 언마운트 하는 경우: ref 콜백이 null을 인자로 받아 한 번 더 호출되기 때문에 이 시점에 클린업 작업을 직접 구현해야 했습니다.

const refCallback = (node) => {
  if (node) {
    // 노드 마운트
  } else {
    // 노드 언마운트 -> 클린업 작업
  }
};

return <div ref={refCallback} />

 

이제 React 19에서는 ref 콜백 클린업 함수를 반환할 수 있기 때문에 null일 때 작업을 하는 것이라니라 return을 이용해 작업을 할 수 있게 됐습니다. 코드로 확인해보면

const refCallback = (node) => {
  if (node) {
    // 노드가 마운트 됨: 리소스 설정
    const handler = () => { //원하는 작업 }
    node.addEventListener('click', handler);
    
    // 클린업 함수 반환
    return () => {
      node.removeEventLisener('click', handler);
      console.log('cleanup', node);
    };
  }
};

return <div ref={refCallback} />

이렇게 else 뒤에 작업을 하는것이 아니라 return으로 클린업 함수를 반환할 수 있습니다.

이를 통해 코드를 명확하고 직관적으로 볼 수 있고 리소스 정리(이벤트 해체, 옵저버 해제)를 자동화할 수 있어 메모리 누수 방지에 효과적입니다.

 

결과적으로 컴포넌트 전체의 생명주기에 대한 작업은 useEffect를 이용하고, 특정 DOM 노드의 생명주기에 대한 작업은 ref 콜백 함수를 이용할 수 있습니다.

 

그런데 위의 예시는 click 이벤트를 다루는 경우입니다. 사실 이런 경우는 그냥 onClick을 사용하면 됩니다. 그렇다면 어떤 상황에서 ref 콜백 함수를 사용할까요?

  • React에서 지원하지 않는 이벤트에 대한 작업이 필요한 경우
  • ResizeObserver, IntersectionObserver, D3.js 등 DOM 노드에 직접 접근해 등록/해제해야 하는 리소스 관리가 필요한 경우
  • 동적으로 여러 개의 ref가 필요한 경우
  • DOM 노드에 대해서 마운트/언마운트될 때의 추가 작업이 필요한 경우
  • 조건부 렌더링 혹은 동적 노드 교체가 자주 일어날 경우

보통 위와 같은 경우에 콜백 ref를 사용하는 것이 일반적입니다.

저는 콜백 ref를 사용한 경험이 없어서 사용 사례들을 찾아보며 이해를 했는데, 그 부분도 정리해 보겠습니다.

 

A. transition 이벤트 관리

const refCallback = (node) => {
  if (node) {
    const handler = () => console.log('transition start');
    node.addEventListener('transitionstart', handler);
    
    return () => {
      node.removeEventListener('trasitionstart', handler);
      console.log('cleanup', node);
    };
  }
};

return <div ref={refCallback}>...</div>

React 19부터 onTransitionStart 및 지원하지 않던 이벤트를 지원해주기 시작했습니다. 하지만 대부분 React 18을 사용중일거라 예상되는데, 위처럼 React에서 지원하지 않는 이벤트에 대해서 처리하고 싶을 때 ret 콜백 함수를 사용하면 됩니다.

 

B. ResizeObserver 등의 리소스 관리

const refCallback = (node) => {
  if (node) {
    const observer = new ResizeObserver(() => { //원하는 작업 });
    observer.observe(node);
    
    return () => observer.disconnect();
  }
}

 

 

C. 동적으로 여러 개의 ref가 필요한 경우

리스트의 각 아이템에 ref를 달아야 하는 경우 ref 객체를 배열이안 Map으로 직접 관리해야 합니다.

이 때 콜백 ref를 쓰면 각 DOM 노드를 원하는 방식으로 저장 및 관리할 수 있습니다.

const itemsRef = useRef(null);
if (itemsRef.current === null) {
  itemsRef.current = new Map();
}
// 위의 코드는 const itemsRef = useRef(new Map());으로 사용할 수 있지만,
// 공식 문서에 따르면 위의 방식은 최초 렌더링 시에만 new Map()을 사용하지만,
// 이후의 렌더링마다 호출 자체는 이루어지기 때문에 성능 저하 가능성이 있습니다.
// 그래서 위의 패턴을 사용합니다. ref.current를 렌더링 중에 쓰거나 읽는 것은 허용되지 않지만,
// 이 경우는 결과가 항상 동일하고 초기화 중에만 조건이 실행되어 충분히 예측이 가능하기에 허용됩니다.

const setItemRef = (id, node) => {
  const map = itemsRef.current;
  if (node) map.set(id, node);
  else map.delete(id);
}

return items.map(item => (
  <div key={item.id} ref={node => setItemRef(item.id, node)}>{item.name}<div>
))

 

 

D. DOM 노드가 마운트/언마운트 될 때 추가 작업이 필요한 경우

이벤트 리스너 등록/해제, 외부 라이브러리 초기화/정리가 DOM 노드의 마운트/언마운트 시점에 필요한 경우 콜백 ref를 사용하여 각 시점에 맞춰 원하는 작업을 할 수 있습니다. React 19 이전에는 마운트 시에는 node가 언마운트 시에는 null이 전달되는 것을 이용해 if문으로 처리했고, React 19부터는 클린업 함수를 함께 이용하면 됩니다.

보통 이벤트 리스너나 외부 라이브러리에 대한 작업은 useEffect를 이용하지만, 동적 DOM 노드 혹은 조건부 렌더링과 같은 특정 상황에서는 ref 콜백 함수를 이용하는 것이 더 적합합니다.

import {useState, useCallback} from 'react';

export default function DynamicComponent() {
  const [isShown, setIsShown] = useState(false);
  
  const refCallback = useCallback((node: HDMLDivElement | null) => {
    if (node) {
      // 노드 마운트
      const handleClick = () => console.log("Clicked !!");
      node.addEventListener("click", handleCLick);
      
      return () => {
        // 노드 언마운트, React 19의 클린업 함수 이용
        node.remveEventListener("click", handleClick);
      }
    }
  }, []);
  
  return (
    <div>
      <button onClick={() => setIsShown(!isShown)}>Toggle</button>
      {isShown && <div ref={refCallback}>동적 노드</div>}
    </div>
  );
}

위의 경우도 click 이벤트는 onClick이 있으니 onClick을 사용하는 것이 더 React스럽고, React에서 사용하지 않는 이벤트나 외부 라이브러리 및 옵저버 등의 등록/해제하는 패턴을 구현해야 할 때 ref 콜백 함수를 사용하는 것이 적합합니다. 

 

E. DOM 노드의 생명주기를 세밀하게 제어애햐 할 경우

const refCallback = (node) => {
  if (node) {
    node.startAnimation();
    return () => node.stopAnimation(); // 애니메이션 정리
  }
};

 

생각보다 정리글이 길어졌네요. 다음 포스팅에 이어서 작성하겠습니다!

useState로 객체를 정의해 사용해보기

import { useState } from 'react';

interface ErrorTypes {
	nameError: string;
    passwordError: string
}

function App () {
    const [userName, setUserName] = useState('');
    const [userPassword, setUserPassword] = useState('');
    const [errors, setErrors] = useState<ErrorTypes>({
    	nameError: '',
        passwordError: '',
    });
    
    const onChangeUserName = (e) => {
    	setUserName(e.target.value);
    }
    
    const onChangePassword = (e) => {
    	setUserPassword(e.target.value);
    }
    
    const handleLoginClick = () => {
    	if(!userName?.trim()) {
        	setErrors({...prev, nameError: '사용자 이름을 입력해 주세요'});
            return;
        }
        if(!userPassword.trim()) {
        	setErrors({...prev, passwordError: '비밀번호를 입력해주세요'});
        }
    }
    
    return (
    	<div>
        	
            <div>
            	<label htmlFor='id'>아이디</label>
                <input value={userName} onChange={onChangeUserName} id='id' />
                {errors.nameError && <p>{errors.nameError}</p>}
            </div>
            <div>
            	<label htmlFor='pw'>비밀번호</label>
            	<input value={userPassword} onChange={onChangeUserPassword} id='pw'/> 
                {errors.passwordError && <p>{errors.passwordError}</p>}
            <div>
            <button onClick={hanleLoginClick}>로그인</button>
        </div>
    )
}

위 코드와 같이 useState로 객체를 다룰 때, setState로 특정 값을 바꾸고 싶다면, ...prev를 입력해 기존 내용을 유지하고, 뒤에 원하는 키에 대한 값을 변경해 주는 방식을 이용하면 됩니다.

 

setState의 비동기적 처리와 일괄 처리(Batching)

비동기 처리

setState는 즉시 상태를 변경하지 않고, React 렌더링 사이클에 맞춰 비동기적으로 처리됩니다. 즉 여러 개의 setState의 호출이 있을 때, React에서는 이를 하나로 묶어서 일괄 처리(Batching)하여 불필요한 렌더링을 줄이고 성능을 최적화합니다.

setState(count + 1);
setState(count + 2);
setState(count + 1);

한 함수나 이벤트 핸들러 내에서 이렇게 setState가 호출된다면 +4가 더해지는 것이 아니라 이벤트가 끝날 때까지 기다렸다가 마지막으로 입력된 값을 적용합니다. 그렇다고 해서 마지막 setState만 실행되는 것은 아닙니다. 각 실행을 큐에 넣어 함수 혹은 이벤트핸들러가 끝날때까지 기다렸다가 순차적으로 실행한다고 이해하면 좋을 것 같습니다.

 

즉, 위와같이 호출되면 count에 4가 더해지는 것이 아니라 최종으로 실행된 setState(count + 1)이 적용되어 count에 1이 더해집니다.

 

 

함수형 업데이트

그렇다면 여러번 연속으로 상태를 변경해야 하거나 이전 상태에 의존하여 새로운 값을 계산 혹은 도출해야 한다면 어떻게 할까요? 함수형 업데이트를 사용하면 됩니다.

setState(prev => prev + 1);

 

처음에 객체에 값을 넣을 때에도 사용한 방식인데요. 위의 방법 뿐만 아니라

setState((prev) => {
  const newPrev = [...prev];
  newPrev.append("내가 원하는 값");
  return newPrev
})

이런식으로 내부에서 원하는 작업을 수행한 후 그 값을 넣는 것도 가능합니다.

 

그렇기에 아래와 같이 한 함수 혹은 이벤트 핸들러 내에서 함수 업데이트를 이용해 여러번 호출하면 그 값이 누적되어 count에 3이 더해집니다.

setState((prev) => prev + 1);
setState((prev) => prev + 1);
setState((prev) => prev + 1);

 

 

 

혼합하여 여러번 호출된다면?

그렇다면 함수형 업데이트와 그냥 값을 넣는 방식이 혼합되어 사용된다면 어떻게 될까요?

처음에 이야기 한 것처럼 혼합하여 사용하는 경우에도 상태 큐 메커니즘에 따라 동작합니다. count의 초기값이 0이라 가정하고 다음 과정들을 따라가보면 이해할 수 있습니다.

setState(count + 1); // 0 + 1
setState((prev) => prev + 1); // 1 + 1 => count -> 2
setState(count + 1); // 0 + 1을 덮어씀 => count -> 1
setState((prev) => prev + 1); // 큐의 마지막 값 1 + 1

count는 2가 됩니다.

setState(count + 1); // 0 + 1
setState((prev) => prev + 1); // 1 + 1
setState((prev) => prev + 1); // 2 + 1
setState(count + 1); // 0(현재 count값을 가져옴) + 1

count는 1이됩니다. 마지막 setCount 호출을 뺀다면 3이 됩니다.

setState(count + 1); // 0 + 1
setState(count + 1); // 0 + 1 덮어씀
setState((prev) => prev + 1); // 1 + 1
setState((prev) => prev + 1); // 2 + 1

count는 3이 됩니다.

 

결국 마지막 값이 prev를 이용한다면 지금까지 큐에 쌓인 값을 업데이트 해주고, 그게 아니라면 초기값에 더한 값을 업데이트 해줍니다! 사실 이렇게 한 함수나 이벤트 핸들러에서 setState를 여러번 호출하는 경우는 거의 없어서 크게 신경쓰지 않아도 될 것 같습니다. 전 호기심에 이것저것 테스트 하면서 알아봤습니다 ㅋㅋ

 

그리고 저렇게 setState를 여러번 호출하는 과정에서 중간에 count를 찍어도 일괄처리 되어 반영되는 것이기 때문에 반영되기 전 count 값이 찍힙니다.

React Projeact Setting

React 공식 문서에서는 Vite 사용을 권장합니다. 작년만 해도 CRA였는데 CRA는 이제 지원되지 않습니다..ㅠ 빠르게 새로운 친구인 Vite에 적응해야죠! 저는 패키지 매니저로 pnpm을 사용하기 때문에 pnpm을 이용하여 프로젝트 셋팅을 할 예정입니다. pnpm을 사용하는 이유는 추후에 따로 정리해 올리겠습니다.

npm install -g pnpm@latest-10
pnpm create vite
pnpm install

 

이렇게 설치하기 전에 컴퓨터나 노트북에 Node.js가 없다면 Node.js를 설치해 줘야 합니다.
그냥 LTS를 설치해 주어도 되지만, 이후 버전을 변경해야 할 경우를 고려해 nvm을 사용해서 node.js를 설치해주는 것이 좋습니다.

 

nvm 설치 및 node 설치 - 사용법(mac&windows)

1. windows 에서 설치 아래 경로로 이동해서 Windows용 nvm설치 파일을 다운로드 한다. nvm-setup.zip 파일을 다운로드 한다.https://github.com/coreybutler/nvm-windows/releases Releases · coreybutler/nvm-windowsA node.js vers

jang8584.tistory.com

 

스타일링은 tailwindCSS로 진행할 예정이기에 tailwindCSS도 설치 및 설정을 해 주었습니다.

pnpm install tailwindcss @tailwindcss/vite @tailwindcss/postcss postcss

 

vite.config.ts에 tailwindcss 추가

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
})

 

postcss.config.mjs 파일을 루츠 폴더에 생성한 후 다음 코드 입력

export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

tailwindCSS는 실제로 postCSS 플러그인을 기반으로 동작하기 때문에 tailwindCSS의 핵심기능인 동적 유틸리티 클래스 생성 및 @tailwind  디렉티브를 사용할려면 필수적으로 함께 설치해야 합니다. 빌드 과정에서 CSS를 처리 함에 있어서 PostCSS가 실행되어 tailwindCSS의 유틸리티 클래스에 대한 CSS 파일을 생성합니다.

 

global.css에 `@import "tailwindcss"`를 입력해 기초적인 설정을 마무리 해줍니다.

 

기초 프로젝트 셋팅은 완료했습니다. 이제 저번 내용에 이어서 기본적인 JSX 사용 방법이나 기초 hook들에 대해서는 알고 있다고 가정하고 React 19에서 바뀌거나 추가된 부분 그리고 기존 hook에 대한 심화적인 내용들에 대해 정리하겠습니다.

 

useState

파생 상태 (+ useMemo를 이용한 최적화)

파생 상태란 기존의 상태(state)를 통해서 얻어낼 수 있는 새로운 상태를 파생 상태라 이야기 합니다. 

import {useState} from 'react';

export default function ExampleComponenet () {
	
    const [userName, setUserName] = useState('')
    
    const userNameDerived = `${userName}입니다.` //파생 상태
    
    reutrn (
    	<div>
        	<input value={useName} onChange={(e) => setUserName(e.target.value)} />
            <p>{}</p>
        </div>
    )
}

- 이러한 파생 상태는 state로 관리하면 안됩니다. 파생 상태를 state로 관리할 경우 원본 상태와 파생 상태가 불일치하는 버그가 발생할 수 있습니다.

- 동일하게 파생 상태를 useEffect 등에서 setState로 관리하면 상태가 바뀔때마다 불필요하게 컴포넌트 리렌더링이 발생되거나 무한 루프가 발생할 수 있습니다.

 

그래서 위 코드와 같이 파생 상태는 const로 정의하여 관리하는 것이 일반적입니다. 또한 이러한 파생 상태는 useMemo를 이용해 최적화도 가능합니다. useMemo를 설명하는 파트에서 더 자세하게 설명 드리겠지만 간단히 설명 드리자면 특정 값(A라 하겠습니다)을 캐싱(메모이제이션)하여, A에 영향을 주는 state가 변경되지 않는다면 컴포넌트가 리렌더링 되는 과정에서 A을 도출하는 연산을 따로하지 않고, 캐싱된 값을 전달할 수 있도록 하는 hook입니다. 이를 기존 파생 상태가 존재하는 코드에 적용한다면 다음과 같습니다.

import {useState} from 'react';

export default function ExampleComponenet () {
	
    const [userName, setUserName] = useState('')
    
    const memoUserNameDerived = userMemo(() => {
    	return `${userName}입니다.`;
    }, [userName]); //userName에 변경되는 경우에만 해당 값 변경
    
    reutrn (
    	<div>
        	<input value={useName} onChange={(e) => setUserName(e.target.value)} />
            <p>{}</p>
        </div>
    )
}

이러한 useMemo는 무분별하게 사용해서는 안되고, 다음과 같은 상황에서 사용하는 것을 추천합니다.

  1. 연산 비용이 큰 계산
  2. 리렌더링이 자주 발생하는 컴포넌트에서 동일한 계산이 반복될 때
  3. props로 객체/배열을 전달하는 과정에서 참조 값이 유지되어야 할 때

3번째 상황에 대해 조금 더 자세한 설명이 필요할 거 같네요.

우선적으로 알아야 할 내용은 React의 값 비교 방식입니다. React는 props나 state 변경에 대해서 데이터의 값이 아닌 참조값(메모리 주소)만 비교하는 얕은 비교(Shallow Comparison)로 판단합니다. <얕은 비교는 숫자나 문자열 boolean과 같은 원시값은 값 자체를 비교하고 객체나 배열은 참조값만 비교합니다. 반대로 깊은 비교는 원시값에 대해서는 값을 동일하게 비교하지만 객체나 배열에 대해서는 구조적으로 모두 일치하는지를 확인합니다.>

 

즉 리렌더링의 여부를 결정하는 과정에 얕은 비교를 사용하는 것이죠 그러므로 props나 state의 참조값이 다르면 내부의 값이 같아도 변경된 것으로 인지하고 리렌더링을 발생 시킵니다.

 

코드로 설명을 드리겠습니다.

function Parent ({value}) {
	return <Child items={[value]} />
}

위와 같이 부모 컴포넌트에서 받은 props를 바로 배열로 변환하여 자식 컴포넌트로 보내준다면, 렌더링마다 새로운 배열을 생성하게 되면서 Child는 매번 리렌더링 되게 됩니다. 이럴 경우

 

fucntion Parent ({value}) {
	const items = useMemo(() => [value], [])
    
    return <Child items={items}>
}

해당 코드처럼 useMemo를 사용하면 자식 컴포넌트가 불필요하게 리렌더링 되는 것을 막을 수 있습니다.

만약 items을 부모가 props로 받은 것이 아니라 useState로 부모에서 선언한 값이라면 setState로 값을 바꾸지 않는 이상 참조값이 바뀌지 않기 때문에 useMemo를 사용하지 않아도 됩니다.

 

 

지연 초기화

useState를 사용하는 과정에서 초기값을 함수를 통해 초기화 하는 경우가 있습니다. 이 과정에서 사용하는 함수가 무거운 연산을 하는 경우 컴포넌트의 성능을 크게 떨어뜨릴 수 있습니다.

const heavyCalculate = () => {
	let sum = 0;
    for (let i = 0; i < 10000000000; i++) {
    	sum += 1
    }
    reutrn sum
}


export default function Component () {
	
    const [state, setState] = useState(heavyCalculate())
    
    
    return (
    	<div>
        	<input value={state} onChange={(e) => setState(e.target.value)} />
        </div>
    )
}

위와 같이 무거운 연산을 하는 함수로 state의 초기값을 설정할 경우 초기값 이후 input에 값을 입력할 때 state에 영향을 주진 않지만 state가 변경되어 리렌더링 될때마다 해당 함수를 실행하며 input 값을 입력하는 과정에 매우 느려지는 것을 확인할 수 있습니다.

 

이런 경우를 대비해 React에서는 지연 초기화 기능(?)을 제공합니다. useState를 선언할 때, 초기값을 콜백 함수로 전달하여 구현한다면, 최초 렌더링 시에만 무거운 연산을 하는 함수를 실행하고, 이후 리렌더링에서는 이전에 계산된 값을 그대로 사용합니다.

 

아래와 같이 사용합니다.

const [state, setState] = useState(() => {
	return heavyCalculate();
})

// or

const [state, setState] = useState(() => heavyCalculate());

// or

const [state, setState] = useState(heavyCalculate);
// 지연 초기화라고 할 순 없지만 위와 같이 사용하면 해당 함수를 초기화 함수로 인식하여
// 지연 초기화와 동일하게 작동합니다.
// 하지만 가독성을 위해 1번 혹은 2번 방법을 추천합니다!

 

그래서 useState의 초기값을 무거운 연산을 하는 함수로 정의한다면 위의 지연 초기화를 이용하는 것이 좋습니다 :)

+ Recent posts