파생 상태에 관하여

2024년 4월 30일

파생 상태(Derived state)란 이전에 정의 되었던 상태들을 통해 계산할 수 있는 상태를 의미한다.

즉, 이전 정보로 계산할 수 있는 정보는 상태에 넣지 않고. 필요한 상태들만 최소한으로 관리하는 것이다.

변수를 상태에 저장하기보다, 변경사항이 생길때의 데이터를 동기화 상태로 유지하는것이 더 간단하다.

- 예시

interface OptionsType {
  [key: string]: string[];
}

const GENRES = ['KPOP', 'Hiphop', 'Indie'];

const Options: OptionsType = {
  KPOP: ['Impossible', 'Kingdomcome', 'Wish'],
  Hiphop: ['False Prophets', 'Midnight', 'Waves'],
  Indie: ['Surf', 'Abeja'],
};

const GenreOption = ({ genre }: { genre: string }) => {
  return (
    <div style={{ display: 'flex' }}>
      <input type={'checkbox'} />
      <h3>{genre}</h3>
    </div>
  );
};

const SongOption = ({ song }: { song: string }) => {
  return (
    <div>
      <input type={'checkbox'} />
      <span>{song}</span>
    </div>
  );
};

export default function DerivedState() {
  return (
    <div>
      {GENRES.map((genre) => {
        return (
          <>
            <GenreOption genre={genre} />
            {Options[genre].map((song) => {
              return <SongOption song={song} />;
            })}
          </>
        );
      })}
    </div>
  );
}

위와 같은 상태가 있다고 가정해보자.

KPOP, Hiphop, Indie 총 3개로 장르가 나누어져있고,

그 장르에 맞는 곡들에 관한 배열 객체가 존재한다.

여기서 원하는것은 장르 자체에 대한 체크박스를 클릭했을때는 해당 하위의 모든 체크박스들이 체크가 되어야한다.

하지만 각 개별 노래에 대한 체크박스를 클릭한다면 해당하는 노래만 체크 되어야 한다.

어떻게 상태 관리를 해주어야할까 ?

개별 선택된 노래들에 대한 상태와, 장르 자체를 선택하는 상태 2개를 둠으로써 관리하려고 할 수 있다.

const [selectedOption, setSelected] = useState(Options);
const [isSelectedAllOptions, setIsSelectedAllOptions] = useState(false);

하지만 공식문서 리액트로 사고하기 섹션을 보면 컴포넌트 안에 다른 state나 props를 가지고 계산 가능한 값이 존재한다면, 그것은 state로 관리할 필요가 없다고 기재되어있다.

위의 예시에서는 각 노래에 대한 상태 값만 가지고 있다면 전체 장르 체크에 대한 상태는 굳이 state로 선언해주지 않더라도 관리가 가능하다.


import { useState } from 'react';

interface OptionsType {
  [key: string]: string[];
}

interface SongSelectionsType {
  [song: string]: boolean;
}

const GENRES = ['KPOP', 'Hiphop', 'Indie'];

const Options: OptionsType = {
  KPOP: ['Impossible', 'Kingdomcome', 'Wish'],
  Hiphop: ['False Prophets', 'Midnight', 'Waves'],
  Indie: ['Surf', 'Abeja'],
};

type GenereOptionProps = {
  genre: string;
  handleSelectGenre: React.ChangeEventHandler<HTMLInputElement>;
  isChecked: boolean;
};

const isGenreChecked = (genre: string, songSelections: SongSelectionsType) => {
  return Options[genre].every((song) => songSelections[song]);
};

const GenreOption = ({ genre, handleSelectGenre, isChecked }: GenereOptionProps) => {
  return (
    <div style={{ display: 'flex' }}>
      <input onChange={handleSelectGenre} type={'checkbox'} checked={isChecked} />
      <h3>{genre}</h3>
    </div>
  );
};

type SongOptionProps = {
  song: string;
  handleSongSelections: React.ChangeEventHandler<HTMLInputElement>;
  isChecked: boolean;
};

const SongOption = ({ song, handleSongSelections, isChecked }: SongOptionProps) => {
  return (
    <div>
      <input onChange={handleSongSelections} type={'checkbox'} checked={isChecked} />
      <span>{song}</span>
    </div>
  );
};

export default function DerivedState() {
  const [songSelections, setSongSelections] = useState<SongSelectionsType>({});

  // 각각의 개별 노래를 선택하는 경우
  const handleSongSelections = (e: React.ChangeEvent<HTMLInputElement>, song: string) => {
    setSongSelections({ ...songSelections, [song]: e.target.checked });
  };

  // 장르 체크 버튼을 클릭하는 경우
  const handleSelectGenre = (e: React.ChangeEvent<HTMLInputElement>, genre: string) => {
    const newSongSelections = { ...songSelections };

    Options[genre].forEach((song) => (newSongSelections[song] = e.target.checked));

    setSongSelections(newSongSelections);
  };

  return (
    <div>
      {GENRES.map((genre, index) => {
        return (
          <div style={{ borderBottom: '1px solid black' }} key={index}>
            <GenreOption
              isChecked={isGenreChecked(genre, songSelections)}
              handleSelectGenre={(e) => handleSelectGenre(e, genre)}
              genre={genre}
            />
            {Options[genre].map((song, index) => {
              return (
                <SongOption
                  key={index}
                  isChecked={Boolean(songSelections[song])}
                  handleSongSelections={(e) => handleSongSelections(e, song)}
                  song={song}
                />
              );
            })}
          </div>
        );
      })}
    </div>
  );
}

위에서 코드를 보면 songSelections라는 상태를 하나만 가지고 있지만, 하나의 상태로 각각의 개별 상태와 모든 경우가 체크 된 경우의 상태 2개를 관리해주는 것을 알 수 있다.

이번 페이먼츠 미션에서도 마찬가지였다.

  1. 카드번호
  2. 카드사
  3. 카드 유효기간
  4. 카드 등록자 이름
  5. 카드 CVC
  6. 카드 비밀번호

에 대한 모든 상태 값이 유효해야지만 확인버튼이 렌더링 되어야했다.

const useDetectComplete = ({
  cardNumbers,
  month,
  year,
  cvc,
  password,
  name,
}: UseDetectCompleteHookProps) => {
  const [isValidAllFormStates, setIsValidAllFormStates] = useState(false);

  useEffect(() => {
    const totalCardNumbers = cardNumbers
      .map((cardNumber: InitialCardNumberState) => cardNumber.value)
      .join('');

    if (
      month.length === MAX_LENGTH.MONTH &&
      year.length === MAX_LENGTH.YEAR &&
      totalCardNumbers.length === CARD_NUMBER.TOTAL_MAX_LENGTH &&
      cvc.length === MAX_LENGTH.CVC &&
      password.length === MAX_LENGTH.PASSWORD &&
      name.length
    ) {
      setIsValidAllFormStates(true);

      return;
    }

    setIsValidAllFormStates(false);
  }, [month, year, cardNumbers, cvc, name, password]);

  return { isValidAllFormStates };
};

export default useDetectComplete;

그래서 이런식으로 useDetectComplete라는 커스텀 훅을 만들어주어서 각 입력값에 대한 유효성 검사를 실시간으로 진행해주었다.

하지만 훅이 아니라 아예 함수로 관리해주는것이 어떻겠냐는 리뷰어분의 피드백을 받았다.

export default function RegisterCardInfoPage() {
  // cardNumbers 상태
  // month 상태
  // year 상태
  // password 상태
  // cvc 상태
  // name 상태
  const isValidAllFormStates = validate.isValidAllFormStates({
    cardNumbers,
    month,
    year,
    password,
    cvc,
    name,
  });
}

그래서 이런식으로 하나의 함수로 관리해주어서 상태값이 변할때마다 유효성 검증일 진행하도록 하였다.

이러한 과정을 겪고 난 후에 파생 상태라는 것에 대해서 공부해보니 감회가 새로웠다.

항상 state 선언 전에 이것이 정말 상태가 필요한것인가? 에 대해서 되짚어보는 연습이 필요하다고 느꼈다.