상태는 React 의 핵심 아이디어입니다. 컴포넌트는 상태를 기준으로 렌더를 하고, 뭐 하나라도 변경하고자 한다면 모두 상태를 이용해야 합니다. 그런 만큼 상태를 잘 설계하는 것이 리액트를 잘 이용하는 핵심인데요, 이번 글에서는 상태를 예쁘게 설계하는 몇 가지 팁을 작성해보려 합니다.
상태는 복잡합니다. 리스트에서 모달이 열리는 상태를 설계해 볼까요?
type State = { isOpen: boolean; todoId: number | null };
const [modalState, setModalState] = useState<State>({
isOpen: false,
todoId: null,
});
문제점이 보이시나요? 이 상태는 isOpen
이 false
인데 todoId
가 number
일 수도 있고, 반대로 isOpen
이 true
인데 todoId
가 null
일 수도 있습니다. 물론 실수하지 않으면 되지만, 실수를 막는 것보단 실수할 수 없게 하는 게 좋습니다. 이런 상태를 굳이 허용해 줄 이유가 없습니다.
유효한 상태만 허용한다면 이런 식이 됩니다.
type State =
| { isOpen: true; todoId: number }
| { isOpen: false; todoId: null };
const [modalState, setModalState] = useState<State>({
isOpen: false,
todoId: null,
});
또는, 타입을 굳이 복잡하게 쓰고 싶지 않다면 이런 식도 좋을 것입니다.
const [modalTodoId, setModalTodoId] = useState<number | null>(null);
const isModalOpen = modalTodoId !== null;
이렇게 유효한 상태만 허용함으로서 혹시 모를 실수를 방지할 수 있습니다.
앞의 항목과 연결되는 내용인데요, 동일한 지식을 중복으로 나타내는 상태가 있을 경우에도 유효하지 않은 상태가 생길 가능성이 생깁니다. 위의 예시에서도 isOpen
과 todoId
가 중복 상태의 역할을 한다고 볼 수 있는데요,
아래 코드를 보겠습니다.
const PurchasePage = () => {
const discountRate = 0.5;
const [price, setPrice] = useState();
const [discountedPrice, setDiscountedPrice] = useState(); // 중복 상태
const onSelectItem = (item: { price: number }) => {
setPrice(item.price);
setDiscountedPrice(item.price * discountRate);
}
discountedPrice
와 price
가 중복된 지식을 보여줍니다. price 가 1000인데 discountedPrice가 700일 수 있을까요? 할인율이 0.5
이기 때문에 price 가 1000이면 discountedPrice 는 무조건 500이어야만 합니다.
따라서 아래와 같이 구현하는 게 낫습니다.
const PurchasePage = () => {
const discountRate = 0.5;
const [price, setPrice] = useState();
const onSelectItem = (item: { price: number }) => {
setPrice(item.price);
}
const discountedPrice = price && (price * discountRate); // 계산으로 해결
이번에는 폼 처리의 예시를 보겠습니다. 투두 아이템을 받아서 제목을 수정하는 기능입니다.
const TodoEdit = ({ todoId }: { todoId: number }) => {
const { data: todo } = useTodo(todoId);
const [title, setTitle] = useState();
useEffect(() => {
if (todo) setTitle(todo.title);
}, [todo]);
return <input value={title} onChange={(e) => setTitle(e.target.value) />;
}
사실 effect 를 저렇게 이용하는 것부터 잘못된 코드이긴 한데, 일단은 넘어가겠습니다.
effect 에서 todo 의 title을 셋한 순간 useState
가 들고 있는 title과 useTodo
가 들고 있는 todo.title 이 중복됩니다.
그렇다고 이 기능을 상태 없이 해결할 수는 없습니다. 상태가 무엇인지 잘 고민해보면, 이 컴포넌트에는 아래 두 개의 상태가 있어야 합니다.
이 둘은 분명 다른 상태입니다. 따라서 이렇게 설계하면 중복 상태를 피할 수 있습니다.
const TodoEdit = ({ todoId }: { todoId: number }) => {
const { data: todo } = useTodo(todoId); // todo 의 제목
const [titleDraft, setTitleDraft] = useState(); // 유저가 수정한 제목
// 유저가 수정했다면 수정한 걸 보여주고, 아니라면 원래 todo 의 title을 보여준다
const displayTitle = titleDraft ?? todo?.title;
return <input value={displayTitle ?? ''} onChange={(e) => setTitleDraft(e.target.value) />;
}
상태의 원본을 저장하면 상태를 가공하기 쉬워집니다. 다시 폼처리 예시를 볼까요?
const TodoPage = () => {
const [errorMessage, setErrorMessage] = useState();
const onSubmit = () => {
try {
// ...
} catch(err) {
if (get(err, 'errcode') === 10110)
return setErrorMessage('제목은 5자 이상이어야 합니다.');
else if (get(err, 'errcode') === 10111)
return setErrorMessage('완료된 TODO는 제목을 바꿀 수 없습니다.');
}
};
투두를 수정하고, 해당 에러메세지를 보여주는 로직입니다. 이 로직 자체는 지금 돌아가는 데에 아무 문제도 없어 보입니다. 그런데 만약 아래와 같은 기획이 추가되면 어떻게 할까요?
const errorColor = errorMessage === '제목은 5자 이상이어야 합니다.' ? 'blue' : 'red';
정말 이상한 코드입니다.
차라리 처음부터 상태의 원본을 저장하고 세부사항을 계산으로 풀었다면 어땠을까요?
enum TodoFormError {
// ...
}
const ERROR_CODE_MESSAGE_MAP = {
[TodoFormError.TITLE_UNDER_5]: '제목은 5자 이상이어야 합니다.',
// ...
}
const TodoPage = () => {
const [error, setError] = useState<TodoFormError>();
const onSubmit = () => {
try {
// ...
} catch(err) {
if (get(err, 'errcode') === 10110)
return setError(TodoFormError.TITLE_UNDER_5);
else if (get(err, 'errcode') === 10111)
return setError(TodoFormError.CANNOT_UPDATE_COMPLETED);
}
};
const errorMessage = errorCode && ERROR_CODE_MESSAGE_MAP[errorCode];
이렇게 가공하기 쉬운 상태를 저장하고 가공하기 어려운 것을 계산으로 풀어내면 이후 유지보수가 쉬워집니다.
많이 알려져있진 않지만, useReducer
를 이용하면 앞의 것들을 달성하기 쉬울 때가 많습니다.
가령 열 수만 있고 닫을 수는 없는 토글 버튼을 생각해 보겠습니다.
const [isOpen, setIsOpen] = useState(false)
const onClick = () => setIsOpen(true);
이렇게 해도, 여전히 핸들러에서 setIsOpen(false)
를 호출할 수 있다는 여지가 남아있습니다.
이를 해결하기 위해 커스텀 훅을 만들어 setState
를 은닉할 수도 있지만
const useUnclosableToggleState = () => {
const [isOpen, setIsOpen] = useState(false);
return { isOpen, open: () => setIsOpen(true) };
}
그냥 useReducer
를 이용하면 편하게 달성할 수 있습니다.
const [isOpen, open] = useReducer(() => true, false);
useReducer
는 유효한 상태 흐름만 허용할 때도 유용한데요, 가령 3개의 스텝을 거쳐 제출하는 비밀번호 변경 폼을 생각해 보겠습니다.
enum Step {
PASSWORD_INPUT,
PASSWORD_CONFIRM,
SUBMIT,
}
const ChangePassword = () => {
const [step, setStep] = useState(Step.PASSWORD_INPUT);
이렇게 디자인하면 setStep
이 제공되어 있기 때문에 PASSWORD_INPUT
에서 SUBMIT
으로 점프한다거나.. 할 수 있는 여지가 있습니다.
대신 useReducer
를 이용하면 유효한 동작만 하도록 만들 수 있습니다.
enum Step {
PASSWORD_INPUT,
PASSWORD_CONFIRM,
SUBMIT,
}
type Action = 'input-done' | 'confirm-done'
const reducer = (state: Step, action: Action) => {
if (action === 'input-done') {
if (state !== Step.PASSWORD_INPUT) throw new Error();
return Step.PASSWORD_CONFIRM;
}
if (action === 'confirm-done') {
if (state !== Step.PASSWORD_CONFIRM) throw new Error();
return Step.SUBMIT;
}
};
const ChangePassword = () => {
const [step, dispatch] = useReducer(reducer, Step.PASSWORD_INPUT);
이제 컴포넌트에서 dispatch 로 상태를 잘못 변경하면 오류가 발생할 것입니다.
좋은 글 잘 봤습니다~