본문으로 건너뛰기

Why don't I use the useReducer?

· 약 5분

어느 순간 useReducer 를 잊고 개발하고 있었습니다.
useReducer 를 사용하면 복잡한 상태 로직을 깔끔하게 관리할 수 있습니다.

시작

다음과 같은 요구조건을 해결해야 한다고 가정해볼까요.

  • 버튼과 툴팁이 있습니다.
  • 버튼에 커서를 올렸을 때는 툴팁이 보여야 하며, 커서를 버튼 밖으로 이동하면 툴팁이 닫혀야 합니다.
  • 버튼을 클릭했을 때는 툴팁이 고정되어야 하며, 이 때는 커서를 버튼 밖으로 이동하더라도 툴팁이 닫히지 않습니다.
  • 만약, 버튼 밖의 공간에서 클릭을 한다면 고정된 툴팁이 닫힙니다.

다음과 같은 UI 입니다.

이 요구사항을 받은 저는 아래와 같이 구현하였습니다.

useTooltip.ts
import React, { useEffect, useState } from 'react';

export default function useTooltip() {
const [opened, setOpened] = useState(false);
const [fixed, setFixed] = useState(false);

useEffect(() => {
function handleClick() {
setFixed(false);
setOpened(false);
}

window.addEventListener('click', handleClick);

return () => {
window.removeEventListener('click', handleClick);
};
}, [fixed]);

return {
isOpened: opened || fixed,
handleEnter() {
setOpened(true);
},
handleLeave() {
setOpened(false);
},
handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
setFixed(true);
},
};
}
App.tsx
import useTooltip from './hooks/useTooltip';

export default function App() {
const { isOpened, handleEnter, handleLeave, handleClick } = useTooltip();

return (
<div style={{ position: 'relative' }}>
<button onMouseEnter={handleEnter} onMouseLeave={handleLeave} onClick={handleClick}>
button
</button>
{isOpened && <Tooltip />}
</div>
);
}

나름 만족하고 PR 을 올렸는데, 다음과 같은 리뷰를 받았습니다!

useReducer 로 작성해보는 것은 어떨까요?

앞선 코드의 문제점

useTooltip 코드는 opened, fixed 라는 상태를 들고 있습니다.

즉, 다음과 같은 흐름을 가졌다고 할 수 있습니다.

useReducer

두 boolean 상태에 따라 4개의 action 이 생겼습니다.

추가적인 요구사항이 생겨 boolean 상태를 하나 더 추가해야 한다면 상태를 관리하기 위한 8개의 action 이 생겨날 것입니다.

상태가 많아질수록 상태를 다뤄야 할 로직이 복잡해질 것이고, 점점 useTooltip 내부 코드는 복잡해질 것입니다. 점점 useTooltip 의 역할이 잘 드러나지 않을 것이고, 가독성을 해치게 될 것입니다.

useReducer 는 이런 상황에 좋은 해결책을 제시해줍니다.

useReducer

가볍게 useReducer 를 설명해보겠습니다. 공식 문서 를 참조하시면 보다 더 정확한 정보를 얻을 수 있습니다.

useReducer 는 아래와 같은 인터페이스를 가지고 있습니다.

function useReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerState<R>,
): [ReducerState<R>, Dispatch<ReducerAction<R>>];

총 3개의 인자를 받으며, 상태와 상태를 변경할 수 있는 dispatch 를 반환합니다.

각 인자는 다음과 같은 역할을 합니다.

reducer
type Reducer<S, A> = (prevState: S, action: A) => S;

reducer 는 이전 상태와 action 을 받아 새로운 상태를 반환하는 함수입니다.

reducer 는 순수하게 작성하는 것이 좋습니다. 다시 말해, 같은 인자를 받으면 같은 결과를 반환해야 하며, side-effect 를 발생시키면 안됩니다. 강제되는 것은 아니지만 이와 같이 작성할 경우, 테스트에 용이하며 별도의 모듈에 reducer 를 관리할 수도 있습니다.

initialState

말 그대로 초기 상태를 정의할 수 있습니다.

initializer

세 번째 인자는 초기화 함수입니다. 앞서 reducer 와 비슷하게 초기화 로직을 순수하게 관리할 수 있습니다.

아래와 같이 사용합니다.

function init(initialCount) {
return { count: initialCount };
}

function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return init(action.payload);
default:
throw new Error();
}
}

function Counter({ initialCount }) {
const [state, dispatch] = useReducer(reducer, initialCount, init);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: 'reset', payload: initialCount })}>Reset</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</>
);
}

앞선 코드의 개선

useReducer 를 사용하면 useTooltip hook 을 다음과 같이 수정할 수 있습니다.

useTooltip.ts
import { useEffect, useReducer } from 'react';

export default function useTooltip() {
const [{ opened, fixed }, dispatch] = useReducer(tooltipReducer, {
opened: false,
fixed: false,
});

useEffect(() => {
function handleClick() {
dispatch({ type: 'unfix' });
}

window.addEventListener('click', handleClick);

return () => {
window.removeEventListener('click', handleClick);
};
}, [fixed]);

return {
isOpened: opened || fixed,
handleEnter() {
dispatch({ type: 'open' });
},
handleLeave() {
dispatch({ type: 'close' });
},
handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
dispatch({ type: 'fix' });
},
};
}

function tooltipReducer({ opened, fixed }: State, action: Action) {
switch (action.type) {
case 'open':
return { opened: true, fixed };
case 'close':
return { opened: false, fixed };
case 'fix':
return { opened, fixed: true };
case 'unfix':
return { opened, fixed: false };
}
}

type State = {
opened: boolean;
fixed: boolean;
};

type Action =
| {
type: 'open';
}
| {
type: 'close';
}
| {
type: 'fix';
}
| {
type: 'unfix';
};

어떤 Action 이 어떤 상태를 반환하는지 명확하게 드러나고, 상태가 더 많아져도 같은 인터페이스로 관리할 수 있습니다.

정리

  • useReducer 를 사용하면 복잡한 상태 로직을 깔끔하게 관리할 수 있습니다.
  • 상태와 관련한 처리는 reducer 에 맡기고, dispatch 를 통한 action 호출만 관리함으로써 hook, 컴포넌트의 역할을 명확히 드러낼 수 있습니다.
  • reducer 가 복잡해지거나 테스트가 필요한 경우 별도의 모듈을 만들어 관리할 수도 있습니다.