sohyeon kim

[React] 리액트 공식문서 정리 3(1) : State 관리하기 - 상태를 쉽게 업데이트 하도록 구조화 본문

React

[React] 리액트 공식문서 정리 3(1) : State 관리하기 - 상태를 쉽게 업데이트 하도록 구조화

aotoyae 2024. 11. 23. 02:19
728x90

 

 

💡 리액트 공식문서 정리 : State 관리하기 - State 를 사용해 Input 다루기, State 구조 선택하기

애플리케이션이 커짐에 따라 state 가 어떻게 구성되는지, 데이터가 컴포넌트 간에 어떻게 흐르는지에 대해 파악해두면 도움이 된다.

불필요하거나 중복된 state 는 버그의 흔한 원인.

state 를 잘 구성하는 방법, state 업데이트 로직을 유지 보수 가능하게 관리하는 방법,

멀리 있는 컴포넌트 간의 state 공유 방법에 대해 알아보자.

 

1. State 를 사용해 input 다루기 : 선언형 UI, 선언형 프로그래밍

UI를 세밀하게 직접 조작하는 것(명령형)이 아니라 각각의 시각적 state로 UI를 묘사하는 것을 의미.

React 에서는 (코드로) UI 를 직접 조작할 필요가 없다. 대신에 무엇을 보여주고 싶은지 선언하기만 하면 된다.

그럼 React 는 어떻게 UI 를 업데이트 해야 할지 이해할 것.

어떻게 가야(변해야) 하는지 하나씩 명령하는 JS / 목적지가 어디인지 선언하는 React

React 에서 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 App() {
  return (
    <>
      {statuses.map(status => (
        <section key={status}>
          <h4>Form ({status}):</h4>
          <Form status={status} />
        </section>
      ))}
    </>
  );
}

 

state 에 따른 UI 변화 / 이렇게 많은 시각적 state 를 한 번에 보여주는 것을 living styleguides or storybooks 라 부른다.

 

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

두 종류의 인풋 유형으로 state 변경을 트리거할 수 있다.

  • 버튼을 누르거나, 필드를 입력하거나, 링크를 이동하는 것 등의 휴먼 인풋(종종 이벤트 핸들러가 필요)
  • 네트워크 응답이 오거나, 타임아웃이 되거나, 이미지를 로딩하거나 하는 등의 컴퓨터 인풋

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

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

 

state 변화의 흐름

 

3️⃣ 메모리의 state 를 useState 로 표현하기

이 과정은 단순함이 핵심. 각각의 state smms '움직이는 조각'이다. 조각은 적을수록 좋다. 복잡한 건 버그를 일으키기 마련!

먼저 반드시 필요한 state 로 시작해보자. 예를 들면 인풋의 answer. 그리고 (존재한다면) 가장 최근에 발생한 error.

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

 

이제 위에서 나열했던 나머지 시각적 state 도 살펴보자.

어떤 state 를 사용할지에 대한 방법은 여러가지이므로 모든 시각적 state 를 커버할 수 있는 실한 것을 먼저 추가하자.

최고의 방법은 아닐 수도 있지만, 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);

 

4️⃣ 불필요한 state 변수 제거하기

state 구조를 리팩토링하는 데 시간을 투자하면 컴포넌트는 더 이해하기 쉬워지고 불필요한 중복은 줄어들 것이다.

리팩토링의 목표는 state 가 사용자에게 유효한 UI 를 보여주지 않는 경우를 방지하는 것.
예시로, 오류 메세지가 나타났는데 인풋이 비활성화되어 있어 유저가 오류를 수정할 수 없는 상황은 원하지 않을 것이다!

 

위 state 변수에 관한 질문

  • state가 역설을 일으키지는 않나요?
    예를 들어 isTyping 과 isSubmitting 이 동시에 true 일 순 없다. 이러한 역설은 보통 state 가 충분히 제한되지 않았음을 의미.
    여기엔 두 boolean 에 대한 네 가지 조합이 있지만 오직 유효한 state 는 세 개뿐이다.
    이러한 불가능한 state 를 제거하기 위해 'typing', 'submitting', 'success' 세 값을 하나의 status 로 합칠 수 있다.
  • 다른 state 변수에 이미 같은 정보가 담겨있진 않나요?
    isEmpty 와 isTyping 은 동시에 true 가 될 수 없다. 이를 각각의 state 변수로 분리하면 싱크가 안맞거나 버그가 발생 위험이 있다.
    이 경우엔 isEmpty 를 지우고 answer.length === 0 으로 체크할 수 있다.
  • 다른 변수를 뒤집었을 때 같은 정보를 얻을 수 있진 않나요?
    isError 는 error !== null 로도 대신 확인할 수 있기에 필요하지 않다.

이러한 정리 과정을 거치면 세 가지(일곱 개에서 줄어든!) 필수 변수 만 남게 된다.

// 어느 하나를 지웠을 때 정상적으로 작동하지 않는다는 점에서 이것들이 모두 필수라는 것을 알 수 있다.
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

 

🧐 Reducer 를 사용하여 '불가능한' state 제거

폼 state 를 나타내는데 충분한 세 가지 변수를 구성했지만, 여전히 부자연스러운 일부 중간 state 가 있다.

예를 들어, error 가 null 이 아닌데 status 가 success 인 것은 말이 되지 않는다.

state 를 좀 더 정확하게 모델링하기 위해 리듀서로 분리하면 여러 state 변수를 하나의 객체로 통합하고 관련된 모든 로직도 합칠 수 있다.

 

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

이러한 코드가 명령형 프로그래밍 예시보단 길지만 조금 더 견고하다.

모든 상호작용을 state 로 표현하게 되면 이후 새로운 시각적 state 가 추가되더라도 기존 로직의 손상을 막을 수 있다.

또한 상호작용 자체의 로직을 변경하지 않고도 각각의 state 에 표시되는 항목을 변경할 수 있다.

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

 

2. State 구조화 : 불필요하고 중복된 데이터를 제거해, 오류 없이 상태를 쉽게 업데이트하자!

  1. 연관된 state 그룹화하기 : 두 개 이상의 state 가 항상 동시에 업데이트된다면, 단일 state 로의 병합을 고려하라.
  2. state 의 모순 피하기 : 여러 state 조각을 서로 모순되고 불일치할 수 있는 방식으로 구성한다면 실수가 발생할 수 있다.
  3. 불필요한 state 피하기 : 렌더링 중에 컴포넌트의 props 나 기존 state 변수에서 일부 정보를 계산할 수 있다면, 컴포넌트의 state 에 해당 정보를 넣지 말아야 한다.
  4. state 의 중복 피하기 : 여러 상태 변수 간 또는 중첩된 객체 내에서 동일한 데이터가 중복될 경우 동기화를 유지하기 어렵다. 
    + 선택된 아이템은 따로 state 에 저장 : 선택과 같은 UI 패턴의 경우, 객체 자체가 아닌 ID 또는 인덱스를 state에 유지하자.
  5. 깊게 중첩된 state 피하기 : 깊게 계층화된 state 는 업데이트하기 쉽지 않다. 가능하면 평탄한 방식으로 구성하자.

 

1️⃣ 연관된 state 그룹화하기

단일 state 변수와 다중 state 변수 사이에서 무엇을 사용할지 불확실한 경우

const [x, setX] = useState(0);
const [y, setY] = useState(0);

const [position, setPosition] = useState({ x: 0, y: 0 });

 

위 두 가지 방식 모두 사용 가능하지만, 두 개의 state 가 항상 함께 변경된다면 단일 state 변수로 통합하는 것이 좋다.

(state 가 객체인 경우 다른 필드를 명시적으로 복사하지 않고 하나의 필드만 업데이트할 순 없다. setPosition({ ..position, x: 100 }) 과 같이 복사 필수)

그러면 마우스 커서의 움직임에 따라 빨간 점의 두 좌표가 모두 업데이트되는 예시처럼 항상 동기화를 유지하는 것을 잊지 않을 것이다.

 

2️⃣ state 의 모순 피하기

const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

 

위 코드는 불가능한 state 를 허용하는데, 둘 중 한 state 를 호출하는 것을 잊은 경우

isSending 과 isSent 가 동시에 true 인 상황에 처할 수 있다. (컴포넌트가 복잡할수록 이해하기 어려워진다.)

 

두 상태는 동시에 true 가 되어서는 안되기에,

typing(초깃값), sending, sent'세 가지 유효한 상태 중 하나를 가질 수 있는 status state 변수로 대체하는 것이 좋다.

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

 

3️⃣ 불필요한 state 피하기

컴포넌트의 props 나 다른 state 에서 일부 정보를 얻을 수 있다면, 그 정보를 가진 state 를 또 생성하지 않아야 한다.

아래 예시에선 렌더링 중에 항상 firstName lastName에서 fullName을 계산할 수 있기 때문에 state에서 제거하자.

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

const fullName = firstName + ' ' + lastName; // state XXX, 렌더링 중 계산됨

 

➕ Props 를 state 에 미러링하지 마세요.

다음 코드는 불필요한 state 의 예시로, color state 는 messageColor prop 으로 초기화된다.

문제는 부모 컴포넌트가 나중에 다른 값의 messageColor 를 전달한다면('blue' 대신 'red'), color state 는 업데이트되지 않는다!

state 는 첫 번째 렌더링 중에만 초기화된다.

function Message({ messageColor }) {
  const [color, setColor] = useState(messageColor);

 

대신 messageColor prop 을 직접 사용하거나, 다른 이름을 지정하려면 상수를 사용한다.

const color = messageColor;

 

이렇게 하면 부모 컴포넌트에서 전달된 prop 과 동기화를 잃지 않는다.

Props 를 상태로 '미러링'하는 것은 특정 prop 에 대한 모든 업데이트를 무시하기를 원할 때에만 의미가 있다.

그 경우엔 prop 의 이름을 initial 또는 default 로 시작해 새로운 값이 무시됨을 명확히 하자.

function Message({ initialColor }) {
  const [color, setColor] = useState(initialColor);

 

4️⃣ state 의 중복 피하기

아래 메뉴 목록 컴포넌트로 하나의 여행 간식을 선택할 수 있다.

하지만 이는 좋지 않다. selectedItem 의 내용이 items 목록 내의 항목 중 하나와 동일하다.(중복된다.)

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]); // 선택한 간식 저장 {id: 0, title: 'pretzels'}

 

이 경우 아래처럼 같은 내용이지만 동기화가 무너진 것을 볼 수 있다.

 

중복을 제거하고, 필수적인 state 만 유지하자!

selectedItem 객체 대신 selectedId 를 state 로 유지한 후, item 배열에서 해당 id 로 selecteItem 을 가져오자.

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0); // 선택한 item id 저장 0

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

 

이제 선택한 항목 input 을 편집하면 아래 메세지가 즉시 업데이트된다.

setItems 가 다시 렌더링하도록 유발하고, items.find(...) 가 업데이트된 제목의 항목을 찾을 것!

 

 선택된 아이템은 따로 state 에 저장(다중 선택 구현)

다중 선택을 구현하려면 state 구조를 배열로 변경해야 한다.

const [selectedId, setSelectedId] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);

//..
function handleToggle(toggledId) {
    if (selectedIds.includes(toggledId)) {
      setSelectedIds(selectedIds.filter(id =>
        id !== toggledId
      ));
//..

 

하지만 배열을 사용했을 때 사소한 단점은 각 항목에 대해 includes() 를 통해 선택 여부를 확인한다는 것으로,

배열이 매우 큰 경우 이러한 배열 검색은 선형 시간이 걸리고, 개별 항목마다 검사를 수행하기에 성능상 문제가 될 수 있다.

 

이를 해결하기 위해, state 에 Set 을 대신 보관하여 has() 연산으로 빠른 검사를 수행할 수 있다.

const [selectedIds, setSelectedIds] = useState(new Set());

//..
  function handleToggle(toggledId) {
    // Create a copy (to avoid mutation).
    const nextIds = new Set(selectedIds);
    if (nextIds.has(toggledId)) {
      nextIds.delete(toggledId);
    } else {
//..

 

 

5️⃣ 깊게 중첩된 state 피하기

행성, 대륙, 국가로 구성된 여행 계획을 상상해 보자. 예시처럼 중첩된 객체와 배열을 사용해 여행 계획의 state 를 구성하고 싶을 수도 있다.

export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Earth',
    childPlaces: [{
      id: 2,
      title: 'Africa',
      childPlaces: [{
        id: 3,
        title: 'Botswana',
        childPlaces: []
      }, {
 // ...
    }, {
      id: 45,
      title: 'Tycho',
      childPlaces: []
    }]
  }, {
    id: 46,
    title: 'Mars',
    childPlaces: [{
      id: 47,
      title: 'Corn Town',
      childPlaces: []
    }, {
      id: 48,
      title: 'Green Hill',
      childPlaces: []      
    }]
  }]
};

 

이제 방문한 장소를 삭제하는 버튼을 추가하고 싶다.

중첩된 state 를 업데이트하려면 변경된 부분부터 모든 객체의 복사본을 만들어야 한다.

(이러한 코드는 매우 장황해질 수 있다.)

 

아래와 같이 state 를 평탄(정규화)하게 만들면 중첩된 항목을 업데이트하는 것이 더 쉬워진다.

// 위 코드처럼 각 place 가 자식 장소의 배열을 가지는 트리 구조 대신,
// 각 장소가 자식 장소 ID 의 배열을 가지도록 했다.
export const initialTravelPlan = {
  0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 42, 46],
  },
  1: {
    id: 1,
    title: 'Earth',
    childIds: [2, 10, 19, 26, 34]
  },
  // ...
  48: {
    id: 48,
    title: 'Green Hill',
    childIds: []
  }
};

 

 

 

🔗 https://ko.react.dev/learn/managing-state

 

State 관리하기 – React

The library for web and native user interfaces

ko.react.dev

 

 

 

728x90
반응형