본문 바로가기
FE/react

[React] 언제 어떻게 state와 useEffect를 사용해야할까?

by s0ojin 2023. 10. 13.

 

들어가며


 인턴 1~2주차에 react state management에 대한 과제를 받고 피드백을 받았다.

해당 글은 그 첫번째 과제에서 실수했던 점과 배운점을 정리한 내용이다. 첫번째 과제는 user가 text를 입력하는 동시에 글자수, 문장수 등을 동시에 계산해서 보여주는 간단한 앱이였다. 혹시 동일한 과제를 직접 수행해보고 싶다면 아래 글을 참고하면 되겠다. 

https://www.codevertiser.com/reactjs-challenge-1-text-analyzer-tool/

 

피드백 이전 코드


귀찮더라도 위 앱 캡쳐사진과 코드를 한번 읽어보면서 이 코드가 잘못된 점이 어딘가를 생각해보자. 

const App = () => {
  const textRef = useRef<HTMLTextAreaElement>(null);
  const [text, setText] = useState('');
  const [result, setResult] = useState({
    words:0,
    characters:0,
    sentences:0,
    paragraphs:0,
    pronouns:0
  });
  const [bottomResult, setBottomResult] = useState({
    averageReadingTime: '-',
    longestWord: '-'
  })

  useEffect(()=>{
    textRef.current?.focus();
  },[])

  const changeHandler = (event:React.ChangeEvent<HTMLTextAreaElement>) => {
    setText(event.target.value);
  }

  useEffect(()=>{
    setResult({
      words:text.split(' ').filter((word:string)=>word !== '').length,
      characters:text.length,
      sentences:text.split(/[.?!|\\n|\\r]+/).filter((sentence:string)=>sentence !== '').length,
      paragraphs:text.split('\\n').filter((paragraph:string)=>paragraph !== '').length,
      pronouns:countPronouns(text)
    })
    setBottomResult({
      averageReadingTime: text.length > 0 ? `~${Math.ceil(text.split(' ').filter((word:string)=>word !== '').length / 225)} minute` : '-',
      longestWord: text.length > 0 ? searchLongestWord(text) : '-'
    })
  },[text])

  return (
    <></>
  )
}

export default App

 혹시 이 코드가 불편하지 않다면 내가 그랬던 것처럼 react에서 언제 state를 사용하고, 언제 useEffect를 사용하는지에 대해 생각해본적이 없었을 가능성이 매우 높다. 우선 내가 어떤 생각으로 이 코드를 작성했는지 간단히 설명해보자면 이렇다.

 

  1. 우선 화면에서 값이 변하며 재렌더링을 시켜줘야하는 값들은 다 state여야한다. 그래서 text filed, words, character, sentences...다 state이다. 그러나 state가 너무 많아 불편하니 상단 계산값, text, 하단 계산값 이렇게 3개의 상태로 합친다.
  2. 화면이 최초 mount될 때(새로고침될 때) focus되어야하므로, 의존성 배열이 빈 useEffect안에 focus를 넣어준다. 
  3. text가 변할 때(작성될 때) 값들이 자동으로 계산되어 보여져야하므로,  result/bottomResult state의 setter를 useEffect에 넣어준다. 의존성배열에 text를 넣어주는 것도 잊지않는다!
  4. text의 경우 user가 입력할 때마다 변경되니까 change이벤트 핸들러 안에 setter를 넣어준다. 

 이 코드는 잘썼고 못썼고를 떠나서 일단 굉장히 잘 작동한다. 나는 여태껏 우선 작동하게 하는 것이 중요했던 경우가 많았고, 유지보수를 고려할만한 큰 프로젝트도 해본 적도 없다. 그래서 코드의 품질이나 렌더링 사이클까지는 생각하지못했다. 아니 정확히는 내가 쓴 코드가 잘못쓴 코드인지 인식조차 못하고 있었다.

 

피드백 이후 코드


const App = () => {
  const textRef = useRef<HTMLTextAreaElement>(null);
  const [text, setText] = useState('');

  useEffect(()=>{
    textRef.current?.focus();
  },[])

  const onChangeHandler = (event:React.ChangeEvent<HTMLTextAreaElement>) => {
    setText(event.target.value);
  }

  const result = {
    words: text.split(' ').filter((word:string)=>word !== '').length,
    characters: text.length,
    sentences: text.split(/[.?!|\\n|\\r]+/).filter((sentence:string)=>sentence !== '').length,
    paragraphs: text.split('\\n').filter((paragraph:string)=>paragraph !== '').length,
    pronouns: countPronouns(text)
  };

  const bottomResult = {
    averageReadingTime: text.length > 0 ? `~${Math.ceil(text.split(' ').filter((word:string)=>word !== '').length / 225)} minute` : '-',
    longestWord: text.length > 0 ? searchLongestWord(text) : '-'
  };

  return (
    <></>
  )
}

export default App

 

달라진 점은 다음과 같다.

  1. 우선 state가 3개에서 text state 1개로 줄었다.
  2. 다른 계산값들은 text state를 기반으로 계산되는 값이므로, state를 만들지 않고 const로 만들어준다. 이렇게 변경하더라도, text가 변경될 때마다 리렌더링이 일어나며 계산된 값이 동시에 변경되게 된다. 오히려 이전 코드를 보면, text가 변경되어 리렌더링이 되었는데, useEffect에 text가 변경될 때마다 result와 bottomResult가 재계산되어서 불필요한 리렌더링이 또 발생하게된다.

 

 과제 진행 후 이전 코드를 되짚어보면서 내가 언제 state와 useEffect를 써야하고 쓰면안되는지에 대해 전혀 모르고 있다는 생각이들었다.

이 부분을 보완하고자 공식문서를 읽게되었는데 내가 작성한 코드들이 대표적인 안티패턴 예시들로 소개되고있었다. 아래는 공식문서의 일부를 번역한 내용이다.

 

 

Choosing the State Structure


다음의 경우에는 state로 사용하지 마세요.

  • 시간이 지남에 따라 변하지 않는 값
  • props로 넘어온 값
  • 이미 존재하는 state로 계산할 수 있는 값

 

1. Group related state. 두개이상의 상태가 동시에 업데이트 되는 경우, 하나의 상태로 병합한다.

2. Avoid redundant state. 기존에 존재하는 상태나 프롭스를 기반으로 렌더링할 때 계산할 수 있는 요소를 또다른 상태로 만들지 않는다.

// from
  const ProfileFieldSet = () => (
  const [fullName, setFullName] = useState('');
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
);

// to
  const ProfileFieldSet = () => (
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const fullName = `${firstName} ${lastName}`;
);

 

 

3. Avoid contradictions in state. 상태가 여러개의 다른 부분으로 구성되어있어 모순적인 상황이 발생하는 것을 피해야 한다. → isSending와 isSent가 동시에 true를 갖게되는 상황이 발생할 수 있으므로 차라리 typing, sending, sent 세가지의 상태를 가질 수 있는 status와 같은 한개의 상태를 만드는 것이 더 좋다.

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSending(true);
    await sendMessage(text);
    setIsSending(false);
    setIsSent(true);
  }

return<></>
}

 

 

4. Avoid duplication in state. 동일한 데이터가 여러 상태 변수 또는 중첩된 객체 내에서 중복되면 이를 동기화하기 어려우므로 가능하면 중복을 줄인다.

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {

//from
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );

//to
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find(item =>
    item.id === selectedId
  );

 

 

5. Avoid deeply nested state. 깊게 중첩된 상태는 업데이트가 불편하므로 가능한 flat하게 구조화한다.

export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Earth',
    childPlaces: [{
      id: 2,
      title: 'Africa',
      childPlaces: [{
        id: 3,
        title: 'Botswana',
        childPlaces: []
      }, {
        id: 4,
        title: 'Egypt',
        childPlaces: []
      }, {
        id: 5,
        title: 'Kenya',
        childPlaces: []
      }, {
        id: 6,
        title: 'Madagascar',
        childPlaces: []
      },
		]
	]
};

0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 43, 47],
  },
  1: {
    id: 1,
    title: 'Earth',
    childIds: [2, 10, 19, 27, 35]
  },
  2: {
    id: 2,
    title: 'Africa',
    childIds: [3, 4, 5, 6 , 7, 8, 9]
  },
  3: {
    id: 3,
    title: 'Botswana',
    childIds: []
  },
  4: {
    id: 4,
    title: 'Egypt',
    childIds: []
  },

 

You Might Not Need an Effect


You don’t need Effects to transform data for rendering.

렌더링을 위해 데이터를 변경할 경우 useEffect를 사용할 필요가 없다.

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

// from
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);

// to
  const fullName = firstName + ' ' + lastName;
}

You don’t need Effects to handle user events.

사용자 이벤트를 처리하기 위해 useEffect를 사용할 필요가 없다.

 

 

References


You Might Not Need an Effect – React

댓글