React Learn - 이벤트 핸들러로 state 를 변경하는 방법

ChoiYongHyeun·2024년 2월 18일
0

리액트

목록 보기
3/31

리액트의 Learn React 5강 챕터 중 두 번째 챕터를 공부하고 추상화 된 개념을
다시 적어보는 공부 기록장입니다
Adding Interactivity

Responding to Events

Adding event handlers

export default function Button() {
  function handleClick() {
    alert('You clicked me!');
  } // 이벤트 호출 시 실행할 함수 객체 선언 

  return (
    <button onClick={handleClick}> // onClick 이벤트 발생 시 handleClick 함수 호출
      Click me
    </button>
  );
}

다음과 같이 특정 태그에 이벤트 핸들러를 부착해줄 수 있다.

JSX 에서는 인라인 스타일로 이벤트 핸들러를 부착해준다.

종종하는 실수는 이벤트 핸들러 부착시 함수를 호출해버리는 것이다.

<button onClick={handleClick()}> // 함수 객체가 아닌 함수를 호출하는 경우

이벤트 핸들러 자체의 의미는 해당 이벤트가 발생 할 시 해당 함수를 호출하는 것인데

위처럼 onClick = {handleClick()} 으로 호출해버리는 경우엔

처음 렌더링 될 때 {} 안을 평가하는 과정에서 handleClick() 가 실행되어 호출된 후

onClick 이벤트 발생시에는 더 이상 호출되지 않는 경우가 존재한다.

그러니 함수 객체를 부착해주든지 화살표 함수로 한 번 더 감싸주도록 하자

<button onClick={() => handleClick()}> // 함수 호출을 쓰고 싶다면 화살표 함수로 감싸주자 

Passing event handlers as props

이벤트 핸들러를 props 형태로 전달해주면 컴포넌트를 더욱 pure 하게 관리하는 것이 가능하다.

function Button({ onClick, children }) {
  return (
    <button onClick={onClick}> // onClicck handler 를 props 로 받아 컴포넌트 반환
      {children}
    </button>
  );
}

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

  return (
    <Button onClick={handlePlayClick}> // Button 컴포넌트를 이용해 PlayButton 컴포넌트 생성
      Play "{movieName}"
    </Button>
  );
}

function UploadButton() {
  return (
    <Button onClick={() => alert('Uploading!')}> // Button 컴포넌트를 이용해 UploadButton 컴포넌트 생성
      Upload Image
    </Button>
  );
}

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

이런 디자인 시스템을 활용하면 각 컴포넌트를 더욱 간결하고 명확하게 표현 할 수 있으며

확장성 및 관리 용이성이 높다.

Event Propagation

td 태그에 이벤트 핸들러를 부착했을 때 자바스크립트의 이벤트 호출은 다음과 같은 단계를 거친다.

  1. 사용자가 td 태그에 이벤트를 발생시킴
  2. document 로부터 td 태그까지 이벤트가 전파되는 Capture Phase 단계를 거침

    td 태그는 전파되는 단계들의 자식 태그이기 때문에 이벤트가 전파되는 것이 직관적으로 이해 된다.

  3. td 태그에 도착하는 단계인 Target Phase 단계를 거치며 등록된 이벤트 핸들러가 호출됨
  4. td 태그에서 document 까지 상위적으로 이벤트가 전파되는 Bubbling Phase 단계를 거친다. 이 때 상위 태그들에서 같은 이벤트에 대한 이벤트 핸들러가 있을 경우 호출된다.

이처럼 이벤트는 Capturing -> target Phase -> Bubbling 단계를 거치기 때문에

특정 컴포넌트에서 이벤트 발생 시 , 해당 컴포넌트와 동일한 이벤트 리스너가 등록된 부모태그들의 이벤트 핸들러도

모두 같이 호출된다. 이러한 일을 Event Propagation 이라고 한다.

Event Propagation은 자식 컴포넌트의 이벤트를 부모 컴포넌트에서도 탐지 할 수 있도록 해주는 편리한 기능이면서

예상치 못한 결과를 내기도 한다.

이를 방지하기 위해서 event.stopPropagation() 을 사용 하여 버블링 단계에서의 전파를 막아줄 수 있다.

event 는 이벤트가 발생한 이벤트 객체이다.
자바스크립트 딥다이브 - 이벤트

function Button({ onClick, children }) {
  return (
    <button onClick={e => {
      e.stopPropagation(); // Button 컴포넌트의 이벤트 핸들러에서는 Propagation을 막는다.
      onClick();
    }}>
      {children}
    </button>
  );
}

export default function Toolbar() {
  return (
    <div className="Toolbar" onClick={() => {
      alert('You clicked on the toolbar!');
    }}>
      <Button onClick={() => alert('Playing!')}> // 해당 컴포넌트에선 propagation 이 일어나지 않는다.
        Play Movie
      </Button>
      <Button onClick={() => alert('Uploading!')}> // 해당 컴포넌트에선 propagation 이 일어나지 않는다.
        Upload Image
      </Button>
    </div>
  );
}

Preventing default behavior

각 태그들은 우리가 부착하는 이벤트 핸들러 외에도

개별적으로 가지고 있는 기본적인 이벤트가 존재한다.

예를 들어 Form 태그 내에서 제출 버튼을 누르면 페이지가 새로고침 되는 등의 내용이 그렇다.

export default function Signup() {
  return (
    <form onSubmit={e => {
      e.preventDefault(); // preventDafault 를 이용해 해당 태그의 기본 이벤트 핸들러의 호출을 막음 
      alert('Submitting!');
    }}>
      <input />
      <button>Send</button>
    </form>
  );
}

이는 preventDefault 를 이용하여 태그의 기본 이벤트 핸들러의 호출을 막아줄 수 있다.

Should Event handlers be pure ?

컴포넌트들의 경우엔 상위 환경의 변수들을 변경하면 안된다고 하였는데

그럼 이벤트 핸들러도 pure 해야 할까 ?

이벤트 핸들러는 pure 해야 할 필요가 없다.

이는 컴포넌트와 이벤트 핸들러의 역할이 확실히 다르기 때문이다.

컴포넌트는 동일한 입력값만 주어진다면 동일한 출력값을 주는 하나의 같은 역할을 하기에 pure 해야 한다.

이벤트 핸들러는 반대로 발생하는 이벤트에 따라 호출되어 , 컴포넌트들의 상태를 변경시켜 인터렉티브한 페이지를 제공해주기 위한 도구이다.

그러기에 오히려 이벤트 핸들러는 pure 하기 보다 컴포넌트의 상태를 조작하는 것이 더 자연스럽다.

그럼 이런 의문이 든다.

컴포넌트들의 상태를 담는 자료구조는 무엇일까 ?

State: A Component's Memory

컴포넌트들은 인터렉티브하게 변화가 발생하는 데이터에 맞춰

발생한 데이터에 따라 다른 값을 렌더링 하도록 설계 되었다.

이렇게 변화가 발생하는 데이터들을 담는 자료구조를 state 라고 한다.

리액트 공식문서에서는 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;
    console.log(index); // local variable 의 값을 로깅 
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}> // button click 시 index + 1 증가 
        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 의 값을 증가시키도록 설정한 컴포넌트가 있을 때

별 생각 없이 보면 버튼이 눌릴 때 마다 index 가 변하니 렌더링 되는 내용도 변할 것이라고 기대하는데

실상은 그렇지 않다.

하지만 index 의 값이 변하지 않는 것은 아니다.
로그 되는 값을 보자

이런 일이 발생 하는 이유는

지역변수의 상태 변화와 페이지 렌더링의 변화가 일치하고 있지 않기 때문이다.

지역 변수인 index 는 초기 컴포넌트 값에 영향을 주지만

이벤트 핸들러에 의해 변화가 일어나도 re-rendering 이 일어나지 않는다.

그로 인해 컴포넌트들의 상태를 담을 자료들은 두 가지 조건을 만족해야 한다.


1. state value 는 변화에 의해 렌더링이 일어났을 때 변화한 상태를 유지해야 한다 .
2. state setter function 을 이용해 state 값을 변경시키고 , 값이 변경되면 re-rendering 을 일으켜야 한다.

Adding a state variable

import { useState } from 'react'; // react 클래스의 useState 메소드 사용

 // state value , state setter function 를 반환하는 use State 메소드 호출
const [index, setIndex] = useState(0);

위 두 조건을 만족하는 state 를 사용하기 위해선 useState 를 이용 할 수 있다.

useState 는 컴포넌트가 호출 될 때 단 한 번 호출된다.

그렇기 때문에 useState(initalState) 에 들어가는 인수는 컴포넌트가 호출될 때 사용 할 첫 번째 state 값이다.

useStatestateValue , state setter function 두 가지를 반환한다.

state setter function 은 위에서 말한 조건 중 2 번째 조건을 만족한다.

  1. state 값을 변경시킨다.
  2. state 값의 변화가 일어나면 컴포넌트를 re-rendering 한다.
import { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0); // useState 를 이용해 index , setIndex 생성
  // setIndex 는 index 의 값을 변경시킬 수 있는 state setter function

  function handleClick() {
    setIndex(index + 1); // eventHandler 에 setIndex 함수 부착 
    console.log(index) // index 로깅
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}> // 실행 때 마다 setIndex(index + 1); 이 실행됨
        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>
    </>
  );
}

useState 가 반환하는 두 번째 반환값인 stateSetterFunction 에 대한 내용이 없었지만

상태 변경이 일어나면 다시 렌더링 하도록 설정되어 있을 것이라 생각한다.

setIndex() 로 인해 변경되는 index 값과 다르게 로깅 되는 index 값은 상태 변경 되기 전의 값이 로깅되고 있는 모습을 볼 수 있다.

이에 대한 내용은 다음 강의에서 이야기 하도록 한다.

이전 local variable 로 상태 관리를 하는 것이 렌더링을 일으키지 않기 때문에 사용하지 말기로 하였다.

그러니 상태를 변경시킬 때는 useState 를 통해 받은 setterFunction 을 이용해주도록 하자

setterFunction 에는 값으로 평가 가능한 문을 넣을 수 있다.
콜백함수를 넣는 것도 가능하다.

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

다음처럼 말이다.

Anatomy of useState

useState 의 내부 동작 구조는 밝혀진 적 없지만

리액트 공식 문서에서 다른 아티클을 보도록 권장하고 있다.

리액트에서 사용하는 useReact 와 같은 로직은 아니지만
useReact 의 동작 원리를 이해하는데 도움이 될 것이라고 한다.
React hooks: not magic, just arrays 에서 예시로 제공하는 코드를 뜯어보자

function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi");
  const [lastName, setLastName] = useState("Yardley");

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

다음처럼 버튼에 따라 상태를 변경시키는 컴포넌트가 존재한다고 가정해보자

useState 의 가상 폴리필

let state = []; // state 를 관리 할 buffer
let setters = []; // setters 를 관리 할 buffer
let firstRun = true; // buffer 에 아무 값이 없을 때를 확인하는 flag 
let cursor = 0; // buffer 의 인덱스 

function createSetter(cursor) { // 0. setter function 을 만드는 함수
  return function setterWithCursor(newVal) { 
    // state 에 인덱스로 접근하여 이전 값을 newVal 로 변경하는 메소드
    state[cursor] = newVal;
  };
}

// This is the pseudocode for the useState helper
export function useState(initVal) {
  if (firstRun) { // 1. buffer 에 아무 값도 존재하지 않을 경우 
    state.push(initVal); // 2. buffer 에 initVal  , setterFunc 를 넣어준다. 
    setters.push(createSetter(cursor));
    firstRun = false; // 3. flag off 
  }

  const setter = setters[cursor]; // 4. buffer 에 state , setterFunc 을 담아준다.
  const value = state[cursor];

  cursor++; // 5. 다음 줄에 있는 useState 들도 buffer 에 담아주도록 cursor 값 ++ 
  return [value, setter]; // 6. state , setterFunc 반환
}

useState 는 컴포넌트가 생성 될 때 단 한 번씩만 호출된다.

재렌더링 될 때 마다 useState 가 호출되는 것이 아니라는 말이다.

function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi"); 
  // 1. states = ['Rudi'] , setters = [setFirstName ] , cursor = 0 -> 1
  const [lastName, setLastName] = useState("Yardley");
  // 2. states = ['Rudi' , lastName] , setters = [setFirstName , setLastName ] , cursor = 1 -> 2

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

이런식으로 각 useStatearray 형태로 state , setterFunc 을 관리하고

statestates 에서의 주소값을 가리키고 있는 포인터를 할당 받고

setterFuncstates 에 담긴 states 들을 받은 인수값으로 수정하도록 한다.

그로 인해 setterFunc 으로 인해 states 의 값이 변경되면 state 값도 같이 변경된다.

하지만 이 방법은 리액트 공식문서에서도 말하듯이 정확히 React 에서 사용하는 방법은 아니지만
로직은 비슷하다고 한다.

이처럼 array 형태로 useState 로 나오는 인수값들을 관리하기 때문에

useState 들은 몇 가지 규칙을 갖는다.

  1. useState 는 해당 환경에서 가장 위 상단에 존재해야 하며
  2. unconditionally 하게 호출해야 한다.

    조건문을 이용해서 useState 를 사용하지 말라는 것이다.
    useState 의 반환값은 순서가 중요한 buffer 에 자료를 담기 때문에 무조건적으로 순차적이게 buffer 에 담도록 해야 한다.

State is isolated and private

각 컴포넌트에 존재하는 state 들은 독립적이고 본인만 참조 가능하다 .

function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi"); 
  const [lastName, setLastName] = useState("Yardley");

  return ( // 반환되는 컴포넌트는 useState 의 반환값들을 참조하는 클로저 객체이다. 
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

그로 인해 동일한 컴포넌트를 여러개 사용하여도 서로의 state 가 침범되지 않는다.


Render and Commit

리액트의 렌더링 단계는 3가지 단계를 갖는다.

Trigger , Render , Commit

여기서 Render 단계는 Actual DOMUser Interface 에 렌더링 하는 것과 다른 의미를 갖는다.

리액트의 렌더링 단계는 Actual DOM 이 아닌 Virtual DOM 이 주체가 된다. 리액트에서는 Virtual DOM 을 렌더링 하고, 이전 virtual DOM 과의 차이가 나는 노드만 가지고 Actual DOM 을 수정한다.

진짜 리액트 공식문서 일러스트 너무 귀엽다

리액트 공식문서에서는 이 3가지 단계를 레스토랑에 브라우저 손님이 등장했을 때 리액트 웨이터의 행동 양상으로 예시를 든다.

  • Trigger 는 브라우저 손님이 주문을 하여, 리액트 웨이터가 컴포넌트들에게 주문서를 전달하는 것이다.
  • Render 는 컴포넌트들이 열심히 주문 사항을 조합하여 음식을 리액트 웨이터에게 전달하는 것이다.
  • Commit 은 리액트 웨이터가 음식을 확인하고 브라우저 손님에게 음식을 전달하는 것이다.

Trigger

Trigger 는 말 그대로 Render the virtual DOM 을 야기시키는 단계이다.

Virtual DOMRender 하는 경우는 두 가지가 존재한다.

  1. React 가 처음 실행되어 initial render 가 필요한 경우
  2. virtual DOM nodestate 가 변경되어 re-render 가 필요한 경우

Initial Render

웹 브라우저를 처음 시행하는 경우 주 마크업 페이지에는

아무런 노드가 존재하지 않거나, 관례적으로 body 태그 내부에 <div id = 'root'></div> 하나만 달랑 존재하는 경우가 흔하다.

Initial Render 단계에서는 virtual DOM 을 생성하기 위해 마크업 페이지에서 virtual DOM 의 진입부가 될
root node 를 생성하거나 선택한 후 Render -> Commit -> Actual DOM Render 단계를 거친다.

Re-render

virtual DOMRe-render 가 필요한 경우는 컴포넌트가 가지고 있는 state 가 변경됐을 때이다.
(useState 에서 받은 setter function 을 이용하였을 때를 의미한다.)

Re-render 는 무조건적으로 initial Render 가 일어난 후 발생한다.

intial Render 없이 어떻게 Re-render 가 되겠는가 !

만약 initial Render 를 하던 찰나 state 가 변경된다면 즉각적으로 Re-render 가 일어나는 것이 아니라 re-render 를 위해 스케줄링 되었다가 initial render 가 종료 된 후 Re-render 가 일어난다.

정리

Trigger 단계는 Render 가 필요한 경우를 Render 단계를 발생시키는 단계이다.

ps. state 의 변화 유무는 얕은 비교를 통해 확인한다고 한다.

Render

리액트에서의 Render 는 컴포넌트를 호출하여 virtual DOM 을 생성하는 과정이다.

"Rendering is React calling your components"

Virtual DOM 을 구성하는 과정으로 재귀적으로 root node 부터 모든 node 를 구성한다.

Virtual DOM 을 구성하는 것은 비용이 크지 않다. 단순히 메모리 상에서 JS 객체만 담아주는 것이기 때문이다.

Actual DOM 을 조작하는 것은 그럼 왜 비용이 큰데 ?!

Actual DOM 에서도 node 를 수정하거나 조회하고 조작하는 등의 행위의 비용이 엄청나게 큰 것은 아니다.
물론 단순한 객체보다는 다양한 메소드들을 가지고 있으니 용량이 크긴 하겠지만

진짜 비용이 큰 것은 node의 조작이 일어나면 발생하는 reflow , repaint 과정이 큰 것이다.
그래서 리액트는 node 하나하나를 조작 할 때 마다 reflow , repaint 를 시키는 기보다
변경 할 node 들을 모두 모아뒀다가 한 번에 일괄적으로 Actual DOM에 적용시키고자 하는 것이다.

리액트는 rendering 과정에서 virtual dom 을 항상 생성한다. virtual dom 은 실제 actual dom 의 구조와 유사한 형태로

user interface 에 렌더링 되는 actual dom 에게 정보를 제공하는 역할을 한다.

생성된 virtual dom 은 이전에 생성한 (actual dom 에게 정보를 전달한) virtual dom 과의 차이점을 비교한다.

이런 과정을 Diffing 이라고 한다.

차이점을 비교하는 이유는 현재 user interface 와의 상호작용이 존재하였기에 state 가 변경되었으며 변경됨에 따라 새로운 actual dom 을 구성해야 하기 때문이다.

Diffing 과정을 통해 actual dom 에서 수정이 필요한 부분만을 모아둔다.

Commit

Commit 단계는 render 과정에서 diffing 해둔 노드들을 실제 actual dom 에게 전달하는 과정을 의미한다.

이런 과정을 reconciliation 이라고 한다.

actual dom 을 전부 새롭게 렌더링 하기 보다 수정이 필요한 부분만 일괄적으로 전달함으로서 호율적으로 actual dom rendering 을 더 빠르게 할 수 있다.

commit 에 사용된 virtual dom 은 이후 rendering 단계에서의 비교 대상이 된다.

이전에 사용된 virtual dom 이 된다는 것이다.

Trigger -> Render -> Commit 단계를 통해 React 가 얻는 이점

  1. 속도가 훨씬 빠르다.

상태 변경에 따라 Virtual DOM 간 차이점을 확인하고, 틀린 부분만 새롭게 Actual DOM 에서 렌더링 하는 것은 실제 Actual DOM 의 모든 부분을 렌더링 하는 거나, 새로운 페이지를 받아오는 것보다 훨씬 빠르다.

  1. 상태와 UI 간의 통일성을 유지 할 수 있다.

상태 변경에 따라 의무적으로 Trigger 가 발생하기 떄문에 상태 변화에 따른 UI 와의 통일성을 유지하는 것이 가능하다.


State as a Snapshot

찰카닥

이 부분을 이해하는데 좀 시간이 오래 걸렸다

import { useState } from 'react';

export default function App() {
  const [num, setNum] = useState(0);

  function plusOne() {
    setNum(num + 1);
  }

  function plusThree() {
    setNum(num + 1);  
    setNum(num + 1); 
    setNum(num + 1);
  }

  function Reset() {
    setNum(0);
  }

  return (
    <>
      <p>{num}</p>
      <button onClick={plusOne}> + 1</button>
      <button onClick={plusThree}> + 3</button>
      <button onClick={Reset}>reset</button>
    </>
  );
}

다음처럼 plusThree 메소드는 setNum(num + 1) 을 3번씩 호출함으로서

plusThree 메소드가 달린 버튼인 +3 을 클릭하면 num 이 3씩 늘어나기를 기대했다.

하지만 setNum(num + 1) 을 몇번을 호출하든 한 번만 호출한 것과 같은 효과를 보였다.

  function plusThree() {
    setNum(num + 9999999999);
    setNum(num + 123123123123);
    setNum(num + 1); // <- num + 1 로 상태가 변경됨
  }

내가 만약 plusThree 메소드를 이렇게 정의해뒀다면 마지막에 호출된 setNum 부분이 적용된다.

왜 이런일이 발생할까 ?

state changes are processed asynchronously

react 에서 상태 변화는 비동기적으로 처리된다.

엥 ? 갑자기 비동기 ?

하는 마음이 들 수 있는데 비동기 처리라는 것은 상태변화를 일으킨 실행 컨텍스트가 종료 된 후 처리된다는 것이다.

블록문 내부에서 상태변화가 일어났다면 블록문이 종료 된 후 , 함수 내부에서 상태 변화를 일으켰다면 함수의 모든 실행을 종료 한 후 말이다.

상태 변화를 비동기적으로 처리시키는 이유는 상태의 변화가 일어나면 렌더링을 트리거하게 되는데

블록문 내에서 상태 변화가 일어날 때 마다 렌더링을 새롭게 하는 것보다

블록문이 모두 종료 된 후 일괄적으로 모아 렌더링을 한 번에 하는 것이 효율적이기 때문이다.

	// 만약 비동기 처리를 지원 안했다면 
  function plusThree() {
    setNum(num + 1); // 여기서 렌더링 한 번 더 되고
    setNum(num + 1); // 여기서 렌더링 또 되고
    setNum(num + 1); // 여기서 마지막으로 렌더링이 될 것임
    				// 그러면 3이 증가하긴 했을 것이다
  }

리액트는 실행 컨텍스트 내부의 상태 변화를 일으키는 코드를 FIFO 형태인 buffer 에 저장해두었다가

컨텍스트가 모두 종료되면 그 때 컨텍스트들을 일괄적으로 실행시킨다.

그러니 컨텍스트 내부에서 상태를 변화시키더라도 , 내부에서의 상태값 자체는 컨텍스트가 종료되기 전까진 변경되지 않는다는 것이다.

  function plusThree() {
    setNum(num + 1); // 현재의 num = 0 , setter buffer = [setNum(0 + 1) , ]
    setNum(num + 1); // 현재의 num = 0 , setter buffer = [setNum(0 + 1) , setNum(0 + 1)]
    setNum(num + 1); // 현재의 num = 0 , setter buffer = [setNum(0 + 1) , ...]
  }
// plusThree 가 모두 호출되고 나면 
// setter buffer = [setNum(0 + 1) , ...] 가 실행되며 num = 0 에서 num = 0 + 1 이 되어 변경된다.

왜 이렇게 하도록 하였을까 ?

  • 렌더링 호출을 감소시키기 위해서
  • 급격한 상태 변경에도 UI 가 안정적이고 반응성을 유지하도록 보장하여 UX 경험 향상

    만약 상태 하나 하나가 변경 될 때 마다 렌더링이 된다면 UI 와 상태값이 일치하지 않는 예상치 못한 오류가 발생 할 수 있음

  • 컨텍스트 별 렌더링을 한 번씩 발생하도록 강제함으로서 개발자 경험 향상

    내 메소드에서 렌더링이 몇 번 일어나고 .. 그럼 뭐 어쩌구 저쩌구 .. 이런 잡걱정을 안해도 된다.

그러니 위에서 말했던 오류는 다음처럼 해결이 가능하다.

// setNum(num + 1) 을 3번 호출해도 마지막 setNum(num + 1) 만 호출되니
  function plusThree() {
    setNum(num + 1);  
    setNum(num + 1); 
    setNum(num + 1);
  }

  function plusThree() {
    // 상태 변경은 한 번에 하도록 수정하자 
    setNum(num + 3);  
  }

value returned is a closure that remembers the state and props

import { useState } from 'react';

export default function App() {
  const [num, setNum] = useState(0);

  function plusOne() {
    setNum(num + 1);
  }

  function delayAlert(delay) {
    setTimeout(() => {
      alert(`current num is ${num}`); // 버튼이 눌렸을 때 num 을 alert 시킴 
    }, delay);
  }

  return (
    <>
      <p>{num}</p>
      <button onClick={plusOne}> + 1</button>
      <button
        onClick={() => {
          delayAlert(1000);
        }}
      >
        alert !
      </button>
    </>
  );
}

+1 버튼을 3번 눌러 num = 3 이 되었을 때 alert 버튼을 눌러 추후 발동되도록 하고

다시 num = 5 가 될 때 까지 버튼을 추가로 눌렀다.

이후 num = 5 가 됐을 때 alert 의 결과물이 나왔다.

current num is ??? 일 때 ??? 는 3일까 5일까?

비동기 적으로 처리된 delayAlert 같은 경우에는 본인이 호출된 시점의 num 을 기억하고 있다.

비동기적으로 처리되는 코드가 모두 저렇게 처리 되나 .. ? 내가 알고 있던게 다른가 ? 하고 실험해보면

// 일반적인 경우 

let num = 0;

num += 1;
num += 1;
num += 1;
setTimeout(() => {
  console.log(num);
}, 1000); // 5 <- 호출 시점의 num 값을 로깅한다.
num += 1;
num += 1;

호출되는 당시의 num 값을 로깅한다.

차이점이 뭘까 곰곰히 생각했는데

컴포넌트의 생김새를 다시 열심히 보니 답이 나왔다.

export default function App() {
 //const [num, setNum] = useState(0); 
  // 0. num 값은 호출될 때 마다 state 값에 따라 알아서 지정됨
  
  const num = something  // 2. 호출 당시 지정된 num 값을 가리키고 있구나 
  ...

  return (
    // 1. 해당 반환문에 들어가는 num 은 모두 
    <>
      <p>{num}</p> 
      <button onClick={plusOne}> + 1</button>
      <button
        onClick={() => {
          delayAlert(1000);
        }}
      >
        alert !
      </button>
    </>
  );
}

리액트에서 재렌더링 된다는 것은 해당 컴포넌트를 재호출 한다는 것이였다.

이 때 반환되는 컴포넌트들은 각자 호출 당시의 실행 컨텍스트와 호출 당시의 변수 를 기억하고 있는

클로저 객체이다. 그러니 delayAlert 에서 console.log(num) 에 쓰인 num 은 호출 당시의 num 인 3이 되는 것이다.

let state = 0; // 컴포넌트에서 참조하는 state 값 

function App(flag = false) {
  const curState = state++; // 호출될 때 마다 state 값이 1씩 증가한다고 가정
  if (flag) {
    setTimeout(() => {
      console.log(curState);
    }, 1000);
  }
}

App(); // curState = 0
App(); // curState = 1
App(); // curState = 2
App(true); // curState = 3
App(); // curState = 4
App(); // curState = 5

요런 느낌일 것이다.

왜 공식 문서에서 statesnapshot 처럼 행동한다고 했는지 이해가 됐다.


Queueing a Series of State Updates

위에서 state 를 변경하는 함수는 비동기적으로 실행된다고 하였다.

import { useState } from 'react';

export default function App() {
  const [num, setNum] = useState(0);

  function plusThree() {
    setNum(num + 1);
    setNum(num + 1);
    setNum(num + 1);
  }

  function Reset() {
    setNum(0);
  }

  return (
    <>
      <p>{num}</p>
      <button onClick={plusThree}> + 3</button>
      <button onClick={Reset}>reset</button>
    </>
  );
}

다시 이 예시를 들고 와서 생각해보자

여기서 plusThree 가 호출되면 setNum(0 + 1) 부분이 queueing 되고 결국 마지막 호출인

setNum(0+1) 이 호출되어 1씩만 증가하는 모습을 볼 수 있었다.

그런데 만약 setNum(???)num + 1 과 같은 값이 아닌 할 일을 함수로 전달해주면 어떻게 될까 ?

  function plusThree() {
    setNum((n) => n + 1);
    setNum((n) => n + 1);
    setNum((n) => n + 1);
  }

이런식으로 말이다.

ㅋㅋ 잘되쥬? 물론 일반적인 경우는 아니다
    setNum(num + 1); // 이건 안되고
    setNum(num + 1);
    setNum(num + 1);

	setNum(n => n + 1); // 이건 되는 이유
	setNum(n => n + 1);
	setNum(n => n + 1);

ReactsetNum() 이 실행 될 때 마다 렌더링을 새롭게 하는 것이 아닌

setNum() 들이 담긴 콜스택 (FIFO를 지키는)이 모두 비었을 때 state 값을 비교 하고 렌더링 한다.

setNum() 이 실행 될 때 마다 state 값이 변경되는 것은 맞고 , 이전과 비교하는 행위는 콜스택이 모두 비었을 때 한다는 것이다.

setNum(num + 1) 의 경우

이전 함수가 호출 될 때마다 실행 컨텍스트가 달라지기 때문에 setNum(num + 1)num

이전에 선언된 값을 기억하고 있다고 하였다.

그러니 여전히 setNum(num + 1)setNum(0 + 1) 인 것이다.

버퍼에 setNum(1) , setNum(1) , setNum(1) 이 쌓여있으니 state 는 3번 1로 변경되고

마지막 변경 이후 이전 state 와 비교된다.

setNum(n => n + 1) 의 경우

리액트 공식문서에서는 n => n+1 처럼 state 를 변경시키기를 기대하고 넣어준 함수를

updater 라고 칭하고 있으니 나도 업데이터라고 하겠다.

업데이터는 pending state 를 인수로 받고 next state 를 반환하는 함수라고 한다.

setNum(n => n + 1) 그러니 여기서 n 은 현재의 state 값을 받는다는 것이다.

setNum(n => n + 1) , setNum(n => n + 1) , setNum(n => n + 1) 가 버퍼에서 시행되면

상태값이 변함에 따라 인수인 n값도 변해

setNum(0 => 0 + 1) , setNum(1 => 1 + 1) , setNum(2 => 2 + 1) 결국 3씩 증가한다는 것이다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글