본문 바로가기

이것저것 스터디📚/React - 공식 문서

React 공식문서 -Choosing the State Structure(State 구조 선택)

state를 잘 구조화하면 수정과 디버깅이 편한 컴포넌트를 만들 수 있다. 다음은 state를 구조화할 때 고려해야하는 내용이다.


Principles for structuring state(state 구조화 원칙)

 

1. 관련 state를 그룹화한다 : 항상 두 개 이상의 state 변수를 동시에 업데이트하는 경우 단일 state 변수로 병합하는 것이 좋다.

2. state의 모순을 피하자 : 여러 state 조각이 서로 모순되거나 "불일치"할 수 있는 방식으로 state를 구성하지 말자.

3. 불필요한 state를 피하자 : 렌더링 중에 컴포넌트의 props나 기존 state 변수에서 일부 정보를 계산할 수 있다면 해당 정보를 해당 컴포넌트의 state에 넣지 말자.

4. state 중복을 피하자 : 동일한 데이터가 여러 state 변수 간에 또는 중첩된 객체 내에 중복되면 동기화 state를 유지하기 어렵다.

5. 깊게 중첩된 state는 피하자 : 깊게 계층화된 state를 업데이트하는 것은 쉽지 않기 때문에, 가능하면 평평한 방식으로 구성하는 것이 좋다.


Group related state(관련 state 그룹화하기)

 

만약 두 개의 state 변수가 항상 함께 변경된다면 하나의 state 변수로 통합하는 것이 좋다. 또는 데이터를 객체나 배열로 그룹화하는 또 다른 경우는 필요한 state의 조각 수를 모를때 유용하다.(예를 들어, 사용자가 사용자 정의 필드를 추가할 수 있는 양식이 있을 때)

 

* state 변수가 객체인 경우 다른 필드를 명시적으로 복사하지 않고는 그 안의 하나의 필드만 업데이트할 수는 없다.

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

setPosition({ x: 100 }) // 수행 불가능

setPosition({ ...position, x: 100 }) // 수행 가능

Avoid contradictions in state(state의 모순을 피하세요)

 

import { useState } from 'react';

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);
  }

  if (isSent) {
    return <h1>Thanks for feedback!</h1>
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>How was your stay at The Prancing Pony?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button
        disabled={isSending}
        type="submit"
      >
        Send
      </button>
      {isSending && <p>Sending...</p>}
    </form>
  );
}

// Pretend to send a message.
function sendMessage(text) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  });
}

위 예제에서 "불가능한" state의 설정을 허용하고 있는데, 예를 들어 setIsSent와 setIsSending 중 어느 하나만 호출하면, isSending과 isSent가 동시에 true가 되는 상황이 발생할 수 있고 이는 컴포넌트가 복잡할수록 문제를 파악하기 쉽지 않아진다.

 

따라서, isSent와 isSending이 동시에 true가 되면 안되므로, 세 가지 유효한 state(typing, sending, sent)중 하나를 취할 수 있는 status라는 state 변수 하나로 대체하는 것이 좋다.


Avoid redunant state(불필요한 state를 피하세요)

 

렌더링 중에 컴포넌트의 props나 기존 state 변수에서 일부 정보를 계산할 수 있는 경우 해당 정보를 컴포넌트의 state에 넣지 않아야 한다.

 

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
    setFullName(e.target.value + ' ' + lastName);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
    setFullName(firstName + ' ' + e.target.value);
  }

  return (
    <>
      <h2>Let’s check you in</h2>
      <label>
        First name:{' '}
        <input
          value={firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:{' '}
        <input
          value={lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

위 예제에서 fullName은 렌더링 중에 언제든지 계산될 수 있기 때문에 state에서 제거하는 것이 좋다

const fullName = firstName + ' ' + lastName;

 

 

* props를 state에 그대로 미러링하지 마세요

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

위 예제에서 color 변수는 messageColor props로 초기화 되는데, 부모 컴포넌트가 나중에 다른 messageColor 값을 전달하면 color state 변수가 업데이트 되지 않는다.(state는 첫 번째 렌더링 중에만 초기화된다)

function Message({ messageColor }) {
  const color = messageColor;

따라서, 위와 같이 코드에서 messageColor prop을 직접 사용하는 방식이 있다.

function Message({ initialColor }) {
  // The `color` state variable holds the *first* value of `initialColor`.
  // Further changes to the `initialColor` prop are ignored.
  const [color, setColor] = useState(initialColor);

위 예제와 같이 props를 state로 "미러링"하는 것은 특정 prop에 대한 모든 업데이트를 무시하려는 경우에만 의미가 있다.

(관례상 prop 이름을 initial 또는 default로 시작하여 새 값이 무시됨을 명확히 해야한다.)


Avoid duplication in state(state 중복을 피하세요)

 

import { useState } from 'react';

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]
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2> 
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedItem(item);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

위 예제에서 selectedItem의 내용은 items 목록 내의 항목 중 하나와 동일한 객체다. 따라서, Choose 버튼을 클릭한 다음 편집하면, 입력은 업데이트되지만, 하단의 레이블에 편집 내용이 반영되지 않는다. (이는 state가 중복되어 있고 selectedItem을 업데이트 하지 않았기 때문이다)

 

import { useState } from 'react';

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
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedId(item.id);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

위 예제에서는 state의 중복을 제거하고 selectedItem 객체(items 내부의 객체 복사본을 생성하는) 대신 selectedId를 state로 유지한 다음 items 배열에서 해당 ID를 가진 항목을 검색하여 selectedItem을 가져온다.

 

이전에는 items = [{ id : 0, title: 'pretzels'}, ...] 과 selectedItem = { id: 0, title: 'pretzels'} 이런식으로 state가 복제되었다면, 코드 변경 후에는 items = [{id : 0, title: 'pretzles'}, ...] 과 seletedId = 0으로 변경되었다. 즉, 중복은 사라지고 필수 state만 유지된다.


Avoid deeply nested state(깊게 중첩된 state는 피하세요)

 

state가 너무 깊게 중첩되어 있는 경우에는 state를 업데이트 하려면 변경된 부분부터 위쪽까지 객체의 복사본을 만들어야 한다.

 

따라서, 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: []
      },
      ...
 }



// flat
export const initialTravelPlan = {
  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]
  },
  ...
}

위 예제에서 중첩된 state가 flat 해졌기 때문에, 중첩된 항목을 업데이트하는 것이 더 쉬워졌다. 예를 들어, 장소를 제거하는 경우에 두 단계의 state만 업데이트하면 된다.

 

1. 부모 장소의 업데이트된 버전은 제거된 ID를 childIds 배열에서 제외해야 한다.

2. 루트'테이블' 객체의 업데이트된 버전에는 상위 위치의 업데이트된 버전이 포함되어야 한다.

 

* 메모리 사용량 개선하기

 

이상적으로는 '테이블'객체에서 삭제된 항목(및 그 하위 항목!)도 제거하여 메모리 사용량을 개선하는 것이 좋다.

아래는 immer를 사용하여 업데이트 로직을 간결하게 만드는 방법이다.

import { useImmer } from 'use-immer';
import { initialTravelPlan } from './places.js';

export default function TravelPlan() {
  const [plan, updatePlan] = useImmer(initialTravelPlan);

  function handleComplete(parentId, childId) {
    updatePlan(draft => {
      // Remove from the parent place's child IDs.
      const parent = draft[parentId];
      parent.childIds = parent.childIds
        .filter(id => id !== childId);

      // Forget this place and all its subtree.
      deleteAllChildren(childId);
      function deleteAllChildren(id) {
        const place = draft[id];
        place.childIds.forEach(deleteAllChildren);
        delete draft[id];
      }
    });
  }

  const root = plan[0];
  const planetIds = root.childIds;
  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {planetIds.map(id => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

function PlaceTree({ id, parentId, placesById, onComplete }) {
  const place = placesById[id];
  const childIds = place.childIds;
  return (
    <li>
      {place.title}
      <button onClick={() => {
        onComplete(parentId, id);
      }}>
        Complete
      </button>
      {childIds.length > 0 &&
        <ol>
          {childIds.map(childId => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      }
    </li>
  );
}

* 참고 : React 공식문서(https://react-ko.dev/learn)