본문 바로가기

나야, 리액트 스터디

[week5] State 관리하기

안녕하세요 웹 YB 이윤지 입니다.ㅎㅎ

이번 주차가 정말 세미나에 합세에 솝커톤에... 정말 뭐가 많았죠.

레전드 정신없었지만 그래도 나리스는 빼먹을 수 없죠.

바로 가겠습니다. 렛츠기

 


State를 이용해 input 다루기

 

선언형 UI 와 명령형 UI 비교

 

명령형은 말 그대로 '명령' 하는 것.

ex) 택시 운전기사는 내가 어디로 가고 싶어하는지 몰라요. 그저 내가 가달라는 곳으로 데려다 주실 뿐...

술에 취해서 이상한 목적지를 말해도 그쪽으로 데려다 주실거예요!

 

이렇게 컴퓨터에게 스피너부터 버튼까지 각각의 요소에 UI를 어떻게! 업데이트 해야할지 '명령' 을 내리는 것이 명령형

 

하지만 코딩을 할 때 난 여기를 이렇게 하고 싶고 저렇게 하고싶고...

주절주절 말하는 것은 미래를 생각했을 때 그닥 좋은 방법은 아니겠죠.

그래서! React가 있는 거예요. (우리 지금까지 리액트 공부 잘 해왔잖아요? ㅎㅎ)

 

React 에서는 직접 UI를 조작할 필요가 없습니다.

대신에 무엇을 보여주고 싶은지 "선언" 하기만 하면 돼요!

명령형에서 들었던 택시기사님을 다시 예로 든다면,

기사님! 저 기차가 8신데..! 

이렇게 말하면 기사님은 본인이 아는 루트를 총동원해서 지름길로 가주시는 경험 한 번쯤은 해보셨을 것 같은데요...ㅎㅎ

그러니까 가고 싶은 곳. 즉 '목적지' ( 방금 든 예에서는 기차역이겠죠?) 만 말해도

우리가 몰랐던 지름길로 가주는게 리액트 입니다.

 

UI를 리액트에서 다시 구현하는 과정!

1. 컴포넌트의 다양한 시각적 state 확인하기

2. 무엇이 state 변화를 트리거하는지 알아내기

3. useState를 사용해서 메모리의 state를 표현하기

4. 불필요한 state 변수를 제거하기

5. state 설정을 위해 이벤트 핸들러를 연결하기

 

1번부터 차근차근 살펴봅시다!

 

첫 번째: 컴포넌트의 다양한 시각적 state 확인하기

먼저 사용자가 볼 수 있는 UI의 모든 "state" 를 시각화해야 합니다.

  • Empty: 폼은 비활성화된 “제출” 버튼을 가지고 있다.
  • Typing: 폼은 활성화된 “제출” 버튼을 가지고 있다.
  • Submitting: 폼은 완전히 비활성화되고 스피너가 보인다.
  • Success: 폼 대신에 “감사합니다” 메시지가 보인다.
  • Error: “Typing” state와 동일하지만 오류 메시지가 보인다.
export default function Form({
  status = 'submitting'
}) {
  ...

이 코드에서 status 의 값을 submitting에서 error, success로 바꾸면 어떻게 될까요?

이렇게 'error' 과 'success' ... 등 status prop 에 의해 '컨트롤' 되는 예제였습니다.

 

 

<Deep Dive>

더보기

컴포넌트가 많은 시각적 state를 가지고 있다면 한 페이지에서 모두 보여주는 것도 편하게 할 수 있습니다!

이런 페이지를 보통 "살아있는 스타일 가이드" 또는 "스토리북" 이라고 합니다.

두 번째: 무엇이 state 변화를 트리거하는지 알아내기

Human inputs Computer inputs
버튼 누르기
필드 입력하기
링크 이동하기... 등등등
'직접' 누르는거
종종 이벤트 핸들러가 필요할 수도!
네트워크 응답이 오거나
타임아웃이 되거나
이미지를 로딩하거나... 등등등
컴퓨터가 처리하는 것

 

 

이 두 가지 경우 모두 UI를 업데이트 하기 위해서는 state 변수를 설정해야 합니다.

  • 텍스트 인풋을 변경하면 (휴먼) 텍스트 상자가 비어있는지 여부에 따라 state를 Empty에서 Typing 으로 또는 그 반대로 변경해야 합니다.
  • 제출 버튼을 클릭하면 (휴먼) Submitting state를 변경해야 합니다.
  • 네트워크 응답이 성공적으로 오면 (컴퓨터) Success state를 변경해야 합니다.
  • 네트워크 요청이 실패하면 (컴퓨터) 해당하는 오류 메시지와 함께 Error state를 변경해야 합니다.

 

세 번째: 메모리의 state 를 useState로 표현하기

 

"단순함" 이 핵심!

각각의 state는 "움직이는 조각" , 이 조각들은 적을수록 좋아요! 많으면 복잡해지고, 복잡한건 버그를 일으키기 때문이죠!

 

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);

 

이렇게 인풋의 answer은 반드시 저장하고, 존재한다면 최근에 생긴 에러도 저장해야 하겠죠

 

그리고 위에서 필요하다고 나열했던 나머지 시각적 state도 살펴봅시다.

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

 

보통은 어떤 state 변수를 사용할지에 대한 방법이 여러 가지기 때문에 이것 저것 해봐야 할 필요가 있습니다.

위에 작성한 건 state 변수들을 분리된 상태로 관리하는 것입니다.

더보기
const [formState, setFormState] = useState({
  isEmpty: true,
  isTyping: false,
  isSubmitting: false,
  isSuccess: false,
  isError: false,
});

이건 단일 state로 관리하는 법!

 

추가로,

단일 state로 관리하면 보기 쉽고, 상태를 추가/삭제하거나 로직 변경할 때 단일 state를 조작하므로 수정이 간편하고 상태 간의 관계를 명시적으로 표시하기 쉬운 장점이 있지만

 

단일 state는 상태 객체가 변경되면 컴포넌트가 다시 렌더링 되므로, 변경되지 않은 상태까지 포함된 컴포넌트가 영향을 받아 불필요한 렌더링 가능성이 올라가고, 로직 복잡도가 증가합니다. (상태가 많아지면 업데이트 로직이 복잡해질 수도 있기 때문에!)

 

네 번째: 불필요한 state 변수 제거하기

 

리팩토링의 목표는
state가 사용자에게 유효한 UI를 보여주지 않는 경우를 방지하는 것입니다.

 

state의 중복은 피하고 정말 필요한 state만 남겨둡시다.

state 변수에 대해 몇 가지 질문이 있는데요.

 

1. state가 역설을 일으키지는 않나요? 

ex) isTyping 과 isSubmitting 이 동시에 true 일 수는 없죠. 폼을 입력중인데 제출한다? 말이 이상하죠.

여기서 T/F 에 대해 4가지 조합이 있지만 두 가지가 동시에 true 인 경우를 제외하고 유효한 state 는 세 가지 뿐입니다.

이러한 "불가능"한 state를 제거하기 위해 세 가지 값

'typing' , 'submitting', 'success' 를 하나의 status인

const[status, setStatus]= useState('typing');

으로 축약할 수 있습니다! 

 

2. 다른 state 변수에 이미 같은 정보가 담겨있진 않나요? 

마찬가지로 isEmpty 와 isTyping은 동시에 true 가 될 수 없습니다. 이를 각각의 state 변수로 분리하면 

싱크가 맞지 않거나 버그가 발생할 위험이 있습니다.

 

왜?

 

더보기

isEmpty 와 isTyping 은 서로 의존적인 상태입니다.

 

앞서 말한 isTyping 과 isSubmitting 이 서로 동시에 존재하면 안되는 이유와 비슷하죠.

입력중인데 비어있다...? 

 

그래서 isEmpty 와 isTyping은 한 상태가 변경되면 다른 상태도 함께 업데이트 되어야 합니다.

Typing 을 하고 있어 (true) -> 그럼 isEmpty 는 false,

입력 필드를 다 지우자! (isEmpty (true)) -> isTyping(false) 가 되어야 합니다.

 

const [formState, setFormState] = useState({
  isEmpty: true, // 사용자 입력중!
  isTyping: false, // 사용자 입력 안하는 중!
});

function handleInputChange(e) {
  const value = e.target.value;
  setFormState({
    isEmpty: value.length === 0, //입력 값이 비어있는 경우 value.length 는 0, isEmpty 를 true로
    isTyping: value.length > 0,// 입력 값이 하나라도 있으면 isEmpty 를 false로, isTyping 은 true로
  });
}

 

이 경우에는 isEmpty : value.length===0 으로 체크할 수 있습니다.

 

3. 다른 변수를 뒤집었을 때 같은 정보를 얻을 수 있진 않나요?

isError 은 error! ==null 로도 확인할 수 있기 때문에 필요하지 않죠

 

위의 3가지 기준에 따라 필수 변수를 정리했을 때

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

마지막은 7개에서 3개로 줄어든 예쁜 코드가 되겠네요 ㅎㅎ 

 

다섯 번째: state 설정을 위해 이벤트 핸들러 연결하기

 

까지 설정하면 끝~!

 


 

State 구조 선택하기

 

앞서 State를 사용해 input 다루기 파트를 공부하면서 단일 state랑 다중 state 둘 중 어느 것을 사용해야 하지? 하면서 공부했었는데, 다음 챕터에 바로 나와버리네요 ㅎㅎ (리액트 너는 다 계획이 있었구나)

 

State를 잘 구조화하기 위해선 어떻게 해야 할까요! 바로 들어가보겠습니다~!

 

State 구조화 원칙

1. 연관된 state 그룹화하기

단일? 다중? 

 

이해하기 편하게 위의 예시를 가져올게요? 

뭐가 뭔지 딱 아시겠나요?

왼쪽이 단일, 오른쪽이 다중입니다.

두 가지를 다 사용할 수 있지만 아까 위에서 말했던 isEmpty 와 isTyping 이 그랬던 것처럼,

하나가 변경되면 다른 하나가 업데이트 되면 단일 state 변수로 통합하는 것이 좋습니다.

 

2. State의 모순 피하기

아까 위에서도 그랬듯, 동시에 true가 되면 안되는 것들이 있죠.

import { useState } from 'react';

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

이 예제에서는 isSending 과 isSent 과 동시에 true 인 상황에 처할 수 있어요.

보내는 중인데 보내졌어! 가 되지 않기 위해서는

typing(초깃값), sending, sent 중 하나를 가질 수 있는 status state 변수로 대체하는 것이 좋습니다.

 

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState('typing'); // 이렇게요!
const isSending = status === 'sending';
const isSent = status === 'sent';

가독성을 위해 상수를 선언하는 방법도 있다고 하네요!

 

3. 불필요한 state 피하기

오른쪽과 같이 만들기 위해서는 사실 fullName 의 변수는 필요없죠.

그냥

const fullName = firstName + ' ' + lastName;

이렇게 두 개를 붙여주면 되잖아요!

 

여기서 fullName 은 state 변수가 아니고, 렌더링 중에 계산됩니다.

setFirstName 또는 setLastName을 호출하면, 다시 렌더링 하는 것을 유발하여 다음 fullName이 새 데이터로 계산됩니다!

 

4. State의 중복 피하기

말 그대로 상태의 "중복" 을 피하는 것입니다.

즉, 같은 상태를 여러 번 사용하는 것은 좋지 않습니다.

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

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );
// ...
}

items, selectedItem 상태를 살펴보면 items의 요소 중 하나를 selectedItem 상태로 가지고 있습니다.

여기서 중복이 발생됩니다.

 

그러면 어떤 상황에서 중복된 상태에 대한 오류가 나타날까요? 다음의 상황을 가정해 보겠습니다.

  • items의 요소 중 하나를 선택합니다. 선택된 요소는 selectedItem 상태가 됩니다.
  • 선택한 요소를 수정합니다. 즉, items의 상태가 업데이트됩니다.
  • 하지만 selectedItem는 수정하지 않습니다.(함께 수정을 한다면 문제가 없습니다. 하지만 이는 효율적인 상태 관리라고 할 수 없습니다.)

위와 같은 상황이 발생된다면 items와 selectedItem은 서로 연관되어 있지만 서로 같은 값을 바라보고 있지 않습니다. 이를 개발자의 실수라고 생각할 수 있지만, 상태를 더 효율적으로 구조화한다면 실수 없이 상태를 업데이트할 수 있습니다.

 

어떻게 효율적으로 구조화를 할 수 있을까요? 상태의 중복을 피하고 필수 상태만 가져오면 됩니다. items의 값들 중 특정 값이 선택되었다는 것을 어떻게 알 수 있을까요? itmes의 요소들을 보면 모두 id를 가지고 있습니다. 그러면 선택된 items의 요소의 id를 상태로 관리하면 문제는 해결됩니다. 즉, id가 필수 상태입니다.

 

난 다 몰라도 돼. 단, '필수 상태' 만은 알고 있을 거야.

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

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

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

이 코드는 중복을 피하고 필수 상태를 가져와서 구조화한 코드입니다.

setItems가 실행되면  items의 요소들이 수정되고 렌더링이 발생됩니다.

이에 따라 selectedItem은 새로운 값을 가지게 됩니다.

 

이는 세 번째 원칙인 불필요한 상태를 피하는 것과 관련 있습니다.

선택된 항목은 전체 항목과 선택된 항목의 아이디를 통해 렌더링 중에 계산할 수 있기 때문이죠!

 

5. 깊게 중첩된 state 피하기

깊게 중첩? 이게 뭐지?

사진처럼 2중, 3중 배열을 생각하면 편합니다.

이런 것들이 상태로 존재한다면...?!

보기만 해도 복잡한데 상상만으로도 굉장히 복잡할 것 같지 않나요?

이 때문에 깊게 중첩된 상태를 피하고, 최대한 평평하게 만드는 것이 중요합니다.

If the state is too nested to update easily, consider making it “flat”.
"상태가 만약 너무 깊게 중첩되어 업데이트하기 어렵다면, 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: []
      }, {
      
 
// ----------------- 옳은 예시 구분선 -------------------


export const initialTravelPlan = {
  0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 42, 46],
  },
  1: {
    id: 1,
    title: 'Earth',
    childIds: [2, 10, 19, 26, 34]
  },
  2: {
    id: 2,
    title: 'Africa',
    childIds: [3, 4, 5, 6 , 7, 8, 9]
  },

 


컴포넌트 간 State 공유하기

 

두 컴포넌트의 state가 항상 함께 변경되기를 원할 수 있다. 그렇게 하려면,

  1. 각 컴포넌트에서 state를 제거
  2. 가장 가까운 공통의 부모 컴포넌트로 옮긴 후
  3. props로 전달해야 한다.

⇒ 이 방법을 “State 끌어올리기”라고 하며 React 코드를 작성할 때 가장 흔히 하는 일 중 하나다.

 

  1. <State 끌어올리는 방법>
    1. 두 컴포넌트를 조정하고 싶을 때, state를 그들의 공통 부모로 이동한다.
    2. 그리고 공통 부모로부터 props를 통해 정보를 전달한다.
    3. 마지막으로 이벤트 핸들러를 전달해 자식에서 부모의 state를 변경할 수 있도록 한다.
  2. 컴포넌트를 (props로부터) “제어”할지 (state로부터) “비제어”할지 고려하면 유용하다.

 

공식문서의 예제를 보면

import { useState } from 'react';

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          Show
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About">
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel title="Etymology">
        The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
      </Panel>
    </>
  );
}

이 예시코드를 실제로 실행시켜보면 한 패널의 show 버튼을 눌러도 다른 패널에 영향을 미치지 않고

독립적으로 작동하는 것을 볼 수 있습니다.

 

하지만, 이제 한번에 하나의 패널만 열리도록 변경하려면 어떻게 해야할까요?

바로 부모 컴포넌트로 패널의 state가 lifting up 되어야 한다!

  1. 자식 컴포넌트의 state를 제거
  2. 하드 코딩된 값을 공통의 부모로부터 전달
  3. 공통 부모 컴포넌트에 state를 추가하고 이벤트 핸들러와 함께 전달

Step 1

const [isActive, setIsActive] = useState(false);

Panel 컴포넌트에서 isActive state를 제거한 다음, 가장 가까운 공통 부모 컴포넌트에서 컨트롤 할 수 있도록 합니다.

동시에 Panel Props에 isActive를 추가하기.
(부모로부터 isActive 상태를 받아올 수 있어야 하니까!)

Step 2

부모인 Accordion 컴포넌트 내부에서 2개의 Panel 컴포넌트에 isActive props를 내려준다.

(일단은 true로 내려주도록)

export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About" isActive={true}>
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel title="Etymology" isActive={true}>
        The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
      </Panel>
    </>
  );
}

Step3

공통부모인 Accordion에 아래와 같이 두개의 자식 컴포넌트에서 참조할 state를 추가한다.

const [activeIndex, setActiveIndex] = useState(0);

그 다음, 자식 컴포넌트인 Panel에서 state를 변경할 수 있도록 이벤트 핸들러를 넘겨주어야 하는데,

자식인 Panel 컴포넌트에서 부모 컴포넌트의 state를 변경할 이벤트핸들러 함수를 받을 수 있도록

props에 onShow를 추가한다.

요약

  • Panel 자식 컴포넌트
    부모 컴포넌트의 state를 변경할 이벤트 핸들러를 props로 전달받도록 props에 onShow를 추가
  • Accordion 부모 컴포넌트
    서로다른 2개의 자식 컴포넌트에서 참조할 state를 만들어두고, state값과 state값을 변경할 setter를 poprs로 넘겨주기

궁극적으로 첫번째 Panel은 state가 0일때 show되고, 2번째 Panel은 state가 1이될때 show가 되는 것을 확인 할 수 있습니다!


와아 이렇게 5주차 나리스 아티클 작성을 마쳤습니다!

사실 이번 주차 정말... 빡셌는데 다들 수고하셨어요 😂😂😂

 

이번 주차는 state 관리에 대해 중점적으로 파헤쳐 볼 수 있는 시간이었던 것 같은데요!

input 을 다뤄보는 것 부터 공유까지 계속 이어지는 내용이라 더 이해가 잘 됐던 것 같습니다.ㅎㅎ

 

이번 주차에서 궁금했던 점은...

state 구조 선택하기- Deep Dive 쪽에서 '메모리 사용량 개선하기' 라는 주제가 있었는데

Immer을 사용하더라구요 state의 평탄화 작업을 위해서도 많이 사용한다고 하는데

immer 말고 중복처리하는 우리 나리스 팀원들만의 방법이 있는지, 아니면 공식문서처럼 하는지가 궁금합니다 -!

 

이번 주차 정말정말정말 다들 너무너무너무 수고 많으셨고 푹 쉬고 행복한 하루 보내길!

웨비 짱 나리스 짱~!