Steady-Dev
2023.07.05.
React State 특징 & 동작원리

React

State란?

  • 시간이 지남에 따라 변하는 데이터
  • 컴포넌트의 메모리
  • 모든 컴포넌트에 state를 추가하고 업데이트를 할 수 있습니다.

선언 방법

const [index, setIndex] = useState(0);

특징

독립적, private

동일한 컴포넌트 두 군데에서 렌더링하면 각 컴포넌트는 완전히 독립된 state를 갖게 됩니다. 이 중 하나를 변경해도 다른 컴포넌트에는 영향을 미치지 않습니다.

import { useState } from 'react';
import ComponentA from './ComponentA';
import ComponentB from './ComponentB';

export default function App() {
  return (
    <>
      <ComponentA />
      <ComponentB />
    </>
  );
}

ComponentA에서 state가 변경되어도 ComponentB는 영향을 미치지 않습니다.

또한, state a, b 둘은 완전히 독립된 메모리입니다. 둘 중 하나의 값이 변해도 나머지 하나는 영향을 받지 않습니다.

import { useState } from 'react';

export default function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  return (
    <>
      <p>{a}</p>
      <p>{b}</p>
      <button onClick={() => setA(prev => prev + 1)}>a</button>
      <button onClick={() => setB(prev => prev + 1)}>b</button>
    </>
  );
}

Snapshot & Batching(일괄처리)

Snapshot

state는 스냅샷처럼 동작합니다. setState를 해도 이미 가지고 있는 state변수는 변경되지 않고, 리렌더링이 실행됩니다.

렌더링이란 React가 컴포넌트, 즉 함수를 호출한다는 뜻입니다. 해당 함수에서 반환하는 JSX는 시간상 UI의 스냅샷과 같습니다. prop, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해 계산됩니다.

React는 컴포넌트를 다시 렌더링할 때 일어나는 일

  1. React가 함수를 다시 호출합니다.
  2. 함수가 새로운 JSX 스냅샷을 반환합니다.
  3. React가 반환한 스냅샷과 일치하도록 화면을 업데이트 합니다.
export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 1);
          setNumber(number + 1);
          setNumber(number + 1);
        }}
      >
        +3
      </button>
    </>
  );
}

이 number는 클릭당 한번만 증가합니다. 그 이유는 이벤트 핸들러 내에서 state.값을 고정시키기 떄문입니다. 즉 number가 0으로 지정되어 있기 때문입니다. 순서는 다음과 같습니다.

setNumber(number + 1) : number는 0이므로 setNumber(1)입니다. 다음 렌더링에서 number를 1로 변경할 준비를 합니다.

setNumber(number + 1) : number는 0이므로 setNumber(1)입니다. 다음 렌더링에서 number를 1로 변경할 준비를 합니다.

setNumber(number + 1) : number는 0이므로 setNumber(1)입니다. 다음 렌더링에서 number를 1로 변경할 준비를 합니다.

setNumber(number + 1)를 3번 호출했지만, 이 렌더링에서 이벤트 핸들러의 number는 항상 0이므로 state를 1로 세번 설정했습니다.

Batching(일괄처리)

state를 세팅하면 다음 렌더링이 큐에 들어갑니다. 위의 예제에서 setNumber(number + 1)을 세번 작성하여도 state는 1이었습니다. 그 이유는 React는 state를 업데이트 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다립니다. 이 때문에 리렌더링은 모든 setNumber() 호출이 완료된 이후에만 일어납니다.

일괄처리 해소하기 - updater function (업데이터 함수)

그러나 다음 렌더링이 일어나기 전에 state를 업데이트 할 수 있습니다.

setNumber(n => n + 1);

n => n + 1는 업데이터 함수(updater function)이라고 부릅니다. React는 이전 업데이터 함수의 반환값을 가져와서 다음 업데이터 함수에 n으로 전달하는 식으로 반복합니다.

<button onClick={() => {
  setNumber(number + 5);
  setNumber(n => n + 1);
}}>

6을 반환합니다. 그 이유는 첫번째 setNumber(number + 5);에서 5를 반환하고, setNumber(n => n + 1);에서 6이 반환되기 때문입니다.


State 구조화 원칙

1. 관련 state를 그룹화하기

항상 두개 이상의 state 변수를 동시에 업데이트 하는 경우, 단일 state 변수로 병합하는 것이 좋습니다.

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

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

2. state가 서로 모순되지 않기

여러 state 조각이 서로 모순되거나 불일치 할 수 있는 방식으로 state를 구성하면 실수가 발생할 여지가 생깁니다.

// ❌
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

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

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

3. 불필요한 state 피하기

렌더링 중에 컴포넌트의 props나 기존 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;

4. state 중복을 피하기

동일한 데이터가 여러 state 변수 간에 중복되면 state를 유지하기 어렵습니다.

// ❌
const [selectedItem, setSelectedItem] = useState(items[0]);

// ⭕
const [selectedId, setSelectedId] = useState(0);

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

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: [],
            },
          ],
        },
      ],
    },
  ],
};

// ⭕
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 유지

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked);
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

체크박스를 선택하거나 선택 취소해도 카운터 state는 재설정되지 않습니다. isFancy가 true이든 false이든, 루트 App 컴포넌트에서 반환된 div의 첫 번째 자식에는 항상 Counter가 있기 때문입니다.

같은 위치에 있는 같은 컴포넌트이므로 React의 관점에서 보면 같은 카운터입니다. 즉, React에서 이 두 카운터는 루트의 첫 번째 자식의 첫 번째 자식이라는 동일한 “주소”를 갖기 때문에 같은 컴포넌트로 인식합니다.

동일한 위치의 다른 컴포넌트는 state를 초기화합니다.

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <div>
          <Counter isFancy={true} />
        </div>
      ) : (
        <section>
          <Counter isFancy={false} />
        </section>
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked);
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

counter가 재설정됩니다. 그 이유는 Counter를 랜더링 하면 div의 첫번째 자식은 div에서 section으로 변경되기 때문에 자식 div가 DOM에서 제거되면 그 아래의 전체 트리(카운터 및 해당 state포함)도 함께 제거됩니다.


State 동작 원리 - React Rendering

State 동작 원리를 이해하려면 React가 어떻게 렌더링이 되는지 알아야 합니다. 컴포넌트를 화면에 표시하기 이전에 React에서는 렌더링을 합니다. 각 단계는 아래와 같습니다.

1. 렌더링 trigger

렌더링이 trigger 되는 경우는 2가지가 있습니다.

  1. 컴포넌트의 첫 렌더링인 경우

    import Image from './Image.js';
    import { createRoot } from 'react-dom/client';
    
    const root = createRoot(document.getElementById('root'));
    root.render(<Image />);

    대상 DOM으로 createRoot를 호출한 다음 컴포넌트로 render 메서드를 호출하면 첫 렌더링이 됩니다.

  2. 컴포넌트의 state (또는 상위 요소중 하나)가 업데이트된 경우

    컴포넌트가 처음 렌더링 되면 setState를 업데이트하여 추가 렌더링을 trigger할 수 있습니다. 컴포넌트의 state를 업데이트하면 자동으로 렌더링이 대기열에 추가됩니다.

2. 컴포넌트 렌더링

렌더링이 trigger되면, React는 컴포넌트를 호출하여 화면에 표시할 내용을 파악합니다. 렌더링은 React에서 컴포넌트를 호출하는 것입니다.

  • 첫 렌더링에서 React는 루트 컴포넌트를 호출합니다.
  • 이후 렌더링에서 React는 state 업데이트에 의해 렌더링이 trigger된 함수 컴포넌트를 호출합니다.

이 과정들은 재귀적입니다. 업데이트된 컴포넌트가 다른 컴포넌트를 반환하면 React는 다음으로 해당 컴포넌트를 렌더링하고 해당 컴포넌트를 반환하면 반환된 컴포넌트를 다음에 렌더링하는 방식입니다.

3, DOM에 변경사항을 커밋

React는 컴포넌트를 렌더링한 후 React는 DOM을 수정합니다.

  • 초기 렌더링의 경우 React는 appendChild()를 사용하여 생성한 모든 DOM 노드를 화면에 표시합니다.
  • 리렌더링의 경우 React는 필요한 최소한의 작업(렌더링 하는동안 계산된 것)을 적용하여 DOM이 최신 렌더링 출력과 일치하도록 합니다.

React는 렌더링 간에 차이가 있는 경우에만 DOM 노드를 변경합니다.

export default function Clock({ time }) {
  return (
    <>
      <h1>{time}</h1>
      <input />
    </>
  );
}

매 초 부모로 전달된 다른 props로 다시 렌더링하는 컴포넌트가 있습니다. input태그에 텍스트를 입력하여 value를 업데이트 하지만 컴포넌트가 리렌더링될 때 텍스트가 사라지지 않습니다.

그 이유는 React가 h1태그의 내용만 새로운 time으로 업데이트하기 때문입니다. input태그가 JSX에서 이전과 같으므로 React는 input태그 또는 value를 건드리지 않습니다.

이 단계를 브라우저 렌더링이라고 합니다.


네이밍

// ❌
setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

// ⭕
setEnabled(enabled => !enabled);
setEnabled(prevEnabled => !prevEnabled);

보통 이전 state에 prev를 붙여 사용합니다.


State 객체, 배열 업데이트 특징

복사본 이용하기

mutation이란 객체, 배열 자체의 내용을 변경하는 것을 의미합니다. React state 객체는 기술적으로 변이할 수 있지만, 객체를 직접 변이하는 대신, 항상 교체해야합니다.

// ❌
<div
    onPointerMove={e => {
      position.x = e.clientX;
      position.y = e.clientY;
    }}>
</div>

// ⭕
<div
  onPointerMove={e => {
	  setPosition({
	    x: e.clientX,
	    y: e.clientY
	  });
}}>
</div>

// 전개구문을 사용하여 객체 복사하기
// 주의할 것 : 전개구문은 얕은 복사를 하여 한단계 깊이만 복사합니다.
// ❌
setPerson({
  firstName: e.target.value, // New first name from the input
  lastName: person.lastName,
  email: person.email,
});

// ⭕
setPerson({
  ...person, // Copy the old fields
  firstName: e.target.value, // But override this one
});

// 중첩된 객체 업데이트하기
const [person, setPerson] = useState({
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
    image: 'https://i.imgur.com/Sd1AgUOm.jpg',
  },
});

person.artwork.city = 'New Delhi';

setPerson({
  ...person,
  artwork: {
    ...person.artwork,
    city: 'New Delhi',
  },
});

// Immer사용하기
// https://github.com/immerjs/use-immer

배열에서의 추천 메서드

비추천(mutates the array) 추천(returns a new array)
추가 push, unshift concat, […arr]
삭제 pop, shift, splice filter, slice
교체 splice, arr[i] = a map
정렬 reverse, sort 배열을 복사한 다음 처리

요약

State의 렌더링 과정

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 1);
          setNumber(number + 1);
          setNumber(number + 1);
        }}
      >
        +3
      </button>
    </>
  );
}

버튼을 클릭합니다. 그러면 setNumber(number + 1);가 실행됩니다. setNumber의 호출은 렌더링을 촉발시킵니다. 그러나 setNumber를 호출하더라도 state는 바로 업데이트가 되지 않습니다. React는 큐에 업데이트 할 setNumber를 등록시켜 놓고, onClick 핸들러가 끝나기를 기다립니다. (batching)

큐에 3개의 setNumber(number + 1);가 담겼습니다. onClick 핸들러가 끝나면 렌더링이 시작됩니다. 렌더링은 새로운 함수, 즉 컴포넌트를 호출하는 걸 뜻합니다. 호출된 함수는 새로운 JSX 스냅샷을 반환합니다.

React는 반환된 그 스냅샷과 일치하도록 변화가 필요한 DOM만 화면에 업데이트합니다.


Reference

https://react.dev/

© 2023 datoybi.com
Powered By Gatsby. Hosted By Netlify.