useState, useReducer 등은 React 개발을 한다면 항상 만나는 친숙한 함수들입니다. 이 hook들 중 일부가 2가지 이상의 구문을 갖고 있다는 것을 알고 계신가요? 이러한 사실이 낯설게 느껴지거나, 기억은 하지만 그다지 사용하지 않는다고 생각한 분들과 함께 React hooks API의 용법을 재발견해보고자 합니다.

useState

useState는 2개의 구문을 갖고 있습니다.

  1. 최초 상태를 매개변수로 받는 경우
  2. 상태를 초기화하는 함수 initializer를 매개변수로 받는 경우
function LoginForm() {
    // 1
    const [loggedIn, setLoggedIn] = useState(
        Boolean(localStorage.getItem('accessToken'))
    );
    
    // 2
    const [loggedIn, setLoggedIn] = useState(
        () => Boolean(localStorage.getItem('accessToken'))
    );
}
서로 다른 구문을 가진 useState의 구현 예시

위 2가지 예시는 같은 역할을 하지만, 1은 LoginForm 컴포넌트의 리렌더링 주기마다 localStorage.getItem() 를 다시 호출하는 한편, 2는 최초 렌더링 때만 호출하게 됩니다. (지연 초기 state 참조)

따라서 상태를 초기화하는 데 시간 혹은 메모리 등의 비용이 크게 소모될 경우 함수 형태의 매개변수를 사용하면 최적화에 도움이 됩니다.

useReducer

useReducer는 크게 다음과 같이 구문을 나눌 수 있습니다.

리듀서의 유형에 따라

  1. 리듀서 함수가 action을 사용하는 경우
  2. 리듀서 함수가 action을 사용하지 않는 경우

최초 상태를 할당하는 방식에 따라

  1. 최초 상태를 매개변수로 받는 경우
  2. 상태를 초기화하는 함수 initializer 를 매개변수로 받는 경우
  3. 상태를 초기화하는 함수 initializer와, 그 함수의 매개변수 initializerArg 를 매개변수로 받는 경우

리듀서가 action을 사용하는 경우, 구문은 Redux 스타일과 동일합니다.

function courseReducer(courses, action) {
    switch (action.type) {
        case 'ADD_COURSE':
            return courses.concat(action.payload);
 
        case 'REMOVE_COURSE':
            return courses.filter(course => course !== action.payload);
        
        default:
            throw new Error('Unhandled action type.');
    }
}

function initializeCourses(includeBackend) {
    const baseCourses = ['javascript', 'react'];
    
    return includeBackend
        ? [...baseCourses, 'node.js', 'mongodb']
        : baseCourses;
}

function CourseManager() {
    // 1 - 1
    const [courses, dispatch] = useReducer(
        courseReducer,
        ['javascript', 'react']
    );
    
    // 1 - 2
    const [courses, dispatch] = useReducer(
        courseReducer,
        initializeCourses
    );
    
    // 1 - 3
    const [courses, dispatch] = useReducer(
        courseReducer,
        initializeCourses,
        true
    );
    
    const toggleCourse = course => {
        if (courses.includes(course)) {
            dispatch({ type: 'REMOVE_COURSE', payload: course });
        } else {
            dispatch({ type: 'ADD_COURSE', payload: course });
        }
    };
    
    return (
    	<button onClick={() => toggleCourse('typescript')}>TypeScript</button>
    	<button onClick={() => toggleCourse('electron')}>Electron</button>
    );
}
action을 사용하는 useReducer의 구현 예시

리듀서가 반드시 Redux 스타일을 따라야 하는 것은 아닙니다. 기존 state를 첫 번째 매개변수로 받는 어떤 함수든 리듀서가 될 수 있습니다.

아래 예시는 리듀서에 전달한 changedItem 매개변수가 이미 수업 목록에 포함돼 있을 경우 삭제, 그렇지 않을 경우 새로 삽입하도록 만든 리듀서입니다.

function courseReducer(courses, changedItem) {
    return courses.includes(changedItem)
        ? courses.filter(item => item !== changedItem)
    	: courses.concat(changedItem);
}

function initializeCourses(includeBackend) {
    const baseCourses = ['javascript', 'react'];
    
    return includeBackend
        ? [...baseCourses, 'node.js', 'mongodb']
        : baseCourses;
}

function CourseManager() {
    // 2 - 1
    const [courses, setCourses] = useReducer(
        courseReducer,
        ['javascript', 'react']
    );
    
    // 2 - 2
    const [courses, setCourses] = useReducer(
        courseReducer,
        initializeCourses
    );
    
    // 2 - 3
    const [courses, setCourses] = useReducer(
        courseReducer,
        initializeCourses,
        true
    );
    
    return (
    	<button onClick={() => setCourses('typescript')}>TypeScript</button>
    	<button onClick={() => setCourses('electron')}>Electron</button>
    );
}
action을 사용하지 않는 useReducer의 구현 예시

action을 생략하면서 보다 간결하고 이해하기 쉬운 코드가 된 것을 볼 수 있습니다.

useRef

useRef는 아주 독특한 용법을 갖고 있습니다.

  1. DOM 엘리먼트에 접근하기 위해 사용되는 경우
  2. 리렌더링 사이에서 동일하게 유지되는 값을 저장하기 위해 사용되는 경우

1번 용법으로 사용할 경우 구문은 다음과 같습니다.

function Resizable() {
    const containerRef = useRef(null);
    const width = useMemo(() =>
        containerRef
            ? containerRef.clientWidth
            : 0,
    	containerRef
    );
    
    // ...
    
    return (
        <div ref={containerRef}>
            <p>width: {width}</p>
        </div>
    );
}
useRef를 사용해 DOM 엘리먼트를 참조하는 예시

혹은 콜백 ref를 사용할 수도 있습니다.

function Resizable() {
    let containerRef = useRef();
    const width = useMemo(() =>
        containerRef
            ? containerRef.clientWidth
            : 0,
    	containerRef
    );
    
    // ...
    
    return (
        <div ref={ref => { containerRef = ref; }}>
            <p>width: {width}</p>
        </div>
    );
}
useRef와 callback ref를 사용해 엘리먼트를 참조하는 예시

(콜백 형태로 선언한 ref는 componentDidMountcomponentDidUpdate 가 호출되기 이전에 호출되므로, DOM이 갱신되었을 때 콜백을 통해 손쉽게 ref 값을 새로 할당하거나 비울 수 있습니다.)

2번 용법은 더욱 더 특이합니다.

function FileUploader() {
    const readerRef = useRef();
    
    useEffect(() => {
        readerRef.current = new FileReader();
        return () => { readerRef.current = null; };
    }, []);
}
useRef를 리렌더링에 영향받지 않는 정적 컨테이너로 활용한 예시

위 예시는 FileReader API를 사용해 파일 업로드를 관리하는 컴포넌트의 일부입니다. 리렌더링이 일어날 때마다 새로운 FileReader 인스턴스를 만드는 현상을 피하고 싶다면 어떻게 해야 할까요?

const reader = new FileReader();

function FileUploader() {
	
}
메모리 누수가 일어나는 패턴

위와 같이 컴포넌트 바깥에 인스턴스를 생성할 수도 있을 것입니다. 하지만 이 경우, 컴포넌트가 마운트 해제된 이후에도 reader에 할당된 메모리를 릴리즈할 수 없다는 단점이 있습니다.

이 때, 유용하게 활용 가능한 것이 바로 useRef의 특성입니다. 이 hook은

  1. 컴포넌트의 리렌더링에 영향을 받지 않는 객체를 생성하고
  2. 이 객체의 current 프로퍼티에 리렌더링의 영향을 받지 않는 데이터를 저장할 수 있게 해 줍니다.

다시 말해서 readerRef.currentFileReader 인스턴스를 할당하는 행위가 리렌더링을 일으키지 않으며, 다른 state가 갱신되면서 컴포넌트에 리렌더링이 일어날 때도 readerRef.current는 항상 같은 인스턴스를 유지합니다.

이러한 특징은 React 라이프사이클에 의해 초기화되지 않는 값을 만들고 싶을 때 매우 유용합니다. 예를 들면 리렌더링이 일어나도 변화하지 않는 콜백 함수를 만들 때, 또는 setTimeout이나 requestAnimationFrame이 반환하는 id 값을 리렌더링이 일어난 후에도 유지하고 싶을 때 useRef를 활용할 수 있습니다.


이 글에서는 useState, useReducer, useRef의 상대적으로 덜 알려진 용법을 소개해 보았습니다. 이들 방법을 통해, 한층 명료하고 효율적인 React 컴포넌트를 작성하는 데 도움이 된다면 좋겠습니다.