컴포넌트에서 setState를 사용 할 때 무슨 일이 일어나는지 고민 해본적이 있습니까?

import React from 'react';
import ReactDOM from 'react-dom';

class Button extends React.Component {
    constructor(props) {
        super(props);
        this.state = { clicked: false };
        this.handleClick = this.handleClick.bind(this);
    };

    handleClick() {
        this.setState({ clicked: true });
    };

    render() {
        if (this.state.clicked) {
            return <h1>Thanks</h1>;
        }

        return (
            <button onClick={this.handleClick}>
                Click me!
            </button>
        );
    }
};

ReactDOM.render(<Button />, document.getElementById('container'));

리액트는 state가 { clicked: true }로 바뀔때, 렌더링을 통해 DOM이 <h1>Thanks</h1>로 변경된다.

간단해 보이지만 의문이 있다. React가 하는 것일까 ReactDOM이 하는 것일까

보통 DOM을 변경하는 것은 ReactDOM이 처리 하는 것으로 생각된다.
그러나 this.setState()를 실행 할 때, ReactDOM에서 가져오지 않는다.
또한, class에 기반한 React.Component는 React 자체에 구현되어 있다.

어떻게 React.Component 내부의 setState()는 DOM을 변경 시킬수 있을까

경고! : 사실 내 블로그의 글들은 React를 생산적으로 사용하는데 필수는 아니다. 이 글은 React 내부에 대한 내용으로 완전히 옵션이다!


React.Component 클래스는 DOM 변경 처리로직을 포함해야 된다고 생각해왔을 것이다.

그러나 this.setState()는 다른 환경에서 어떻게 동작하고 있습니까.
예를 들어, React Native app 또한 React.Component를 상속 받는다.
위에 예시처럼 this.setState()를 사용 하고 있지만, Android와 iOS의 렌더링 되는 뷰는 DOM이 아니다.

Test RendererShallow Renderer를 알고 있을 것이다.
위 두가지의 테스트 방법은 컴포넌트를 렌더하고, 컴포넌트 내부에서 this.setState()를 실행하는 것이다.
그러나 DOM과 함께 동작되지는 않는다.

React ART 같은 렌더러를 사용 할때, 페이지에서 하나 이상의 렌더러를 사용 할 수 있다는 것을 알 수 있다.
(예를 들어, ART Components는 React DOM tree 내부에서 동작한다.)
이렇게 하면 전역 플래그나 변수 유지를 할 수 없게 된다.

그래서 React.Component는 어떻게든 state 변화에 대한 핸들링을 플랫폼 관련 코드에 위임한다.
어떻게 동작하는지 이해하기 전에, 패키지들이 어떻게 분리되는지, 왜 분리되는지 조금 더 자세하게 알아보자.


흔히들 하는 오해중 하나가 React "엔진"은 react 패키지 내부에 있다는 것이다. 사실이 아니다.

사실 이 패키지는 React 0.14 버전 이후로 분리되었다.(package split in React 0.14)
react 패키지는 의도적으로 컴포넌트가 정의된 API들을 노출시켰다. React 구현의 대부분은 "renderers" 안에 정의되어 있다

react-dom, react-dom/server, react-native, react-test-renderer, react-art 같은 것들이 renderers의 분류된다(본인의 것을 스스로 빌드 할 수도 있다).

이게 바로 react 패키지가 플랫폼에 종속 되지 않고 사용되는 이유이다.
React.Component, React.createElement, React.Children과 같은 모든 유틸리티들이 전부, 그리고 Hooks까지 특정 플랫폼에 종속 되지 않는다.
ReactDOM, ReactDOM server, React Native와 같은 다양한 환경에서, 컴포넌트는 import 이후에 동일한 방법으로 실행한다.

반면에, 렌더러 패키지는 플랫폼 종속적 API, ReactDOM.render() 같은 DOM node 내부의 React 체계를 노출시켰다.

각 렌더러들은 API를 제공한다.
이상적으론 대부분 컴포넌트들이 렌더러로부터 import를 하는것이 필요하지 않다.
이러한 것들이 좀더 렌더러들의 자유도를 높여준다.

많은 사람들이 상상하는 리액트 엔진은 실제로 각각의 렌더러 내부에 있습니다.
-> (브라우저에서 동작하는 렌더러[react-dom], 스마트폰에서 동작하는 렌더러[react-native]에 각각 리액트 엔진이 있습니다)

많은 렌더러들은 같은 코드의 복사본을 하나씩 가지고 있습니다. 우리는 이 '같은 코드'를 reconciler라고 부르죠.
-> (리액트 엔진 == 같은 코드 == reconciler )

webpack같은 번들러들은 보다 나은 퍼포먼스를 위해 reconciler 코드'와 '렌더러 코드'를 뭉쳐서 최적화된 번들을 만듭니다.

하지만 같은 코드의 복사본을 다수의 렌더러들이 가지고 있는 건 '번들 사이즈'를 고려한다면 좋지 않습니다.
react-dom과 react-native를 함께 번들링하면 결과물에 같은 코드가 2개가 되니까요.

보통 절대 다수의 리액트 유저들은 한번에 하나의 렌더러만 필요하니까 큰 문제는 아닐겁니다.

이제 왜 reactreact-dom 패키지는 새로운 기능을 업데이트하기 위해 필요한 것을 알 수 있다.
예를 들어, Context API, React.createContext()는 React 16.3에서 추가될 때, React 패키지에 노출되었다.

그러나 사실 React.createContext()는 context의 기능을 구현하지 않는다.
구현은 ReactDOM과 ReactDOM Server는 각각 다를 필요가 있다.
예를 들어, createContext()는 몇몇 일반 객체를 반환한다.

// A bit simplified

function createContext (defaultValue) {
    let context = {
        _currentValue: defaultValue,
        Provider: null,
        Consumer: null,
    };

    context.Provider = {
        $$typeof: Symbol.for('react.provider'),
        _context: context,
    };

    context.Consumer = {
        $$typeof: Symbol.for('react.context'),
        _context: context,
    };

    return context;
}

<MyContext.Provider><MyContext.Consumer>를 코드에서 사용 할 때, 렌더러는 이것들을 어떻게 핸들링 할지 결정한다.
ReactDOM은 한가지 방법으로 context value들을 추적해야 했다.
그러나 ReactDOM Server는 다르게 동작한다.

그래서 16.3 버전 이상에서 react를 업데이트 할 때, react-dom은 업데이트 되지 않았다.
당신은 아직 ProviderConsumer 타입 같은 특별한 렌더러를 사용 할 때 아직 모를거다.

이것이 더 버전이 오래된 react-dom이 사용가능한 이유이다.

React Native 에서도 동일하다.
그러나 ReactDOM과 다르게 React 릴리즈는 즉시 React Native 릴리즈를 강제 실행 하지 않는다.
그쪽은 독립적인 일정을 가지고 있다.
업데이트된 렌더러 코드는 몇주에 한번씩 React Native repository에 각각 동기화 된다.
이것이 React Native가 ReactDOM과 일정이 다름에도 기능을 사용 가능하게 하는 이유이다.


좋다, 그래서 우리는 react 패키지는 많은것을 포함하지 않는것을 알았다.
구현체는 react-dom, react-native 같은 렌더러들에 포함되어있다.
그러나 어떻게 setState()React.Component에서 올바른 렌더러에게 작동하는지 답변이 충분하지 않다.

정답은 모든 렌더러는 생성된 클래스에서 특별한 필드를 설정한다

특별한 필드는 updater라고 불린다.

당신의 클래스 인스턴스가 생성된 이후에, ReactDOM, ReactDOM Server, React Native 같은 것들은 거의 올바르게 설정한다.

// Inside React DOM

const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMUpdater;

// Inside React DOM Server

const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMSererUpdater;

// Inside React Native

const inst = new YourComponent();
inst.props = props;
inst.updater = ReactNativeUpdater;

React.ComponentsetState 구현체를 살펴보면, 모든것이 컴포넌트 인스턴스를 생성한 렌더러에 동작하도록 위임되었다.

// A bit simplified

setState(partialState, callback) {
    // Use the 'updater' field to talk back to the renderer!
    this.updater.enqueueSetState(this, partialState, callback);
};

ReactDOM Server는 state 변화를 무시하고 경고하는 반면, ReactDOM과 React Native는 reconciler의 복사본을 처리하게 한다.

이게 this.setState()가 React 패키지에 정의되어 있지만, DOM을 업데이트하는 방법입니다.
React DOM에 의해 설정된 this.updater를 읽고 ReactDOM schedule을 허용하고 업데이트를 처리합니다.


class에 대해선 알았으니 Hooks는 어떻게 생각하나

Hooks API를 처음 봤을 때, 궁금한 점이 있다.
useState가 무엇을 어떻게 하는지 추정으론 React.Component 클래스의 this.setState()보다 "마법" 같다는 것이다.

그러나 오늘 위의 글에서 보듯, 기본 this.setState() 구현체도 훌륭하다.
렌더러로 호출을 전달하는 것을 제외하고는 아무 것도하지 않는다.
useState Hook은 똑같은 일을합니다.

Hooks는 updater 필드 대신, "dispatcher" 객체를 사용한다.
React.useState(), React.useEffect()나 Hooks에서 만들어진 것들을 사용 할 때, dispatcher로 전달된다.

// In React (simplified a bit)

const React = {
    // Real property is hidden a bit deeper, see if you can find it!
    __currentDispatcher: null,

    useState(initialState) {
        return React.__currentDispatcher.useState(initialState);
    },

    useEffect(initialState) {
        return React.__currentDispatcher.useEffect(initialState);
    },
    // ...
};

그리고 각각의 렌더러들은 컴포넌트가 렌더링되기전에 dispatcher를 설정한다.

// In React DOM

const prevDispatcher = React.__currentDispatcher;
React.__currrentDispatcher = ReactDOMDispatcher;

let result;

try {
    result = YourComponent(props);
} finally {
    // Restore it back
    React.__currentDispatcher = prevDispatcher;
};

예를 들면, ReactDOM Server 구현체나, ReactDOM과 React Native가 공유하는 reconciler 구현체가 해당된다.

이게 바로 ReactDom과 같은 렌더러가 Hooks같은 동일한 React 패키지에 접근해야하는 이유이다.
그렇지 않으면 컴포넌트가 dispatcher를 참조 하지 않는다.
동일한 컴포넌트 구조에 React의 복사본이 여러 개있을 때 기능이 작동하지 않을 수 있다.
그러나 이러한 점은 항상 불분명 한 버그로 이어져 있으므로 Hooks는 공수가 들기 전에 패키지 복제본을 해결하도록 한다.

권장하지는 않지만 진보된 기술 사용 사례의 경우 dispatcher를 기술적으로 무시할 수 있다.
(__currentDispatcher 이름에 대해 거짓말을했지만 React repo에서 찾을 수 있다.)
예를 들어 React DevTools는 특별한 목적으로 만들어진 dispatcher를 사용하여 JavaScript 스택 추적을 캡처하여 Hooks 구조를 스스로 수정한다.
집(?)에서 이것을 반복하지 말자.

이것은 또한 Hooks가 본질적으로 React에 종속적이지 않다는 것을 의미한다.
나중에 더 많은 라이브러리가 동일한 원시적인 Hooks를 다시 사용하려는 경우, dispatcher는 별도의 패키지로 분리되어 좀 더 나은 이름의 일급 클래스 API로 공개 될 수 있다.
실제로 필요가있을 때까지 조기 추상화를 피하는 것이 좋다.

updater 필드와 __currentDispatcher 객체는 모두 종속성 주입이라는 일반적인 프로그래밍 원칙의 형태이다.
두 경우 모두 렌더러는 setState와 같은 기능의 구현을 일반 React 패키지에 삽입하여 구성 요소를보다 선언적으로 유지한다.

이런것들이 React를 사용할 때 어떻게 작동하는지 생각할 필요는 없다.
React 사용자는 의존성 주입과 같은 추상 개념보다 응용 프로그램 코드에 대해 생각하는 데 더 많은 시간을 할애해야 한다.
그래도 this.setState(), useState()가 어떻게 동작하는지 궁금했다면 도움되길 바란다.

원문 - How Does setState Know What to Do?