draft state 아이디어로 버그 없이 폼 처리하기

우현민·2024년 2월 13일
23

React

목록 보기
10/11
post-thumbnail

리액트로 서버 데이터를 수정하는 ui를 만들면서 개인적으로 유용하다고 느낀 간단한 패턴이 있습니다.

처음 아이디어를 얻은 곳은 tanstack query 메인테이너이신 tkdodo님의 블로그였고, 적용해보면서 발전시켜서 지금은 개인적으로 구현하는 많은 곳에서 유용하게 이용하고 있습니다.

코드를 먼저 보여드리자면, 아래와 같습니다.

const useNicknameForm = (nickname: string) => {
  const [draft, setDraft] = useState<string>();
  
  const value = draft ?? nickname;
  
  const onChangeValue = (newValue: string) => setDraft(newValue);
  
  return { value, onChangeValue };
}

아이디어는 단순합니다.

  • 상태 초기값을 비워둡니다.
  • 값이 변경되면 그제서야 상태에 반영합니다.
  • 보여줘야 하는 value는 데이터와 상태를 조합하여 계산합니다.
    이게 전부입니다.

정말 간단하고 별 차이 없어 보이지만, 상태를 이렇게 설계하면 확장하기도 매우 쉽고 버그도 없고 리액트 철학에도 부합합니다. 제가 이전에 작성한 글인 React 상태 예쁘게 설계하는 법 - 상태의 원본을 저장한다 와도 연결되는 내용입니다.

draft state 라는 이름은 공식 명칭은 아니고, tkdodo 님의 블로그에서 처음 봤던 상태 이름이 draft 여서 이렇게 부르고 있습니다. 명칭보다는 아이디어에 집중해서 읽어주시면 좋을 것 같습니다.




❌ 일반적인 방식들의 문제점

가령 서버에서 받아온 닉네임을 수정하는 코드를 설계해 보겠습니다. 분량을 줄이기 위해 컴포넌트보다는 훅으로 설계하겠습니다.

몇 가지 방식이 있는데요,

첫 번째 방식: initial state

// ❌ 변할 수 있는 값이 initial state 로 들어갑니다.
const useNicknameForm = (nickname: string) => {
  const [value, setValue] = useState(nickname);
  
  const onChangeValue = (newValue: string) => setValue(newValue);
  
  return { value, onChangeValue };
}

useState 에 넣은 initial state 는 컴포넌트가 언마운트될 때까지 변하지 않습니다. 따라서 이 코드는 파라미터로 들어오는 nickname 이 변경될 경우 버그가 생기게 됩니다. 많은 경우 서버 데이터는 초기값이 undefined 였다가 api 콜이 완료되면서 값이 채워지기 때문에 이는 큰 문제입니다.

또한 값이 수정되었는지 알기가 어렵습니다. 초기값이 abcd 이었을 때,

  • 초기값 그대로 둔 것과
  • 한 글자 지워 abc 가 되었다가 다시 d 를 입력하여 abcd 가 된 것

이 둘을 구별할 수 없습니다.


두 번째 방식: useEffect

// ❌ 사용자 경험 이슈 및 버그 가능성
const useNicknameForm = (nickname: string) => {
  const [value, setValue] = useState(nickname);
  
  useEffect(() => {
    setValue(nickname);
  }, [nickname]);
    
  const onChangeValue = (newValue: string) => setValue(newValue);
  
  return { value, onChangeValue };
}

첫 번째 방식의 동기화 문제를 회피하기 위해 이렇게 처리할 수도 있습니다. 하지만 이는 value 상태에 값이 한 틱 늦게 반영되므로 사용자 경험이 좋지 않고, nickname 이 의도치 않게 변경되었을 때 유저가 작성하던 값이 날아가 버린다는 문제가 있습니다.


세 번째 방식: state 끌어올리기

애초에 상태를 props나 인자로 받지 말고, 처음부터 서버 데이터를 받아올 때부터 폼 상태에 넣어두는 방식도 있겠습니다.

// ❌ 확장하기 어렵다
const [value, setValue] = useState<string>();

useEffect(() => {
  let ignore = false;
  fetchNickname().then(setValue);
  return () => { ignore = true; };
}, []);

return <input value={value} onChange={ //...

이는 리액트 철학에 부합하고 안티패턴도 없지만, 확장하기 어렵습니다. 가령 우리는 서버 상태를 관리할 때 @tanstack/react-query 와 같은 라이브러리를 이용합니다. 이러면 방법이 없습니다.

const { data } = useQuery(/* ... */);
// 🤷 어떻게 할 건데

또는 next.js 를 이용한다면 서버 데이터를 pageProps 로 받아오기도 합니다. 이 경우에도 방법이 없습니다.

export default function Page({ nickname }) {
  // 🤷 어떻게 할 거냐고
  // ...
}

export const getServerSideProps = async () => {
  const nickname = await fetchNickname();
  return { props: { nickname } };
}

즉 이 방식은 모든 상황에서 적용하기는 어렵다는 단점이 있습니다.

또한, 이렇게 처리하면 수정 전의 원본 데이터를 알기 어렵다는 단점도 존재합니다.



✅ draft state로 해결한다

다시 처음에 소개한 draft 패턴으로 돌아가 보겠습니다.

nickname 예시에서

위의 nickname 예시는 draft 를 활용했다면 어떻게 할 수 있을까요?

const useNicknameForm = (nickname: string) => {
  const [draft, setDraft] = useState<string>();
  
  const value = draft ?? nickname;
  
  const onChangeValue = (newValue: string) => setDraft(newValue);
  
  return { value, onChangeValue };
}

이 코드는 react 안티패턴도 없고, 사용자 경험도 훌륭하며, 불필요한 useEffect 도 이용하지 않고, 어느 코드베이스에도 훌륭하게 결합될 수 있습니다.


기능 확장이 용이하다

const useNicknameForm = (nickname: string) => {
  const [draft, setDraft] = useState<string>();
  
  const value = draft ?? nickname;
  
  const onChangeValue = (newValue: string) => setDraft(newValue);
  
  const isChanged = draft !== nickname; // 변경했는지만 확인한다
  const isTouched = draft !== undefined; // 지웠다 다시 쓴 것도 찾아준다
  const onReset = () => setDraft(undefined); // 초기값으로 돌려두기
  
  return { value, isChanged, isTouched, onReset, onChangeValue };
}

이렇게 isChanged, isTouched, onReset 등의 기능을 확장하기도 매우 쉽습니다. 기존 방식들은 이런 확장들을 모두 대응해주기에는 한계가 있습니다.



객체로의 확장

draft 패턴은 객체 형태의 폼을 다룰 때도 매우 편리합니다. 객체라면 다음과 같은 아이디어로 접근할 수 있습니다.

변경된 키값만 저장한다

이를 위해 초기 상태를 undefined 가 아닌 Partial<데이터> 로 둘 것입니다.

가령 닉네임과 나이를 수정하는 form 이 있다면,

type User = { nickname: string, age: number };

const useUserForm = (user: User) => {
  const [draft, setDraft] = useState<Partial<User>>({});
  
  const value = { ...user, ...draft };
  
  const onChange = <K extends keyof User>(key: K, value: User[K]) => {
    setDraft({ ...draft, [key]: value });
  };
  
  const onReset = () => setDraft({});
  
  return { value, onChange, onReset };
};

이렇게 spread 연산자를 활용하여 간단하게 구현할 수 있겠습니다. value 를 만들 때 draft 에 있는 키값들이 user 에 있는 키값들을 덮어쓰기 때문에, 수정한 부분들만 value 에 반영됩니다.



사실 익숙한 패턴입니다.

draft 상태는 사실 특별한 패턴이 아닙니다. 글 초반에 언급했던 draft state의 철학은 아래와 같습니다.

  • 상태 초기값을 비워둡니다.
  • 값이 변경되면 그제서야 상태에 반영합니다.
  • 보여줘야 하는 value는 데이터와 상태를 조합하여 계산합니다.

우리는 이런 코드를 일상적으로 계속 구현해 왔습니다. 가령 세 개의 탭 중 하나를 선택할 때 우리는 이렇게 구현합니다.

type Tab = { id: number; label: string };

const useTab = (tabs: Tab[]) => {
  const [selectedTabId, setSelectedTabId] = useState<Tab['id']>();
  
  const selectedTab = tabs.find(t => t.id === selectedTabId) ?? tabs[0];
  
  const onSelectTab = (tabId: Tab['id']) => setSelectedTabId(tabId);
  
  return { selectedTab, onSelectTab };
};

사실 이 코드는 draft state와 동일한 철학을 가지고 있습니다.

  • 상태 초기값을 비워둡니다.
  • 상태가 수정되면 (onSelectTab) 그제서야 상태에 반영합니다.
  • 보여줘야 하는 값 selectedTab 은 데이터(tabs)와 상태(selectedTabId) 를 조합하여 계산으로 풀어냅니다.

그러니까, draft state 는 우리에게 개념적으로 익숙하지만 왜인지 폼을 처리할 때 잘 이용되지 않았습니다.




결론

draft state 를 이용하면 버그도 없고 여러 프레임워크와도 자유롭게 결합하고 리액트 철학에도 부합하는 상태를 설계할 수 있습니다.

심지어는 폼 처리가 아닌 곳에서도 이 아이디어를 활용해본 만큼 많은 곳에 적용할 수 있는 유용한 아이디어라고 생각합니다.

react-querynextjs 등에서 폼처리로 골치아파보셨던 분들이라면 한번쯤 도입해보시는 것도 좋을 것 같습니다 :)

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

2개의 댓글

comment-user-thumbnail
2024년 5월 28일

항상 좋은 글 감사드립니다!!

답글 달기
comment-user-thumbnail
2024년 6월 10일

간단하면서 유용한 컨셉이네요! 마운트 시점에 주어진 initial value를 유지해야하는 케이스, 예를 들어 mount 될 때 current time을 initial value로 가져야하는 케이스 등에서만 조금 유의해서 쓰면 좋을 것 같아요. 기억하고 있다가 바로 사용해봐야겠습니다. 감사합니다 ㅎㅎ

답글 달기