React 상태 예쁘게 설계하는 법

우현민·2024년 1월 17일
22

React

목록 보기
9/11
post-thumbnail

상태는 React 의 핵심 아이디어입니다. 컴포넌트는 상태를 기준으로 렌더를 하고, 뭐 하나라도 변경하고자 한다면 모두 상태를 이용해야 합니다. 그런 만큼 상태를 잘 설계하는 것이 리액트를 잘 이용하는 핵심인데요, 이번 글에서는 상태를 예쁘게 설계하는 몇 가지 팁을 작성해보려 합니다.




유효한 상태만 허용한다

상태는 복잡합니다. 리스트에서 모달이 열리는 상태를 설계해 볼까요?

type State = { isOpen: boolean; todoId: number | null };
const [modalState, setModalState] = useState<State>({
  isOpen: false,
  todoId: null,
});

문제점이 보이시나요? 이 상태는 isOpenfalse 인데 todoIdnumber 일 수도 있고, 반대로 isOpentrue 인데 todoIdnull 일 수도 있습니다. 물론 실수하지 않으면 되지만, 실수를 막는 것보단 실수할 수 없게 하는 게 좋습니다. 이런 상태를 굳이 허용해 줄 이유가 없습니다.

유효한 상태만 허용한다면 이런 식이 됩니다.

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;

이렇게 유효한 상태만 허용함으로서 혹시 모를 실수를 방지할 수 있습니다.




중복 상태가 생기지 않도록 한다

앞의 항목과 연결되는 내용인데요, 동일한 지식을 중복으로 나타내는 상태가 있을 경우에도 유효하지 않은 상태가 생길 가능성이 생깁니다. 위의 예시에서도 isOpentodoId 가 중복 상태의 역할을 한다고 볼 수 있는데요,

핸들러 예시

아래 코드를 보겠습니다.

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);
  } 

discountedPriceprice 가 중복된 지식을 보여줍니다. 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 이 중복됩니다.

그렇다고 이 기능을 상태 없이 해결할 수는 없습니다. 상태가 무엇인지 잘 고민해보면, 이 컴포넌트에는 아래 두 개의 상태가 있어야 합니다.

  • todo 의 원래 제목
  • 유저가 수정한 제목

이 둘은 분명 다른 상태입니다. 따라서 이렇게 설계하면 중복 상태를 피할 수 있습니다.

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는 제목을 바꿀 수 없습니다.');
    }
  };

투두를 수정하고, 해당 에러메세지를 보여주는 로직입니다. 이 로직 자체는 지금 돌아가는 데에 아무 문제도 없어 보입니다. 그런데 만약 아래와 같은 기획이 추가되면 어떻게 할까요?

  • 제목이 5자 이상이 아닌 오류일 경우 오류 메세지를 파란색으로
  • 완료된 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 를 고려한다

많이 알려져있진 않지만, 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 로 상태를 잘못 변경하면 오류가 발생할 것입니다.

profile
프론트엔드 개발자입니다

2개의 댓글

comment-user-thumbnail
2024년 1월 23일

좋은 글 잘 봤습니다~

1개의 답글