State 구조 선택하기

State를 잘 구조화하면 수정과 디버깅이 즐거운 컴포넌트와 지속적인 버그의 원인이 되는 컴포넌트의 차이를 만들 수 있습니다. 다음은 state를 구조화할 때 고려해야 할 몇 가지 팁입니다.

학습 내용

  • 단일 vs 다중 state 변수를 사용하는 경우
  • State를 구성할 때 피해야 할 사항
  • 상태 구조의 일반적인 문제를 해결하는 방법

State 구조화 원칙

상태를 갖는 구성요소를 작성할 때, 사용할 state 변수의 수와 데이터의 형태를 선택해야 합니다. 최적이 아닌 state 구조에서도 올바른 프로그램을 작성할 수 있지만, 더 나은 선택을 할 수 있는 몇 가지 원칙이 있습니다.

  1. 연관된 state 그룹화하기. 두 개 이상의 state 변수를 항상 동시에 업데이트한다면, 단일 state 변수로 병합하는 것을 고려하세요.
  2. State의 모순 피하기. 여러 state 조각이 서로 모순되고 “불일치”할 수 있는 방식으로 state를 구성하는 것은 실수가 발생할 여지를 만듭니다. 이를 피하세요.
  3. 불필요한 state 피하기. 렌더링 중에 컴포넌트의 props나 기존 state 변수에서 일부 정보를 계산할 수 있다면, 컴포넌트의 state에 해당 정보를 넣지 않아야 합니다.
  4. State의 중복 피하기. 여러 상태 변수 간 또는 중첩된 객체 내에서 동일한 데이터가 중복될 경우 동기화를 유지하기가 어렵습니다. 가능하다면 중복을 줄이세요.
  5. 깊게 중첩된 state 피하기. 깊게 계층화된 state는 업데이트하기 쉽지 않습니다. 가능하면 state를 평탄한 방식으로 구성하는 것이 좋습니다.

이러한 원칙 뒤에 있는 목표는 오류 없이 상태를 쉽게 업데이트하는 것 입니다. State에서 불필요하고 중복된 데이터를 제거하면 모든 데이터 조각이 동기화 상태를 유지하는 데 도움이 됩니다. 이는 데이터베이스 엔지니어가 데이터베이스 구조를 “정규화”하여 버그 발생 가능성을 줄이는 것과 유사합니다. 알베르트 아인슈타인의 말을 빌리자면, “당신의 state를 가능한 한 단순하게 만들어야 한다, 더 단순하게 가 아니라.”

이제 이 원칙들이 실제로 어떻게 적용되는지 살펴보겠습니다.

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

이렇게 해야 할까요?

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

아니면 이렇게?

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

기술적으로 이 두 가지 접근 방식 모두 사용할 수 있습니다. 하지만 두 개의 state 변수가 항상 함께 변경된다면, 단일 state 변수로 통합하는 것이 좋습니다. 그러면 마우스 커서를 움직이면 빨간 점의 두 좌표가 모두 업데이트되는 이 예제처럼 항상 동기화를 유지하는 것을 잊지 않을 것입니다.

import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        setPosition({
          x: e.clientX,
          y: e.clientY
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  )
}

데이터를 객체나 배열로 그룹화하는 또 다른 경우는 필요한 state의 조각 수를 모를 때입니다. 예를 들어, 사용자가 커스텀 필드를 추가할 수 있는 양식이 있는 경우에 유용합니다.

주의하세요!

State 변수가 객체인 경우에는 다른 필드를 명시적으로 복사하지 않고 하나의 필드만 업데이트할 수 없다는 것을 기억하세요. 예를 들어, 위의 예제에서는 y 속성이 전혀 없기 때문입니다 setPosition({ x: 100 })를 할 수 없습니다! 대신, x만 설정하려면 setPosition({ ...position, x: 100 })을 하거나 두 개의 state 변수로 나누고 setX(100)을 해야 합니다.

State의 모순 피하기

다음은 isSendingisSent 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를 허용합니다. 예를 들어 setIsSentsetIsSending을 함께 호출하는 것을 잊어버린 경우, isSendingisSent가 동시에 true인 상황에 처할 수 있습니다. 컴포넌트가 복잡할수록 무슨 일이 일어났는지 이해하기가 어렵습니다.

isSendingisSent는 동시에 true가 되어서는 안되기 때문에, 이 두 변수를 'typing'(초깃값), 'sending', 'sent' 세 가지 유효한 상태 중 하나를 가질 수 있는 status state 변수로 대체하는 것이 좋습니다.

import { useState } from 'react';

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState('typing');

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('sending');
    await sendMessage(text);
    setStatus('sent');
  }

  const isSending = status === 'sending';
  const isSent = status === 'sent';

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

가독성을 위해 몇 가지 상수를 선언할 수도 있습니다.

const isSending = status === 'sending';
const isSent = status === 'sent';

이들은 state 변수가 아니기 때문에 서로 동기화되지 않을 우려는 없습니다.

불필요한 state 피하기

렌더링 중에 컴포넌트의 props나 기존 state 변수에서 일부 정보를 계산할 수 있다면, 컴포넌트의 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>
    </>
  );
}

이 양식에는 firstName, lastName, fullName의 세 가지 state 변수가 있습니다. 그러나 fullName은 불필요합니다. 렌더링 중에 항상 firstNamelastName에서 fullName을 계산할 수 있기 때문에 state에서 제거하세요.

이렇게 하면 됩니다.

import { useState } from 'react';

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

  const fullName = firstName + ' ' + lastName;

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e) {
    setLastName(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;

따라서 변경 핸들러는 이를 업데이트하기 위해 특별한 작업을 수행할 필요가 없습니다. setFirstName 또는 setLastName을 호출하면, 다시 렌더링하는 것을 유발하여, 다음 fullName이 새 데이터로 계산됩니다.

Deep Dive

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

다음 코드는 불필요한 state의 일반적인 예입니다.

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

여기서 color state 변수는 messageColor prop로 초기화됩니다. 문제는 부모 컴포넌트가 나중에 다른 값의 messageColor를 전달한다면 (예를 들어, 'blue' 대신 'red'), color state 변수 가 업데이트되지 않습니다! State는 첫 번째 렌더링 중에만 초기화됩니다.

그 때문에 state 변수의 일부 prop를 “미러링”하면 혼란이 발생할 수 있습니다. 대신 코드에 messageColor prop를 직접 사용하세요. 더 짧은 이름을 지정하려면 상수를 사용하세요.

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

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

Props를 상태로 “미러링”하는 것은 특정 prop에 대한 모든 업데이트를 무시하기를 원할 때에만 의미가 있습니다. 관례에 따라 prop의 이름을 initial 또는 default로 시작하여 새로운 값이 무시됨을 명확히 하세요.

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

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

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

현재는 선택된 항목을 selectedItem state 변수에 객체로 저장합니다. 그러나 이는 좋지 않습니다. selectedItem의 내용이 items 목록 내의 항목 중 하나와 동일한 객체입니다. 이는 항목 자체에 대한 정보가 두 곳에서 중복되는 것입니다.

이것은 왜 문제일까요? 각 항목을 편집할 수 있도록 만들어 보겠습니다.

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

먼저 항목에서 “Choose”를 클릭한 이를 편집할 경우, 입력이 업데이트되지만, 하단의 라벨에는 편집 내용이 반영되지 않습니다. 이는 state가 중복되었으며 selectedItem을 업데이트하는 것을 잊어버렸기 때문입니다.

selectedItem도 업데이트할 수 있지만 더 쉬운 수정 방법은 중복을 제거하는 것입니다. 이 예에서는 selectedItem 객체(items 내부의 객체와 중복을 생성하는) 대신 selectedId를 state로 유지하고, 그다음 items 배열에서 해당 ID의 항목을 검색하여 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는 다음과 같이 중복되었습니다.

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedItem = {id: 0, title: 'pretzels'}

하지만 변경 후에는 다음과 같습니다.

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedId = 0

중복은 사라지고 필수적인 state만 유지됩니다!

이제 선택한 항목을 편집하면 아래 메시지가 즉시 업데이트됩니다. 이는 setItems가 다시 렌더링하도록 유발하고, items.find(...)가 업데이트된 제목의 항목을 찾을 것이기 때문입니다. 선택한 ID만 필수이므로 선택한 항목을 state로 유지할 필요가 없습니다. 나머지는 렌더링하는 동안 계산할 수 있습니다.

깊게 중첩된 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: 4,
        title: 'Egypt',
        childPlaces: []
      }, {
        id: 5,
        title: 'Kenya',
        childPlaces: []
      }, {
        id: 6,
        title: 'Madagascar',
        childPlaces: []
      }, {
        id: 7,
        title: 'Morocco',
        childPlaces: []
      }, {
        id: 8,
        title: 'Nigeria',
        childPlaces: []
      }, {
        id: 9,
        title: 'South Africa',
        childPlaces: []
      }]
    }, {
      id: 10,
      title: 'Americas',
      childPlaces: [{
        id: 11,
        title: 'Argentina',
        childPlaces: []
      }, {
        id: 12,
        title: 'Brazil',
        childPlaces: []
      }, {
        id: 13,
        title: 'Barbados',
        childPlaces: []
      }, {
        id: 14,
        title: 'Canada',
        childPlaces: []
      }, {
        id: 15,
        title: 'Jamaica',
        childPlaces: []
      }, {
        id: 16,
        title: 'Mexico',
        childPlaces: []
      }, {
        id: 17,
        title: 'Trinidad and Tobago',
        childPlaces: []
      }, {
        id: 18,
        title: 'Venezuela',
        childPlaces: []
      }]
    }, {
      id: 19,
      title: 'Asia',
      childPlaces: [{
        id: 20,
        title: 'China',
        childPlaces: []
      }, {
        id: 21,
        title: 'India',
        childPlaces: []
      }, {
        id: 22,
        title: 'Singapore',
        childPlaces: []
      }, {
        id: 23,
        title: 'South Korea',
        childPlaces: []
      }, {
        id: 24,
        title: 'Thailand',
        childPlaces: []
      }, {
        id: 25,
        title: 'Vietnam',
        childPlaces: []
      }]
    }, {
      id: 26,
      title: 'Europe',
      childPlaces: [{
        id: 27,
        title: 'Croatia',
        childPlaces: [],
      }, {
        id: 28,
        title: 'France',
        childPlaces: [],
      }, {
        id: 29,
        title: 'Germany',
        childPlaces: [],
      }, {
        id: 30,
        title: 'Italy',
        childPlaces: [],
      }, {
        id: 31,
        title: 'Portugal',
        childPlaces: [],
      }, {
        id: 32,
        title: 'Spain',
        childPlaces: [],
      }, {
        id: 33,
        title: 'Turkey',
        childPlaces: [],
      }]
    }, {
      id: 34,
      title: 'Oceania',
      childPlaces: [{
        id: 35,
        title: 'Australia',
        childPlaces: [],
      }, {
        id: 36,
        title: 'Bora Bora (French Polynesia)',
        childPlaces: [],
      }, {
        id: 37,
        title: 'Easter Island (Chile)',
        childPlaces: [],
      }, {
        id: 38,
        title: 'Fiji',
        childPlaces: [],
      }, {
        id: 39,
        title: 'Hawaii (the USA)',
        childPlaces: [],
      }, {
        id: 40,
        title: 'New Zealand',
        childPlaces: [],
      }, {
        id: 41,
        title: 'Vanuatu',
        childPlaces: [],
      }]
    }]
  }, {
    id: 42,
    title: 'Moon',
    childPlaces: [{
      id: 43,
      title: 'Rheita',
      childPlaces: []
    }, {
      id: 44,
      title: 'Piccolomini',
      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의 배열을 가지도록 할 수 있습니다. 그런 다음 각 장소 ID와 해당 장소에 대한 매핑을 저장하세요.

이 데이터 재구성은 데이터베이스 테이블을 떠올리게 할 수 있습니다.

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]
  }, 
  3: {
    id: 3,
    title: 'Botswana',
    childIds: []
  },
  4: {
    id: 4,
    title: 'Egypt',
    childIds: []
  },
  5: {
    id: 5,
    title: 'Kenya',
    childIds: []
  },
  6: {
    id: 6,
    title: 'Madagascar',
    childIds: []
  }, 
  7: {
    id: 7,
    title: 'Morocco',
    childIds: []
  },
  8: {
    id: 8,
    title: 'Nigeria',
    childIds: []
  },
  9: {
    id: 9,
    title: 'South Africa',
    childIds: []
  },
  10: {
    id: 10,
    title: 'Americas',
    childIds: [11, 12, 13, 14, 15, 16, 17, 18],   
  },
  11: {
    id: 11,
    title: 'Argentina',
    childIds: []
  },
  12: {
    id: 12,
    title: 'Brazil',
    childIds: []
  },
  13: {
    id: 13,
    title: 'Barbados',
    childIds: []
  }, 
  14: {
    id: 14,
    title: 'Canada',
    childIds: []
  },
  15: {
    id: 15,
    title: 'Jamaica',
    childIds: []
  },
  16: {
    id: 16,
    title: 'Mexico',
    childIds: []
  },
  17: {
    id: 17,
    title: 'Trinidad and Tobago',
    childIds: []
  },
  18: {
    id: 18,
    title: 'Venezuela',
    childIds: []
  },
  19: {
    id: 19,
    title: 'Asia',
    childIds: [20, 21, 22, 23, 24, 25],   
  },
  20: {
    id: 20,
    title: 'China',
    childIds: []
  },
  21: {
    id: 21,
    title: 'India',
    childIds: []
  },
  22: {
    id: 22,
    title: 'Singapore',
    childIds: []
  },
  23: {
    id: 23,
    title: 'South Korea',
    childIds: []
  },
  24: {
    id: 24,
    title: 'Thailand',
    childIds: []
  },
  25: {
    id: 25,
    title: 'Vietnam',
    childIds: []
  },
  26: {
    id: 26,
    title: 'Europe',
    childIds: [27, 28, 29, 30, 31, 32, 33],   
  },
  27: {
    id: 27,
    title: 'Croatia',
    childIds: []
  },
  28: {
    id: 28,
    title: 'France',
    childIds: []
  },
  29: {
    id: 29,
    title: 'Germany',
    childIds: []
  },
  30: {
    id: 30,
    title: 'Italy',
    childIds: []
  },
  31: {
    id: 31,
    title: 'Portugal',
    childIds: []
  },
  32: {
    id: 32,
    title: 'Spain',
    childIds: []
  },
  33: {
    id: 33,
    title: 'Turkey',
    childIds: []
  },
  34: {
    id: 34,
    title: 'Oceania',
    childIds: [35, 36, 37, 38, 39, 40, 41],   
  },
  35: {
    id: 35,
    title: 'Australia',
    childIds: []
  },
  36: {
    id: 36,
    title: 'Bora Bora (French Polynesia)',
    childIds: []
  },
  37: {
    id: 37,
    title: 'Easter Island (Chile)',
    childIds: []
  },
  38: {
    id: 38,
    title: 'Fiji',
    childIds: []
  },
  39: {
    id: 40,
    title: 'Hawaii (the USA)',
    childIds: []
  },
  40: {
    id: 40,
    title: 'New Zealand',
    childIds: []
  },
  41: {
    id: 41,
    title: 'Vanuatu',
    childIds: []
  },
  42: {
    id: 42,
    title: 'Moon',
    childIds: [43, 44, 45]
  },
  43: {
    id: 43,
    title: 'Rheita',
    childIds: []
  },
  44: {
    id: 44,
    title: 'Piccolomini',
    childIds: []
  },
  45: {
    id: 45,
    title: 'Tycho',
    childIds: []
  },
  46: {
    id: 46,
    title: 'Mars',
    childIds: [47, 48]
  },
  47: {
    id: 47,
    title: 'Corn Town',
    childIds: []
  },
  48: {
    id: 48,
    title: 'Green Hill',
    childIds: []
  }
};

이제 state가 “평탄”(“정규화”라고도 함)하므로 중첩된 항목을 업데이트하는 것이 더 쉬워졌습니다.

이제 장소를 제거하기 위해, state의 두 단계만 업데이트하면 됩니다.

  • 업데이트된 버전의 부모 장소는 childIds 배열에서 제거된 ID를 제외해야 합니다.
  • 업데이트된 버전의 루트 “테이블” 객체는 부모 장소의 업데이트된 버전을 포함해야 합니다.

다음은 이를 수행하는 방법의 예입니다.

import { useState } from 'react';
import { initialTravelPlan } from './places.js';

export default function TravelPlan() {
  const [plan, setPlan] = useState(initialTravelPlan);

  function handleComplete(parentId, childId) {
    const parent = plan[parentId];
    // Create a new version of the parent place
    // that doesn't include this child ID.
    const nextParent = {
      ...parent,
      childIds: parent.childIds
        .filter(id => id !== childId)
    };
    // Update the root state object...
    setPlan({
      ...plan,
      // ...so that it has the updated parent.
      [parentId]: nextParent
    });
  }

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

State를 원하는 만큼 중첩할 수 있지만, “평탄”하게 만드는 것은 많은 문제를 해결할 수 있습니다. State를 업데이트하기 쉽게 만들고 중첩된 객체의 다른 부분에 중복이 없도록 도와줍니다.

Deep Dive

메모리 사용량 개선하기

이상적으로 메모리 사용량을 개선하기 위해서는 삭제된 항목(그리고 그들의 자식들!)을 “테이블” 객체에서 제거해야 합니다. 이 버전은 그렇게 합니다. 또한 업데이트 로직을 더 간결하게 만들기 위해 Immer를 사용합니다.

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

때로는 중첩된 state를 자식 컴포넌트로 이동시켜 state 중첩을 줄일 수도 있습니다. 이는 항목이 호버되었는가와 같이 저장할 필요가 없는 임시의 UI state에 대해 잘 작동합니다.

요약

  • 만약 두 state 변수가 항상 함께 업데이트된다면, 하나로 합치는 것을 고려해 보세요.
  • State 변수를 신중하게 선택하여 “불가능한” state를 만들지 않도록 하세요.
  • State를 업데이트할 때 실수할 가능성을 줄이도록 state를 구조화하세요.
  • 동기화를 유지하지 않아도 되도록 불필요하고 중복된 state를 피하세요.
  • 특별히 업데이트를 방지하려는 경우를 제외하고는 props를 state에 넣지 마세요.
  • 선택과 같은 UI 패턴의 경우, 객체 자체가 아닌 ID 또는 인덱스를 state에 유지하세요.
  • 깊게 중첩된 state를 업데이트하는 것이 복잡한 경우, 평탄하게 만들어 보세요.

챌린지 1 of 4:
업데이트되지 않는 컴포넌트 수정하기

Clock 컴포넌트는 colortime 두 가지 props를 받습니다. 선택 창에서 다른 색상을 선택하면 Clock 컴포넌트는 부모 컴포넌트에서 다른 color prop을 받습니다. 그러나 어떤 이유에서인지 표시된 색상이 업데이트되지 않습니다. 왜 그럴까요? 문제를 해결하세요.

import { useState } from 'react';

export default function Clock(props) {
  const [color, setColor] = useState(props.color);
  return (
    <h1 style={{ color: color }}>
      {props.time}
    </h1>
  );
}