Development/React

React) useState에서 놓치기 쉬운 사실들 (1)

duckworth 2022. 12. 23. 00:40

React로 개발을 하다보면 useState hook을 가장 많이 사용하게 됩니다.

오늘은 컴포넌트가 props를 받아와서 state(상태)를 초기화 할때 쉽게 함정에 빠지는 경우를 소개하려합니다.

먼저 오늘의 핵심 코드를 보고 시작하겠습니다.

핵심 코드

import { useState } from "react";

interface IDetailViewProps {
  initialColor: string;
}

const fruits = [
  {
    id: 1,
    name: "banana",
    color: "yellow",
  },
  {
    id: 2,
    name: "apple",
    color: "red",
  },
];

const Pratice: React.FunctionComponent = () => {
  const [selected, setSelected] = useState(fruits[0]);

  return (
    <>
      <div>
        {fruits.map((fruit) => (
          <button
            type="button"
            key={fruit.id}
            onClick={() => setSelected(fruit)}
          >
            {fruit.id === selected.id ? fruit.name.toUpperCase() : fruit.name}
          </button>
        ))}
        <DetailView initialColor={selected.color} />
      </div>
    </>
  );
};

const DetailView: React.FunctionComponent<IDetailViewProps> = ({
  initialColor,
}) => {
  const [color, setColor] = useState(initialColor);

  return (
    <div>
      <input
        type="text"
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      <button type="button" onClick={() => alert(color)}>
        Apply
      </button>
    </div>
  );
};

export default Pratice;

코드를 실행해보겠습니다.

현재 과일 버튼을 클릭하면 DetailView 컴포넌트의 input의 value는 변경되지 않습니다.

오늘 우리의 목표는 과일 이름을 클릭했을 때 DetailView 컴포넌트의 color state도 바꾸려합니다.

3가지 방법을 통해서 구현해보겠습니다.

1. 조건부 렌더링

import { useState } from "react";

interface IFruit {
  id: number;
  name: string;
  color: string;
}

interface IDetailViewProps {
  initialColor: string;
  close: () => void;
}

const fruits = [
  {
    id: 1,
    name: "banana",
    color: "yellow",
  },
  {
    id: 2,
    name: "apple",
    color: "red",
  },
];

const Pratice: React.FunctionComponent = () => {
  const [selected, setSelected] = useState<IFruit>();

  const close = () => setSelected(undefined);

  return (
    <div className="flex justify-center items-center">
      {fruits.map((fruit) => (
        <button
          type="button"
          key={fruit.id}
          onClick={() => setSelected(fruit)}
          className="first:mr-3"
        >
          {fruit.name}
        </button>
      ))}
      {selected && (
        <div className="fixed top-0 left-0 pt-25 w-full h-full bg-transparent">
          <div className="flex justify-center w-5/6 h-[50vh] m-auto bg-black">
            <DetailView initialColor={selected.color} close={close} />
            <span className="cursor-pointer ml-3" onClick={close}>
              &times;
            </span>
          </div>
        </div>
      )}
    </div>
  );
};

const DetailView: React.FunctionComponent<IDetailViewProps> = ({
  initialColor,
  close,
}) => {
  const [color, setColor] = useState(initialColor);

  return (
    <div>
      <input
        type="text"
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      <button
        type="button"
        onClick={() => {
          alert(color);
          close();
        }}
      >
        Apply
      </button>
    </div>
  );
};

export default Pratice;

흔히 사용되는 모달로 조건부 렌더링을 하여 selected state가 값이 존재할때만 컴포넌트를 다시 렌더링합니다.(버튼을 누르면 값이 변경되어 리렌더링이 발생함)

중점은 모달 안에서만 DetailView 컴포넌트를 렌더링한다는 것입니다. 

color state을 변경한다는 목적은 달성했지만 DetailView 컴포넌트를 어디서든 렌더링 하기 위해서는 다른 방법을 사용해야합니다.

2. State 끌어올리기(Lifting State Up) 

import { useState, Dispatch, SetStateAction } from "react";

interface IFruit {
  id: number;
  name: string;
  color: string;
}

interface IDetailViewProps {
  fruit?: string;
  setFruit: Dispatch<SetStateAction<string | undefined>>;
}

const fruits = [
  {
    id: 1,
    name: "banana",
    color: "yellow",
  },
  {
    id: 2,
    name: "apple",
    color: "red",
  },
];

const Pratice: React.FunctionComponent = () => {
  const [selected, setSelected] = useState<IFruit>();
  const [fruit, setFruit] = useState(selected?.color);

  return (
    <div className="flex justify-center items-center flex-col">
      <div>
        {fruits.map((fruit) => (
          <button
            type="button"
            key={fruit.id}
            onClick={() => {
              setSelected(fruit);
              setFruit(fruit.color);
            }}
            className="first:mr-3"
          >
            {fruit.id === selected?.id ? fruit.name.toUpperCase() : fruit.name}
          </button>
        ))}
      </div>
      <DetailView fruit={fruit} setFruit={setFruit} />
    </div>
  );
};

const DetailView: React.FunctionComponent<IDetailViewProps> = ({
  fruit,
  setFruit,
}) => {
  return (
    <div>
      <input
        type="text"
        value={fruit}
        onChange={(e) => setFruit(e.target.value)}
      />
      <button type="button" onClick={() => alert(fruit)}>
        Apply
      </button>
    </div>
  );
};

export default Pratice;

State를 상위 컴포넌트인 Pratice로 끌어올렸습니다. 덕분에 DetailView는 어떤 state도 없는 dumb 컴포넌트가 되었습니다.

이 코딩 패턴은 치명적인 단점이 한가지 있는데요, 우리가 input에 한 글자 입력할때마다 Pratice 컴포넌트 전체가 리렌더링 된다는 것입니다.

지금 같은 작은 규모의 웹 앱에선 문제가 없지만 현업에 쓰이는 큰 규모의 앱일수록 이런 코딩 방식은 문제가 생길것입니다.

3. key를 기반으로 리렌더링

이 코드는 맨 처음 나온 핵심 코드 예제에서 한 줄만 수정된 것입니다.

import { useState } from "react";

interface IDetailViewProps {
  initialColor: string;
}

const fruits = [
  {
    id: 1,
    name: "banana",
    color: "yellow",
  },
  {
    id: 2,
    name: "apple",
    color: "red",
  },
];

const Pratice: React.FunctionComponent = () => {
  const [selected, setSelected] = useState(fruits[0]);

  return (
    <div className="flex justify-center items-center flex-col">
      <div>
        {fruits.map((fruit) => (
          <button
            type="button"
            key={fruit.id}
            onClick={() => setSelected(fruit)}
          >
            {fruit.id === selected.id ? fruit.name.toUpperCase() : fruit.name}
          </button>
        ))}
        //유일하게 바뀐 코드입니다. key props를 작성하였습니다.
        <DetailView key={selected.id} initialColor={selected.color} />
      </div>
    </>
  );
};

const DetailView: React.FunctionComponent<IDetailViewProps> = ({
  initialColor,
}) => {
  const [color, setColor] = useState(initialColor);

  return (
    <div>
      <input
        type="text"
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      <button type="button" onClick={() => alert(color)}>
        Apply
      </button>
    </div>
  );
};

export default Pratice;

 

DetailView 컴포넌트에 key만 props로 추가되었을 뿐인데도 버튼을 누르면 본래의 목적이던 input 값이 변경이 됩니다.

key는 보통 리액트의 리스트에서 안정화를 위해 쓰이는데요, reconciler(조정자)라는 존재가 어떤 요소를 재사용할지 다시 렌더링 할지 알고 있습니다.

따라서 key가 변경되면 해당 데이터를 리렌더링 해줍니다. 반대로 key가 같으면 그대로 둡니다. 

key의 리렌더링 방식은 어찌보면 useEffect에서 종속성 배열이 변경되면 컴포넌트를 다시 마운트 해주는 방식과 유사합니다. 

 

Q. useEffect로 코드를 짜보면 어떻게 될까요?

const DetailView: React.FunctionComponent<IDetailViewProps> = ({
  initialColor,
}) => {
    const [color, setColor] = useState(initialColor);

    React.useEffect(() => {
        setColor(initialColor)
    }, [initialColor])

    return (...)
}

이 코딩 패턴은 최대한 피해야하는 패턴(anti-pattern)입니다. 동기화(syncing)에 useEffect를 사용한다면 리액트 state를 리액트 외부(e.g. LocalStorage)와 동기화 할때 사용합니다.

하지만 DetailView 컴포넌트는 리액트 내부에 있는 initialColor를 state와 동기화하고 있습니다.

또한 동기화 조건은 이번 글의 목표 달성에 실제로 반영되지 않습니다. 그래서 setColor로 색깔이 변경될 때가 아니라 다른 과일이 선택될 때마다 상태를 재설정하려고 합니다.

 

만약에 과일 2개가 참외,바나나처럼 동일한 색깔을 가지고 있으면 어떻게 될까요?

다른 과일을 클릭해도 useEffect가 다시 실행되지 않으므로 state가 재설정되지 않습니다.

부모 컴포넌트에서 데이터가 변경되었지만 유저가 이미 input 값을 변경시킨다면 어떻게 될까요?이런 경우 추적하기 힘든 오류가 발생하게 될 것입니다.

 

결론: 정해진 방식은 없다

3가지 방식 모두 장단점이 있으며 상황에 맞게 사용하는 것이 좋습니다. 

 

 

Reference:

https://tkdodo.eu/blog/putting-props-to-use-state