Adding Interactivity

Chaerin Kim·2023년 11월 8일

1. Responding to Events

React를 사용하면 JSX에 이벤트 핸들러를 추가할 수 있음. 이벤트 핸들러는 click, hover, focus 등과 같은 상호작용에 반응하여 트리거되는 자체 함수.

Adding event handlers

이벤트 핸들러는 일반적으로

  • 컴포넌트 내부에 정의됨
  • handle 뒤에 이벤트 이름이 뒤따름(ex. handleClick, handleMouseEnter)

이벤트 핸들러를 추가하려면

  1. 함수를 정의한 후 이를 적절한 JSX 태그에 프로퍼티로 전달해야 함.

    export default function Button() {
      function handleClick() {
        alert('You clicked me!');
      }
    
      return (
        <button onClick={handleClick}>
          Click me
        </button>
      );
    }
  1. JSX에서 이벤트 핸들러를 inline으로 정의할 수도 있음.

    <button onClick={function handleClick() {
      alert('You clicked me!');
    }}>
  2. 더 간결하게, 회살표 함수를 사용할 수도 있음.

    <button onClick={() => {
      alert('You clicked me!');
    }}>

위 세 가지 방법은 동일하며, inline 이벤트 헨들러는 짧은 함수일 때 편리함.

주의!
이벤트 핸들러에 전달되는 함수는 '호출'이 아니라 '전달'되어야 함!

  • 함수 '전달' ✅
    <button onClick={handleClick}>
    <button onClick={() => alert('...')}>
    React는 전달받은 함수를 기억했다가 사용자가 버튼을 클릭할 때만 함수를 호출하도록 지시

  • 함수 '호출' ❌
    <button onClick={handleClick()}>
    <button onClick={alert('...')}>
    JSX { } 내부의 자바스크립트는 바로 실행되기 때문에, handleClick() 끝에 있는 ()은 클릭 없이도 렌더링 중에 즉시 함수를 실행함.

Reading props in event handlers

이벤트 핸들러는 컴포넌트 내부에서 선언되므로 컴포넌트의 프로퍼티에 접근할 수 있음.

function AlertButton({ message, children }) {
  return (
    <button onClick={() => alert(message)}>
      {children}
    </button>
  );
}

export default function Toolbar() {
  return (
    <div>
      <AlertButton message="Playing!">
        Play Movie
      </AlertButton>
      <AlertButton message="Uploading!">
        Upload Image
      </AlertButton>
    </div>
  );
}

Passing event handlers as props

부모 컴포넌트가 자식의 이벤트 핸들러를 지정하고 싶은 경우, 이벤트 핸들러를 프로퍼티로 내려줌.

function Button({ onClick, children }) {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}

function PlayButton({ movieName }) {
  function handlePlayClick() {
    alert(`Playing ${movieName}!`);
  }

  return (
    <Button onClick={handlePlayClick}>
      Play "{movieName}"
    </Button>
  );
}

function UploadButton() {
  return (
    <Button onClick={() => alert('Uploading!')}>
      Upload Image
    </Button>
  );
}

export default function Toolbar() {
  return (
    <div>
      <PlayButton movieName="Kiki's Delivery Service" />
      <UploadButton />
    </div>
  );
}

디자인 시스템을 사용하는 경우, 버튼과 같은 컴포넌트에 스타일링은 포함하지만 동작을 지정하지 않는 것이 일반적임.

Naming event handler props

<button><div>와 같은 기본 제공 컴포넌트는 onClick과 같은 브라우저 이벤트 이름
만 지원하지만, 자체 컴포넌트는 이벤트 핸들러 프로퍼티의 이름을 원하는 방식으로 지정할 수 있음.

이벤트 핸들러 프로퍼티의 이름은 대문자가 뒤따르는 on으로 시작해야 함. (ex. onSmash)

function Button({ onSmash, children }) {
  return (
    <button onClick={onSmash}>
      {children}
    </button>
  );
}

export default function App() {
  return (
    <div>
      <Button onSmash={() => alert('Playing!')}>
        Play Movie
      </Button>
      <Button onSmash={() => alert('Uploading!')}>
        Upload Image
      </Button>
    </div>
  );
}

컴포넌트가 여러 상호작용을 지원하는 경우 이벤트 핸들러 프로퍼티의 이름을 app-specific하게 지정할 수 있음.

export default function App() {
  return (
    <Toolbar
      onPlayMovie={() => alert('Playing!')}
      onUploadImage={() => alert('Uploading!')}
    />
  );
}

function Toolbar({ onPlayMovie, onUploadImage }) {
  return (
    <div>
      <Button onClick={onPlayMovie}>
        Play Movie
      </Button>
      <Button onClick={onUploadImage}>
        Upload Image
      </Button>
    </div>
  );
}

function Button({ onClick, children }) {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}

App 컴포넌트는 Toolbar 컴포넌트가 onPlayMovie 또는 onUploadImage로 어떤 작업을 수행할지(Toolbar의 세부 구현사항) 알 필요가 없음. Toolbar 컴포넌트는 onPlayMovie, onUploadImage를 button에 onClick 핸들러로 전달하지만, 키보드 단축키로도 트리거할 수 있습니다. 프로퍼티의 이름을 onPlayMovie, onUploadImage처럼 app-specific한 상호작용의 이름으로 지정하면 나중에 프로퍼티의 사용 방식을 유연하게 변경할 수 있음.

주의!
이벤트 핸들러에 적절한 HTML 태그를 사용해야 함!
클릭 이벤트 핸들러에 <div> 대신 <button> 태그를 사용하면 키보드 탐색과 같은 기본 브라우저 동작을 사용할 수 있음. 버튼의 기본 브라우저 스타일링잉 마음에 들지 않으면 CSS를 사용할 것! (참고: 접근성을 지키는 마크업 작성)

Event propagation

이벤트 핸들러는 모든 하위 컴포넌트의 이벤트도 포착함. 이를 이벤트가 트리 위로 'bubbles' 또는 'propagates' 된다고 하며, 이벤트가 발생한 위치에서 시작하여 트리를 따라 올라감.

export default function Toolbar() {
  return (
    <div className="Toolbar" onClick={() => {
      alert('You clicked on the toolbar!');
    }}>
      <button onClick={() => alert('Playing!')}>
        Play Movie
      </button>
      <button onClick={() => alert('Uploading!')}>
        Upload Image
      </button>
    </div>
  );
}

두 버튼 중 하나를 클릭하면 해당 버튼의 onClick이 먼저 실행되고 부모 <div>의 onClick이 실행되어 두 개의 메시지가 나타남. Toolbar 자체를 클릭하면 부모 <div>의 onClick만 실행됨.

주의!
onScroll을 제외한 모든 이벤트는 React에서 전파됨.

Stopping propagation

이벤트 핸들러는 이벤트 객체를 유일한 인수로 받고, 일반적으로 이를 "event"를 의미하는 e라고 표기함. 이 객체를 사용하여 이벤트에 대한 정보를 읽을 수 있음.

이 이벤트 객체를 사용하면 전파를 중지할 수도 있음. 이벤트가 부모 컴포넌트에 도달하지 못하도록 하려면 e.stopPropagation()을 호출해야 함.

function Button({ onClick, children }) {
  return (
    <button onClick={e => {
      e.stopPropagation();
      onClick();
    }}>
      {children}
    </button>
  );
}

export default function Toolbar() {
  return (
    <div className="Toolbar" onClick={() => {
      alert('You clicked on the toolbar!');
    }}>
      <Button onClick={() => alert('Playing!')}>
        Play Movie
      </Button>
      <Button onClick={() => alert('Uploading!')}>
        Upload Image
      </Button>
    </div>
  );
}

버튼을 클릭하면

  1. React는 <button>에 전달된 onClick 핸들러를 호출
  2. 버튼에 정의된 이 핸들러는 다음을 수행:
    • 이벤트가 더 이상 버블링되지 않도록 e.stopPropagation()을 호출
    • Toolbar 컴포넌트에서 전달된 프로퍼티인 onClick 함수를 호출
  3. Toolbar 컴포넌트에 정의된 이 함수는 alert를 표시
  4. 전파가 중지되었으므로 부모 <div>의 onClick 핸들러는 실행되지 않음.

Capture phase events
드물지만 하위 요소에서 전파가 중지된 경우에도 모든 이벤트를 포착해야 하는 경우가 있음. (ex. 전파 로직에 관계없이 모든 클릭을 애널리틱스에 기록하고자 할 경우) 이벤트 이름 끝에 Capture를 추가하면 이 작업을 수행할 수 있음.

<div onClick={() => {alert('outer onClick')}} onClickCapture={() => {alert('outer onClickCapture')}}>
  <button onClick={(e) => {e.stopPropagation(); alert('inner onClick')}} onClickCapture={() => {alert('inner onClickCapture')}}/>
</div>
// 결과: outer onClickCapture -> inner onClickCapture -> inner onClick

버튼을 클릭하면 이벤트는 세 단계로 전파됨:
1. 아래로 이동하며 모든 onClickCapture 핸들러를 호출
2. 클릭된 요소의 onClick 핸들러를 호출
3. (전파가 중지되지 않을 경우) 위쪽으로 이동하여 모든 onClick 핸들러를 호출


캡처 이벤트는 라우터나 분석과 같은 코드에서는 유용하지만 앱 구현에는 자주 사용되지 않음.

Passing handlers as alternative to propagation

상위 컴포넌트에서 핸들러를 전달받는 방법으로 이벤트 전파를 대신할 수 있음.

function Button({ onClick, children }) {
  return (
    <button onClick={e => {
      e.stopPropagation();
      onClick();
    }}>
      {children}
    </button>
  );
}

이 방법은 이벤트 전파와 달리 자동으로 처리되지는 않지만, 하위 컴포넌트가 이벤트를 처리하는 동시에 상위 컴포넌트의 일부 추가 기능이 작동되도록 할 수 있음. 또, 이벤트 전파에 의존하여 실행되는 핸들러는 추적이 어려운 반면, 이 방법은 특정 이벤트의 결과로 실행되는 전체 코드 체인을 명확하게 이해하고 추적할 수 있음.

Preventing default behavior

일부 브라우저 이벤트에는 기본 동작이 있음. 예를 들어, <form> 내부의 버튼을 클릭할 때 발생하는 submit 이벤트는 기본적으로 전체 페이지를 다시 로드함. 이러한 기본 동작을 막기 위해서는 e.preventDefault()를 호출해야 함.

export default function Signup() {
  return (
    <form onSubmit={e => {
      e.preventDefault();
      alert('Submitting!');
    }}>
      <input />
      <button>Send</button>
    </form>
  );
}

Can event handlers have side effects?

이벤트 핸들러는 사이드 이펙트가 가장 많이 발생하는 곳!

렌더링 함수와 달리, 이벤트 핸들러는 순수할 필요가 없기 때문에 타이핑으로 입력값을 변경하거나 버튼을 눌러 목록을 변경하는 등 무언가를 변경하기에 적합함. 하지만 정보를 변경하기 위해서는 정보를 저장할 방법이 필요하고, React에서는 컴포넌트의 메모리인 state를 사용해 이 작업을 수행함.


2. State: A Component's Memory

컴포넌트는 상호작용의 결과로 화면에 보여지는 내용을 변경해야 하는 경우가 많음. 예를 들어, form에 타이핑을 하면 입력 필드가 업데이트되어야 하고, 이미지 캐러셀에서 '다음' 버튼을 누르면 표시되는 이미지가 바뀌어야하고, '구매' 버튼을 누르면 상품이 장바구니에 담겨야 함. 컴포넌트는 현재 입력값, 현재 이미지, 장바구니 등을 '기억'해야하고, React에서는 이러한 컴포넌트별 메모리를 state라고 부름.

When a regular variable isn’t enough

import { sculptureList } from './data.js';

export default function Gallery() {
  let index = 0;

  function handleClick() {
    index = index + 1;
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i> 
        by {sculpture.artist}
      </h2>
      <h3>  
        ({index + 1} of {sculptureList.length})
      </h3>
      <img 
        src={sculpture.url} 
        alt={sculpture.alt}
      />
      <p>
        {sculpture.description}
      </p>
    </>
  );
}

handleClick 이벤트 핸들러가 로컬 변수 index를 업데이트하고 있지만, 다음 이유 때문에 아무 일도 발생하지 않는 것 처럼 보임:

  1. 로컬 변수는 렌더링 사이에 지속되지 않음. React가 이 컴포넌트를 두 번째로 렌더링할 때 로컬 변수에 대한 변경 사항을 고려하지 않고 처음부터 렌더링함.
  2. 로컬 변수를 변경해도 렌더링이 트리거되지 않음. React는 새로운 데이터로 컴포넌트를 다시 렌더링해야 한다는 것을 인식하지 못함.

컴포넌트를 새 데이터로 업데이트하려면:

  1. 렌더링 사이에 데이터를 유지해야함.
  2. 새로운 데이터로 컴포넌트를 렌더링하도록 React를 트리거해야함(재렌더링).

useState Hook은 다음 두 가지를 제공함:

  1. 렌더링 사이에 데이터를 유지하는 상태 변수
  2. 변수를 업데이트하고 React가 컴포넌트를 다시 렌더링하도록 트리거하는 상태 setter 함수

Adding a state variable

import { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);

  function handleClick() {
    setIndex(index + 1);
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i> 
        by {sculpture.artist}
      </h2>
      <h3>  
        ({index + 1} of {sculptureList.length})
      </h3>
      <img 
        src={sculpture.url} 
        alt={sculpture.alt}
      />
      <p>
        {sculpture.description}
      </p>
    </>
  );
}

index는 state 변수, setIndex는 setter 함수

여기서 [] 구문을 array destructuring이라고 하며 배열에서 값을 읽을 수 있게 해줌. useState가 반환하는 배열에는 항상 두 개의 항목이 있음.

Meet your first Hook

React에서 useState처럼 "use"로 시작하는 함수를 Hook이라고 함. Hook은 React가 렌더링하는 동안에만 사용할 수 있는 특수 함수로, 이를 통해 다양한 React 기능을 "연결"할 수 있음.

주의!
Hook은 컴포넌트의 최상위 레벨 또는 커스텀 Hook에서만 호출할 수 있음. 조건문, 반복문, 중첩 함수 내부에서는 Hook을 호출할 수 없음.습니다. 조건, 루프 또는 기타 중첩된 함수 내부에서는 Hook을 호출할 수 없음.
Hook은 함수이지만 "컴포넌트의 요구사항에 대한 무조건적인 선언"으로 생각해도 좋음. 파일 상단에서 모듈을 "import"하는 것처럼, 컴포넌트 상단에서 React 기능을 "use"하는 것!

Anatomy of useState

useState를 호출하는 것은, React에게 해당 컴포넌트가 무언가를 기억하기를 원한다고 React에 말하는 것!

useState의 유일한 인수는 상태 변수의 초기 값

컴포넌트가 렌더링될 때마다 useState는 두 개의 값을 포함하는 배열을 반환:

  1. 저장한 값을 가진 state 변수
  2. state 변수를 업데이트하고 React가 컴포넌트를 다시 렌더링하도록 트리거할 수 있는 setter 함수

참고
이 쌍의 이름은 const [something, setSomething]과 같이 정하는 것이 일반적. 원하는 대로 이름을 지정할 수 있지만, 규칙을 따르면 프로젝트에 관계없이 이해가 쉬워짐.

const [index, setIndex] = useState(0);
  1. 컴포넌트의 첫 렌더링: index의 초기값으로 0을 useState에 전달했기 때문에 [0, setIndex]를 반환하고, React는 0이 최신 상태 값임을 기억.
  2. 상태 업데이트: 사용자가 버튼을 클릭하면 setIndex(index + 1)를 호출. index가 0이므로 setIndex(1)을 호출. React는 index가 이제 1이라는 것을 기억하고 다른 렌더링을 트리거.
  3. 컴포넌트의 두 번째 렌더링: React는 여전히 useState(0)을 보지만, 사용자가 index를 1로 설정한 것을 기억하기 때문에 대신 [1, setIndex]를 반환.
  4. 위의 과정 반복!

Giving a component multiple state variables

하나의 컴포넌트에 원하는 만큼 많은 type의 state 변수를 가질 수 있음

import { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    setIndex(index + 1);
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleNextClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i> 
        by {sculpture.artist}
      </h2>
      <h3>  
        ({index + 1} of {sculptureList.length})
      </h3>
      <button onClick={handleMoreClick}>
        {showMore ? 'Hide' : 'Show'} details
      </button>
      {showMore && <p>{sculpture.description}</p>}
      <img 
        src={sculpture.url} 
        alt={sculpture.alt}
      />
    </>
  );
}

위 예제의 index와 showMore처럼 상태가 서로 관련이 없는 경우 여러 개의 state 변수를 사용하는 것이 좋지만, 두 개의 상태 변수를 자주 함께 변경하는 경우 두 변수를 하나로 결합하는 것이 더 쉬울 수 있음. 예를 들어 필드가 많은 form이 있는 경우 필드별로 상태 변수를 사용하는 것보다 객체를 보유하는 단일 상태 변수를 사용하는 것이 더 편리함. (팁: 상태 구조 선택)

DEEP DIVE: How does React know which state to return?
useState는 어떤 state 변수를 참조하는지에 대한 정보를 받지 않음. 전달되는 '식별자'가 없는데, 어떤 상태 변수를 반환할지 어떻게 알까?
Hook은 간결한 구문을 구현하기 위해 동일한 컴포넌트의 모든 렌더링에서 동일한 호출 순서에 의존. '최상위 레벨에서만 Hook을 호출'하는 위의 규칙을 따르면 Hook은 항상 같은 순서로 호출되고, 린터 플러그인은 대부분의 실수를 잡아냄.
내부적으로 React는 모든 컴포넌트에 대해 state 쌍의 배열을 보유하고, 렌더링 전에 0으로 설정된 인덱스를 유지. useState를 호출할 때마다 React는 다음 상태 쌍을 제공하고 인덱스를 증가시킴. (참고: React hooks: not magic, just arrays)

let componentHooks = [];
let currentHookIndex = 0;
// How useState works inside React (simplified).
function useState(initialState) {
  let pair = componentHooks[currentHookIndex];
  if (pair) {
    // This is not the first render,
    // so the state pair already exists.
    // Return it and prepare for next Hook call.
    currentHookIndex++;
    return pair;
  }
  // This is the first time we're rendering,
  // so create a state pair and store it.
  pair = [initialState, setState];
  function setState(nextState) {
    // When the user requests a state change,
    // put the new value into the pair.
    pair[0] = nextState;
    updateDOM();
  }
  // Store the pair for future renders
  // and prepare for the next Hook call.
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

State is isolated and private

동일한 컴포넌트를 두 번 렌더링하면 각 사본은 완전히 분리된 state를 가짐! 그 중 하나를 변경해도 다른 컴포넌트에는 영향을 미치지 않음.

모듈 상단에 선언하는 일반 변수와 달리 state는 특정 함수 호출이나 코드의 특정 위치에 연결되지 않지만 화면의 컴포넌트 인스턴스에 독립적임.

프로퍼티와 달리 state는 그것을 선언하는 컴포넌트에서만 사용할 수 있는 완전한 비공개 데이터이며, 부모 컴포넌트는 이를 변경할 수 없음. 따라서 다른 컴포넌트에 영향을 주지 않고 상태를 추가하거나 제거할 수 있음.

두 컴포넌트 인스턴스의 상태를 동기화하려면? React에서의 올바른 방법은 자식 컴포넌트에서 state를 제거하고 가장 가까운 공유 부모 컴포넌트에 추가하는 것! (참고: 상태 공유하기)


3. Render and Commit

컴포넌트가 화면에 표시되기 전에 React에서 렌더링 과정을 거쳐야 함.

[비유]
컴포넌트: 주방에서 재료로 맛있는 요리를 만드는 요리사
React: 고객의 요청을 접수하고 주문을 가져오는 웨이터

UI를 요청하고 제공하는 이 과정은 세 단계로 이루어짐:

  1. 렌더링 트리거(손님의 주문을 주방에 전달)
  2. 컴포넌트 렌더링(주방에서 주문 준비)
  3. DOM에 커밋(주문을 테이블에 배치)

Step 1: Trigger a render

컴포넌트가 렌더링되는 두 가지 이유:

  1. 컴포넌트의 초기 렌더링
  2. 컴포넌트(또는 그 조상 중 하나)의 상태 업데이트

Initial render

앱이 시작되면 초기 렌더링을 트리거해야함. ReactDOM의 createRoot()에 타겟 DOM 요소를 전달하여 생성된 ReactDOM 루트(root)의 render 메서드를 사용해 렌더링.

import Image from './Image.js';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'))
root.render(<Image />);

Re-renders when state updates

컴포넌트가 처음 렌더링된 후, setter 함수로 상태를 업데이트하여 추가 렌더링을 트리거할 수 있음. 컴포넌트의 상태를 업데이트하면 렌더링이 자동으로 대기열에 추가됨. (레스토랑에서 손님이 첫 주문을 한 후 갈증이나 배고픔의 상태에 따라 차, 디저트 등 다양한 음식을 주문하는 것처럼!)

Step 2: React renders your components

렌더링을 트리거한 후 React는 컴포넌트를 호출하여 화면에 표시할 내용을 파악. "렌더링"은 React가 컴포넌트를 호출하는 것.

  • 초기 렌더링에서 React는 루트 컴포넌트를 호출
  • 이후 렌더링에서 React는 상태 업데이트가 렌더링을 트리거한 함수 컴포넌트를 호출

업데이트된 컴포넌트가 다른 컴포넌트를 반환하면 React는 다음으로 그 컴포넌트를 렌더링하고, 그 컴포넌트 역시 무언가를 반환하면 다음으로 그 컴포넌트를 렌더링하는 식으로 재귀적인 프로세스를 수행. 이 프로세스는 중첩된 컴포넌트가 더 이상 존재하지 않고 React가 화면에 표시해야 할 내용을 정확히 파악할 때까지 계속됨.

주의!
렌더링은 항상 순수한 계산이어야 함:

  • 동일 입력, 동일 출력! 동일한 입력이 주어지면 컴포넌트는 항상 동일한 JSX를 반환해야 함. (토마토가 들어간 샐러드를 주문했는데 양파가 들어간 샐러드가 나오면 안됨!).
  • 컴포넌트는 자기 일에만 신경씀! 렌더링 전에 존재했던 객체나 변수를 변경하지 않아야 함. (하나의 주문이 다른 사람의 주문을 변경해서는 안 됨!)


    그렇지 않으면 코드베이스가 복잡해지면서 혼란스러운 버그와 예측할 수 없는 동작이 발생할 수 있음. "Strict Mode"에서 개발하면 React는 각 컴포넌트의 함수를 두 번 호출하므로 순수하지 않은 함수로 인한 실수를 발견하는 데 도움이 될 수 있음.

DEEP DIVE: Optimizing performance
업데이트된 컴포넌트가 트리에서 매우 높은 위치에 있는 경우 업데이트된 컴포넌트 내에 중첩된 모든 컴포넌트를 렌더링하는 기본 동작은 성능을 저하시킴. 성능 문제가 발생하는 경우 성능 섹션에 설명된 몇 가지 방식으로 해결할 수 있지만, 조급하게 최적화하지 말 것!

Step 3: React commits changes to the DOM

컴포넌트를 렌더링(호출)한 후 React는 DOM을 수정함.

  • 초기 렌더링에서 React는 appendChild() DOM API를 사용해 생성한 모든 DOM 노드를 화면에 배치
  • 이후 렌더링에서 React는 DOM이 최신 렌더링 결과와 일치되기 위해 필요한 최소한의 연산(렌더링하는 동안 계산!)을 적용

React는 렌더링 간에 차이가 있는 경우에만 DOM 노드를 변경함. 예를 들어, 부모로부터 매초마다 다른 props를 전달받아 다시 렌더링하는 Clock 컴포넌트의 에 텍스트를 입력해도 컴포넌트가 다시 렌더링될 때 텍스트는 사라지지 않음!

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

이 마지막 단계에서 React는 <input>이 JSX에서 지난번과 같은 위치에 표시된다는 것을 알기 때문에 <input>이나 그 값을 건드리지 않고, <h1>의 내용만 새로운 시간으로 업데이트함.

Epilogue: Browser paint

렌더링이 완료되고 React가 DOM을 업데이트하면 브라우저는 화면을 다시 칠함. 이 프로세스를 "브라우저 렌더링"이라고 부르지만 문서 전체에서 혼동을 피하기 위해 "페인팅"이라고 부를 것.


4. State as a Snapshot

state 변수는 읽고 쓸 수 있는 일반 JavaScript 변수처럼 보일 수 있지만, state는 스냅샷처럼 동작함. state 변수를 설정해도 이미 가지고 있는 state 변수가 변경되는 것이 아니라 재렌더링이 트리거됨.

Setting state triggers renders

클릭과 같은 사용자 이벤트에 반응하여 사용자 인터페이스가 직접 변경된다고 생각할 수 있지만, 인터페이스가 이벤트에 반응하려면 state가 업데이트되어 React에 렌더링을 요청해야함.

import { useState } from 'react';

export default function Form() {
  const [isSent, setIsSent] = useState(false);
  const [message, setMessage] = useState('Hi!');
  if (isSent) {
    return <h1>Your message is on its way!</h1>
  }
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      setIsSent(true);
      sendMessage(message);
    }}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

function sendMessage(message) {
  // ...
}

버튼을 클릭하면:

  1. onSubmit 이벤트 핸들러 실행
  2. setIsSent(true)는 isSent를 true로 설정하고 새 렌더링을 대기열에 추가
  3. React는 새로운 isSent 값에 따라 컴포넌트를 다시 렌더링

Rendering takes a snapshot in time

'렌더링'이란 React가 컴포넌트, 즉 함수를 호출하는 것. 해당 함수에서 반환하는 JSX는 '시간에 따른 UI 스냅샷'과 같음. 프롭, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해 계산됨.

사진이나 동영상 프레임과 달리 'UI 스냅샷'은 대화형! 입력에 대한 응답으로 어떤 일이 일어날지 결정하는 이벤트 핸들러와 같은 로직이 포함됨. React는 이 스냅샷에 맞춰 화면을 업데이트하고 이벤트 핸들러를 연결. 결과적으로 버튼을 누르면 JSX에서 클릭 핸들러가 트리거됨.

React가 컴포넌트를 다시 렌더링할 때:

  1. React가 함수를 다시 호출
  2. 함수는 새로운 JSX 스냅샷을 반환
  3. React는 함수가 반환한 스냅샷과 일치하도록 화면을 업데이트

컴포넌트의 메모리로서, state는 함수가 반환된 후 사라지는 일반 변수와 다름. state는 실제로 함수 외부에 마치 선반에 있는 것처럼 React 자체에 "존재". React가 컴포넌트를 호출하면 특정 렌더링에 대한 상태의 스냅샷을 제공. 컴포넌트는 해당 렌더링의 상태 값을 사용해 계산된 새로운 프로퍼티 세트와 이벤트 핸들러가 포함된 UI의 스냅샷을 JSX에 반환!

import { useState } from '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는 0이었, 해당 렌더링의 onClick 핸들러에서 setNumber(number + 1)가 호출된 후에도 number의 값은 여전히 0!

버튼의 클릭 핸들러가 React에 지시하는 작업:

  1. setNumber(number + 1): 숫자는 0이므로 setNumber(0 + 1).
    • React는 다음 렌더링에서 숫자를 1로 변경할 준비
  2. setNumber(number + 1): 숫자는 0이므로 setNumber(0 + 1).
    • React는 다음 렌더링에서 숫자를 1로 변경할 준비
  3. setNumber(number + 1): 숫자는 0이므로 setNumber(0 + 1).
    • React는 다음 렌더링에서 숫자를 1로 변경할 준비

setNumber(number + 1)를 세 번 호출했지만, 이 렌더링의 이벤트 핸들러에서 number는 항상 0이므로 상태를 1로 세 번 설정한 것. 이것이 이벤트 핸들러가 완료된 후 React가 컴포넌트를 3이 아닌 1로 다시 렌더링하는 이유!

State over time

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        alert(number);
      }}>+5</button>
    </>
  )
}
// result: 0 -> 10 -> 15

setTimeout으로 delay를 준다면?

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}
// result: 0 -> 10 -> 15

최초에 버튼을 클릭한 시점에 alert에 전달된 number state의 '스냅샷'은 0!

alert가 실행될 때 React에 저장된 상태는 변경되었을 수 있지만, 사용자가 상호작용한 시점의 상태 스냅샷을 사용하여 예약됨!

상태 변수의 값은 이벤트 핸들러의 코드가 비동기적이더라도 렌더링 내에서 절대 변경되지 않음. 해당 렌더링의 onClick 내에서 number의 값은 setNumber(number + 5)가 호출된 후에도 계속 0으로 유지. 이 값은 컴포넌트를 호출해 React가 UI의 스냅샷을 '가져올' 때 '고정'된 값.

다음은 이벤트 핸들러가 타이밍 실수를 줄이는 방법을 보여주는 예.

import { useState } from 'react';

export default function Form() {
  const [to, setTo] = useState('Alice');
  const [message, setMessage] = useState('Hello');

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`You said ${message} to ${to}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{' '}
        <select
          value={to}
          onChange={e => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

'Send' 버튼을 눌러 Alice에게 "Hello"를 보내고, 5초 지연이 끝나기 전에 "To" 필드의 값을 "Bob"으로 변경하면?

결과: 'You said Hello to Alice'

React는 하나의 렌더링 이벤트 핸들러 내에서 상태 값을 '고정'으로 유지하므로, 코드가 실행되는 동안 상태가 변경되었는지 걱정할 필요 없음. 하지만 다시 렌더링하기 전에 최신 상태를 읽고 싶다면? 다음 챕터에서 다룰 상태 업데이터 함수 사용!


5. Queueing a Series of State Updates

상태 변수를 설정하면 다른 렌더링이 대기열에 추가됨. 하지만 다음 렌더링을 대기열에 넣기 전에 값에 대해 여러 연산을 수행하고 싶다면? 이를 위해서 React가 상태 업데이트를 일괄 처리하는 방법을 이해하는 것이 도움이 됨.

React batches state updates

import { useState } from '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는 1씩 증가!

React는 상태 업데이트를 처리하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다림. 이것이 바로 모든 setNumber() 호출 이후에만 리렌더링이 일어나는 이유!

식당에서 주문을 받는 웨이터는 첫 번째 요리가 나오자마자 주방으로 달려가지 않고, 주문이 끝날 때까지 기다렸다가 주문을 변경하고 테이블에 있는 다른 사람의 주문도 받음.

이렇게 하면 너무 많은 리렌더링을 트리거하지 않고도 여러 컴포넌트에서 여러 상태 변수를 업데이트할 수 있음. '일괄 처리'라고도 하는 이 동작은 React 앱을 훨씬 빠르게 실행할 수 있게 해줌. 또한 일부 변수만 업데이트된 혼란스러운 '반쯤 완성된' 렌더링을 처리하지 않아도 됨. 하지만 이는 이벤트 핸들러와 그 안에 있는 코드가 완료될 때까지 UI가 업데이트되지 않는다는 의미이기도 함.

React는 클릭과 같은 여러 의도적인 이벤트에 대해서는 일괄 처리하지 않으며, 각 클릭은 개별적으로 처리됨. React는 일반적으로 안전한 경우에만 일괄 처리를 수행하므로 안심할 것! 예를 들어 첫 번째 버튼 클릭으로 양식이 비활성화되면 두 번째 클릭으로 양식이 다시 제출되지 않도록 보장함.

Updating the same state multiple times before the next render

흔한 상황은 아니지만, 다음 렌더링 전에 동일한 상태 변수를 여러 번 업데이트하고 싶다면 setNumber(number + 1)처럼 다음 상태 값을 전달하는 대신, setNumber(n => n + 1)처럼 대기열의 이전 상태를 기반으로 다음 상태를 계산하는 함수를 전달할 수 있음. 이는 단순히 값을 바꾸는 것이 아니라 React에게 '상태 값으로 무언가를 하라'고 지시하는 방법.

import { useState } from 'react';

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

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

버튼을 한 번 클릭할 때 number는 3씩 증가!
alert: 0, 0 -> 3, 3 -> 6, 6 ...

여기서 n => n + 1을 업데이터 함수라고 하고, 이를 state setter에게 전달하면:

  1. React는 이벤트 핸들러의 다른 모든 코드가 실행된 후에 이 함수가 처리되도록 대기열에 추가
  2. 다음 렌더링 중에 React는 대기열에 쌓인 요청을 모두 실행하고 최종 업데이트된 상태를 제공
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);

위 코드를 포함하는 이벤트 핸들러를 실행하는 동안 React는:

setNumber(n => n + 1): n => n + 1은 함수. React는 이를 큐에 추가.
setNumber(n => n + 1): n => n + 1은 함수. React는 이를 큐에 추가.
setNumber(n => n + 1): n => n + 1은 함수. React는 이를 큐에 추가.

다음 렌더링 중에 useState를 호출하면 React는 큐에 쌓인 요청을 모두 실행. number의 이전 상태는 0이었으므로 React는 이를 첫 번째 업데이터 함수의 인수 n에 전달. 그런 다음 React는 이전 업데이터 함수의 반환값을 가져와서 다음 업데이터에 n으로 전달...

React는 3을 최종 결과로 저장하고, useState에서 반환. 이것이 위 예제에서 "+3"을 클릭하면 값이 3씩 올바르게 증가하는 이유!

What happens if you update state after replacing it

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>Increase the number</button>
    </>
  )
}

이벤트 핸들러를 실행하는 동안 React는:

  1. setNumber(number + 5): number가 0이므로 setNumber(0 + 5). React는 대기열에 "5로 변경"을 추가.
  2. setNumber(n => n + 1): n => n + 1은 업데이터 함수. React는 해당 함수를 대기열에 추가.

다음 렌더링 중에 useState를 호출하면 React는 큐에 쌓인 요청을 모두 실행.

React는 3을 최종 결과로 저장하고, useState에서 반환.

참고!
setState(5)setState(n => 5)처럼 작동하지만 n이 사용되지 않을 뿐!

What happens if you replace state after updating it

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}

이벤트 핸들러를 실행하는 동안 React는:

  1. setNumber(number + 5): number가 0이므로 setNumber(0 + 5). React는 대기열에 "5로 변경"을 추가.
  2. setNumber(n => n + 1): n => n + 1은 업데이터 함수. React는 해당 함수를 대기열에 추가.
  3. setNumber(42): React는 대기열에 "42로 변경"을 추가.

React는 42를 최종 결과로 저장하고, useState에서 반환.

[요약]

setter 함수에:

  • 업데이터 함수(ex. n => n + 1)가 전달되면: 전달된 업데이터 함수를 큐에 추가.
  • 값(ex. 5)이 전달되면: 큐에 '값으로 바꾸기'를 추가하고 이미 큐에 대기중이던 항목은 무시.

이벤트 핸들러가 완료되면 React는 다시 렌더링을 트리거. 다시 렌더링하는 동안 React는 대기열을 처리.

업데이터 함수는 렌더링 중에 실행되므로 업데이터 함수는 순수해야 하고, 결과만 반환해야 함. 업데이트 함수 내부에서 상태를 변경하거나, 사이드 이펙트 코드를 실행해서는 안됨. Strict Mode에서 React는 각 업데이터 함수를 두 번 실행(두 번째 결과는 버림)하여 실수를 찾을 수 있도록 도와줌.

Naming conventions

업데이터 함수의 인수 이름은 해당 상태 변수의 첫 글자로 지정하는 것이 일반적


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

좀 더 자세한 코드를 선호하는 경우, 또 다른 일반적인 규칙은 setEnabled(enabled => !enabled)와 같이 전체 상태 변수 이름을 반복하거나 setEnabled(prevEnabled => !prevEnabled)와 같은 접두사를 사용하는 것.


6. Updating Objects in State

상태는 객체를 포함한 모든 종류의 자바스크립트 값을 저장할 수 있음. 하지만 React state에 있는 객체를 직접 변경해서는 안됨. 대신 객체를 업데이트하려면 새 객체를 생성하거나 기존 객체의 복사본을 만들어 사용하도록 state를 설정해야 함.

What’s a mutation?

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

number, string, boolean과 같은 기본 타입 값은 '불변(immutable)', 즉 변경할 수 없거나 '읽기 전용'인 값. 재렌더링을 트리거하여 새로운 값으로 '대체(replacement)'할 수 있음. x 상태가 0에서 5로 변경되었지만 숫자 0 자체는 변경되지 않았음.

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

JavaScript 객체는 '변경(mutation)'할 수 있음. 하지만 React의 state로 설정된 객체 값은 기술적으로 변경 가능하더라도 number, string, boolean처럼 '불변(immutablee)'값으로 취급해야 함. 그러므로 '변경(mutation)' 대신, 항상 새로운 값으로 '대체(replacement)'해야 함.

Treat state as read-only

즉, 상태로 설정된 JavaScript 객체는 읽기 전용(read only)으로 취급해야 함.

import { useState } from 'react';


export default function MovingDot() {

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

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

이 예제는 현재 마우스 포인터 위치를 나타내는 객체를 상태로 관리함. 빨간색 점은 마우스 포인터와 함께 이동되어야하지만 처음 위치에서 움직이지 않음.

원인: 상태 업데이트 함수를 사용하지 않으면 React는 객체가 변경된 것을 알 수 없음. 따라서 React는 아무런 응답도 하지 않는 것! 상태 변경이 때때로 작동할 수 있다 하더라도 React에서 권장되는 방법은 아님. 렌더링 중에 접근할 수 있는 상태 값은 읽기 전용으로 취급해야 함!

<div
  onPointerMove={e => {
    // 새로운 상태 값으로 설정할 position 객체 갱성
    const newPosition = { x: e.clientX, y: e.clientY };
		// 컴포넌트 리-렌더링 트리거(요청)
    setPosition(newPosition);
  }}
> 
  {/* ... */}
</div>```

해결: 실제로 재렌더링을 트리거하려면 **새 객체를 만든 후 상태 업데이트 함수에 전달**해야함.

### Copying objects with the spread syntax
이전 예제에서 `position` 객체는 항상 현재 커서 위치에서 새로 만들어짐. 그러나 기존 데이터를 새로 만드는 객체의 일부로 포함시키고 싶다면, [전개 구문(...)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)를 사용할 수 있음.

```jsx
import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    setPerson({
      ...person,
      firstName: e.target.value
    });
  }

  function handleLastNameChange(e) {
    setPerson({
      ...person,
      lastName: e.target.value
    });
  }

  function handleEmailChange(e) {
    setPerson({
      ...person,
      email: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

규모가 큰 form의 경우 각 입력 필드에 대해 별도의 state 변수를 선언하는 것 보다, 모든 데이터를 객체에 그룹화하여 보관하는 것이 훨씬 편리!

단, 전개 구문 “얕은 복사”를 수행함. 얕은 복사는 속도가 빠르지만, 중첩된 속성을 업데이트 하려면 2번 이상 전개 구문을 사용해야 함.

Updating a nested object

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의 값을 업데이트하고싶다면,

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

또는

setPerson({
  ...person, // Copy other fields
  artwork: { // but replace the artwork
    ...person.artwork, // with the same one
    city: 'New Delhi' // but in New Delhi!
  }
});

처럼 코드를 작성해야함.

Write concise update logic with Immer

state가 깊게 중첩되어 있는 경우 평면화(flatten) 할 수 있음. 하지만 상태 구조를 변경하고 싶지 않다면 중첩 전개구문 대신 Immer 라이브러리를 사용할 수도 있음. Immer는 '변경 가능'한 구문을 사용하면 사본 생성을 자동으로 처리해줌. Immer를 사용하면 코드가 "규칙을 깨고" 객체를 변경하는 것처럼 보임.

updatePerson(draft => {
  draft.artwork.city = 'Lagos';
});

하지만 Immer는 일반 뮤테이션과 달리, 이전 state를 수정하지 않고 새로운 상태 값으로 '대체(replacement)'함!


7. Updating Arrays in State

자바스크립트에서는 배열을 변경할 수 있지만 state에 저장할 때는 변경할 수 없는 것으로 취급해야 함. 객체와 마찬가지로 state에 저장된 배열을 업데이트하려면 새 배열을 생성하거나 기존 배열의 복사본을 만들어 사용하도록 state를 설정해야 함.

Updating arrays without mutation

자바스크립트에서 배열은 또 다른 종류의 객체일 뿐! 객체와 마찬가지로 React의 state 배열은 읽기 전용으로 취급해야 함. 즉, arr[0] = 'bird'와 같이 배열 내부의 항목을 재할당해서는 안 되며, push() 및 pop()과 같이 배열을 변경하는 메서드도 사용해서는 안 됨.

대신 배열을 업데이트할 때마다 state setter 함수에 새 배열을 전달해야 함. 이렇게 하려면 filter() 및 map()과 같은 비변환(non-mutating) 메서드를 호출하여 state의 원래 배열로부터 새 배열고, state를 새 배열로 업데이트 해야함.

업로드중..

또는, 두 종류의 메서드들을 모두 사용할 수 있는 Immer를 사용하는 방법도 있음.

주의!
slicesplice는 이름이 비슷하지만 매우 다름.

  • slice는 배열 또는 배열의 일부를 복사
  • splice는 배열을 변경(항목을 삽입하거나 삭제).
    React에서는 state 객체나 배열을 변경하지 않아야 하므로 slice를 훨씬 더 자주 사용하게 될 것!

Adding to an array

push()는 배열을 변경함 ❌

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

대신, 기존 항목과 끝에 새 항목을 포함하는 새 배열을 생성할 수 있음. 가장 쉬운 방법은 ... 배열 스프레드 구문을 사용하는 것.

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

배열 스프레드 구문을 사용하면 원본 배열의 복사본 앞에 항목을 추가할 수도 있음.

setArtists([
  { id: nextId++, name: name },
  ...artists // Put old items at the end
]);

이런 식으로 스프레드는 배열의 끝에 항목을 추가하는 push()와 배열의 시작 부분에 항목을 추가하는 unshift()의 기능을 모두 수행할 수 있음.

Removing from an array

배열에서 항목을 제거하는 가장 쉬운 방법은 필터링! 즉, 해당 항목을 포함하지 않는 새 배열을 생성. 이렇게 하려면 filter() 메서드를 사용하면 됨.

import { useState } from 'react';

let initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [artists, setArtists] = useState(
    initialArtists
  );

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

여기서 artists.filter(a => a.id !== artist.id)는 "artist.id와 다른 ID를 가진 아티스트로 구성된 배열을 생성한다"는 의미. 즉, 각 아티스트의 "Delete" 버튼을 누르면 원본 배열에서 해당 아티스트를 필터링한 새로운 배열로 다시 렌더링하도록 요청. filter()는 원본 배열을 수정하지 않음!

Transforming an array

배열의 일부 또는 모든 항목을 변경하려면 map()을 사용하여 새 배열을 만들 수 있음. map에 전달할 함수는 데이터 또는 인덱스(또는 둘 다)에 따라 각 항목에 대해 수행할 작업을 결정.

import { useState } from 'react';

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

export default function ShapeEditor() {
  const [shapes, setShapes] = useState(
    initialShapes
  );

  function handleClick() {
    const nextShapes = shapes.map(shape => {
      if (shape.type === 'square') {
        // No change
        return shape;
      } else {
        // Return a new circle 50px below
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // Re-render with the new array
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        Move circles down!
      </button>
      {shapes.map(shape => (
        <div
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}

위 예에서 state 배열인 shapes는 두 개의 원과 하나의 정사각형의 좌표를 포함. 버튼을 누르면 map()을 사용해 새로운 데이터 배열을 생성하고, 원만 50픽셀 아래로 이동함.

Replacing items in an array

배열에서 하나 이상의 항목을 바꾸고 싶은 경우, arr[0] = 'bird'와 같은 할당은 원래 배열을 변경하는 것이므로 대신 map을 사용하는 것이 좋음.

map 호출 내에서 두 번째 인수로 항목의 인덱스를 받게 되는데, 이를 사용하여 원래 항목(첫 번째 인수)을 반환할지 바뀐 다른 항목을 반환할지 결정.

import { useState } from 'react';

let initialCounters = [
  0, 0, 0
];

export default function CounterList() {
  const [counters, setCounters] = useState(
    initialCounters
  );

  function handleIncrementClick(index) {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        // Increment the clicked counter
        return c + 1;
      } else {
        // The rest haven't changed
        return c;
      }
    });
    setCounters(nextCounters);
  }

  return (
    <ul>
      {counters.map((counter, i) => (
        <li key={i}>
          {counter}
          <button onClick={() => {
            handleIncrementClick(i);
          }}>+1</button>
        </li>
      ))}
    </ul>
  );
}

Inserting into an array

시작도 끝도 아닌 특정 위치에 항목을 삽입하고 싶은 경우 ... 배열 스프레드 구문을 slice() 메서드와 함께 사용할 수 있음. slice() 메서드를 사용하면 배열의 "슬라이스"를 잘라낼 수 있음. 항목을 배열의 중간에 삽입하려면 삽입 지점 앞에 슬라이스를 펼치고, 새 항목을 삽입하고, 남은 원래 배열을 펼치는 새로운 배열을 만들면 됨.

import { useState } from 'react';

let nextId = 3;
const initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState(
    initialArtists
  );

  function handleClick() {
    const insertAt = 1; // Could be any index
    const nextArtists = [
      // Items before the insertion point:
      ...artists.slice(0, insertAt),
      // New item:
      { id: nextId++, name: name },
      // Items after the insertion point:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        Insert
      </button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

Making other changes to an array

반전, 정렬과 같이 스프레드 구문과 map(), filter()와 같은 비변환 메서드만으로는 할 수 없는 일도 있음. 자바스크립트의 reverse()sort() 메서드는 원래 배열을 변경하므로 직접 사용할 수 없음.

대신, 배열을 먼저 복사한 후 복사한 배열을 변경할 수 있음.

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies' },
  { id: 1, title: 'Lunar Landscape' },
  { id: 2, title: 'Terracotta Army' },
];

export default function List() {
  const [list, setList] = useState(initialList);

  function handleClick() {
    const nextList = [...list];
    nextList.reverse();
    setList(nextList);
  }

  return (
    <>
      <button onClick={handleClick}>
        Reverse
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

스프레드 구문을 사용하여 먼저 원본 배열의 복사본을 만들고, 복사된 배열에 reverse() 또는 sort()와 같은 메서드를 사용하거나 nextList[0] = "something"으로 개별 항목을 할당할 수도 있음.

하지만 배열을 복사하더라도 그 에 있는 항목을 직접 변경할 수는 없음. 복사는 얕은 수준이기 때문에, 새 배열은 원본 배열과 동일한 항목들로 구성되어 있음. 따라서 복사된 배열 내부의 객체를 수정하면 기존 상태를 변경하는 것!

const nextList = [...list];
nextList[0].seen = true; // Problem: mutates list[0]
setList(nextList);

nextListlist는 서로 다른 두 배열이지만, nextList[0]list[0]은 같은 객체를 가리킴. 따라서 nextList[0].seen을 변경하면 list[0].seen도 변경되고, 이는 상태 변이이므로 피해야 함! 중첩된 JavaScript 객체를 업데이트할 때와 비슷한 방법으로 이 문제를 해결할 수 있는데, 변경하려는 개별 항목을 변경하는 대신 복사하는 것.

Updating objects inside arrays

객체는 실제로 배열의 "내부"에 위치하지 않음. 코드에서는 "내부"에 있는 것처럼 보일 수 있지만 배열의 각 객체는 배열이 "가리키는" 별도의 값. 그렇기 때문에 list[0]과 같이 중첩된 필드를 변경할 때 주의해야 함.

중첩된 상태를 업데이트할 때는 업데이트하려는 지점부터 최상위 수준까지 복사본을 만들어야 함.

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    const myNextList = [...myList];
    const artwork = myNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setMyList(myNextList);
  }

  function handleToggleYourList(artworkId, nextSeen) {
    const yourNextList = [...yourList];
    const artwork = yourNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setYourList(yourNextList);
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

위 예에서는 두 개의 개별 아트웍 목록의 초기 상태가 동일. 두 목록은 분리되어야 하지만 mutation으로 인해 상태가 실수로 공유되어 한 목록의 상자를 선택하면 다른 목록에 영향을 미침.

myNextList 배열 자체는 새 배열이지만 항목 자체는 원래 myList 배열과 동일. 따라서 artwork.seen을 변경하면 원본 아트웍 항목이 변경됨. 해당 아트웍 항목이 yourList에도 있으므로 버그가 발생. 이와 같은 버그는 생각하기 어려울 수 있지만, 다행히도 상태가 변하지 않도록 하면 사라짐!

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    setMyList(myList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

map()을 사용하여 mutation 없이 이전 항목을 업데이트된 버전으로 대체할 수 있음.

Write concise update logic with Immer

중첩된 배열을 변이 없이 업데이트하는 작업은 객체와 마찬가지로 다소 반복적일 수 있음.

  • 일반적으로 상태를 몇 레벨 이상 깊이 업데이트할 필요는 없음. 상태 객체의 깊이가 매우 깊은 경우, 평평해지도록 객체를 다르게 재구성하는 것이 좋음.
  • 상태 구조를 변경하고 싶지 않다면, 편리한 mutation 구문을 사용하여 코드를 작성하면 자동으로 사본을 생성해주는 Immer를 사용하는 것이 좋음.

0개의 댓글