황준일 님의 개발 블로그에 작성된 포스팅 가운데 Vanilla Javascript로 React UseState Hook 만들기를 보게 되었다. 그동안 React 함수형 컴포넌트를 이용한 개발은 많이 해봤지만 useState
, useEffect
등의 Hook이 어떤 식으로 동작하는지는 알아본 적이 없는 것 같았다. 그래서 React Hook은 실제로 어떻게 동작하는지 궁금해졌고, 찾아보다보니 클로저와의 관계성을 발견하여 클로저 개념을 중심으로 동작 방식을 간단히 구현하고 이해해보고자 한다.
💡 클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical Environment)의 조합이다. - MDN
클로저의 정의를 다르게 표현하면,
클로저란 함수가 속한 렉시컬 스코프를 기억하여 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능이다.
let foo = 1 // global scope
function add() {
foo += 1
return foo
}
console.log(add()) // 2
console.log(add()) // 3
console.log(add()) // 4
foo 변수에 1을 더해주는 add 함수가 있다. 위 코드는 정상적으로 동작하지만 아래와 같이 작성할 경우 문제가 발생한다.
let foo = 1 // global scope
function add() {
foo += 1
return foo
}
console.log(add()) // 2
console.log(add()) // 3
foo = 9999
console.log(add()) // 10000
foo를 global에서 접근하여 임의로 수정이 가능하다는 점이다.
function add() {
let foo = 1
foo += 1
return foo
}
console.log(add()) // 2
console.log(add()) // 2
foo = 9999 // 'foo' is not defined.
console.log(add()) // 2
다음은 외부에서 foo에 접근할 수 없지만 add가 정상적으로 동작하지 못한다.
function getAdd() {
let foo = 1
return function () {
foo += 1
return foo
}
}
const add = getAdd()
console.log(add()) // 2
console.log(add()) // 3
foo = 9999 // 'foo' is not defined.
console.log(add()) // 4
이때 다음과 같이 클로저를 활용할 수 있다. 이렇게되면 getAdd 함수에서 add 함수를 반환해 함수를 add 함수를 호출할 때 마다 getAdd 함수에 선언되어있는 foo에 접근할 수 있다.
혹은 아래와 같은 모듈 패턴을 통해 클로저를 만들 수도 있다.
const add = (function getAdd() {
let foo = 1
return function () {
foo += 1
return foo
}
})()
console.log(add()) // 2
console.log(add()) // 3
console.log(add()) // 4
이러한 클로저의 성질을 이용해 React Hook은 만들어졌다. useState를 클로저를 활용해 구현해보자.
useState
훅을 이용하면 함수형 컴포넌트에서 상태 값을 관리할 수 있다.
useState
는 초기값을 넘겨받아 상태값(count)과 상태값을 변경하는 함수(setCount=setState)를 반환한다. 일반적으로 두 요소를 비구조화 할당을 통해 변수에 할당해 사용한다.
const [state, setState] = useState(initialValue);
함수형 컴포넌트에서 이전과 현재 상태의 변경 여부를 감지하기 위해서는 함수가 실행됐을 때 이전 상태에 대한 정보를 가지고 있어야 한다. React는 이 과정에서 클로저 개념을 이용한다.
이번에는 useState를 간단히 구현해보자.
function useState(initialState) {
let value_ = initialState;
const state = value_;
const setState = (newState) => {
value_ = newState;
};
return [state, setState];
}
const [count, setCount] = useState(0);
console.log(count); // 0
setCount(1);
console.log(count); // 0
위 코드는 의도대로 동작하지 않는다. 이는 count
값이 한 번 가져오고 끝난 값이기 때문이다. 이를 의도대로 동작시키려면 const state = value_
부분을 함수 형태로 바꿔, 값을 쓰는게 아니라 호출해주는 식으로 바꾼다면 호출할 때마다 값을 가져오기 때문에 setCount
가 반영된 값을 얻을 수 있다.
function useState(initialState) {
let value_ = initialState;
const state = () => value_; // 호출하는 방식으로 수정
const setState = (newState) => {
value_ = newState;
};
return [state, setState];
}
const [count, setCount] = useState(0);
console.log(count()); // 0
setCount(1);
console.log(count()); // 1
위 코드는 정상적으로 동작은 되지만, state가 변수가 아닌 getter 함수로 정의되어 있어 리액트의 useState와는 차이가 있다.
그래서 이번에는 hook을 React 모듈 안으로 넣어 state를 변수로 사용하도록 useState를 변경해보자. 즉, 클로저를 또 다른 클로저의 내부로 이동시켜 해결해보자.
const React = (function () {
function useState(initialState) {
let value_ = initialState;
const state = () => value_;
const setState = (newState) => {
value_ = newState;
};
return [state, setState];
}
return { useState };
})();
그리고 안에 훅을 넣은 함수인 Component를 만든다.
const React = (function () {
function useState(initialState) {
let value_ = initialState;
const state = () => value_;
const setState = (newState) => {
value_ = newState;
};
return [state, setState];
}
function render(Component) {
const C = Component();
C.render();
return C;
}
return { render, useState };
})();
function Component() {
const [count, setCount] = React.useState(0);
return {
render: () => console.log(count),
increase: () => setCount(count + 1),
};
}
var App = React.render(Component); // [Function: state]
App.increase();
var App = React.render(Component); // [Function: state]
현재는 콘솔에 state function이 출력되므로 value_
을 함수 외부에 선언하고, 를 수정해보자.
let value_;
const React = (function () {
function useState(initialState) {
const state = value_ || initialState;
const setState = (newState) => {
value_ = newState;
};
return [state, setState];
}
function render(Component) {
const C = Component();
C.render();
return C;
}
return { render, useState };
})();
function Component() {
const [count, setCount] = React.useState(0);
return {
render: () => console.log(count),
increase: () => setCount(count + 1),
};
}
var App = React.render(Component); // 0
App.increase();
var App = React.render(Component); // 1
이제 정상적으로 동작되도록 구현한 것 같다. 그러나 아직 하나의 문제가 존재한다.
위 구현은 컴포넌트가 단 하나의 state만을 가진다. 그러나 실제 구현에서는 여러 개의 Hook을 가질 수 있다.
const [count, setCount] = React.useState(0);
const [text, setText] = React.useState("apple");
따라서 React는 state를 useState 외부에 배열 형식으로 관리한다. useState에 저장된 state들은 배열에 순서대로 저장되고, 이러한 state 배열은 컴포넌트를 구분 짓는 유일한 키를 통해 접근할 수 있다.
배열과 인덱스를 이용해 위 코드를 변경하면 다음과 같다.
const React = (function () {
let hooks = [];
let idx = 0;
function useState(initialState) {
const state = hooks[idx] || initialState;
const setState = (newState) => {
hooks[idx] = newState;
};
idx++; // 다음 훅을 받을 수 있게 인덱스 증가
return [state, setState];
}
function render(Component) {
const C = Component();
C.render();
return C;
}
return { render, useState };
})();
function Component() {
const [count, setCount] = React.useState(0);
const [text, setText] = React.useState('apple');
return {
render: () => console.log({ count, text }),
increase: () => setCount(count + 1),
type: (word) => setText(word),
};
}
var App = React.render(Component); // { count: 0, text: 'apple' }
App.increase();
var App = React.render(Component); // { count: 1, text: 'apple' }
App.type('orange');
var App = React.render(Component); // { count: 'orange', text: 'apple' }
increase()는 잘 동작하지만, type()을 실행할 때 count가 ‘orange’로 바뀌게 된다. 이는 App 컴포넌트가 render 되면 useState 함수를 호출하고, 그때마다 계속해서 index가 증가되기 때문이다. 따라서 render 될 때마다 index를 0으로 초기화 해주자.
const React = (function () {
let hooks = [];
let idx = 0;
function useState(initialState) {
const state = hooks[idx] || initialState;
const setState = (newState) => {
hooks[idx] = newState;
console.log(hooks);
};
idx++;
return [state, setState];
}
function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}
return { render, useState };
})();
function Component() {
const [count, setCount] = React.useState(0);
const [text, setText] = React.useState('apple');
return {
render: () => console.log({ count, text }),
increase: () => setCount(count + 1),
type: (word) => setText(word),
};
}
var App = React.render(Component);
App.increase();
var App = React.render(Component);
App.type('orange');
var App = React.render(Component);
// 실행 결과
// { count: 0, text: 'apple' }
// { count: 0, text: 'apple' }
// { count: 0, text: 'apple' }
이번에는 상태가 바뀌지 않는 문제가 발생한다. 이는 render된 후에 useState가 호출되므로 원래 의도했던 배열의 0, 1번 인덱스에 저장되는 것이 아니라 증가된 index(2)에 저장되기 때문이다.
{ count: 0, text: 'apple' }
[ <2 empty items>, 1 ]
{ count: 0, text: 'apple' }
[ <2 empty items>, 'orange' ]
{ count: 0, text: 'apple' }
이를 해결하기 위해 또 클로저 개념을 이용해서 setState 안의 idx 값이 useState에 의해 변하지 않도록 freeze
시켜준다. 이를 통해 정상적으로 동작하는 결과를 얻을 수 있다.
const React = (function () {
let hooks = [];
let idx = 0;
function useState(initialState) {
const state = hooks[idx] || initialState;
const _idx = idx;
const setState = (newState) => {
hooks[_idx] = newState;
};
idx++;
return [state, setState];
}
function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}
return { render, useState };
})();
function Component() {
const [count, setCount] = React.useState(0);
const [text, setText] = React.useState('apple');
return {
render: () => console.log({ count, text }),
increase: () => setCount(count + 1),
type: (word) => setText(word),
};
}
var App = React.render(Component); // { count: 0, text: 'apple' }
App.increase();
var App = React.render(Component); // { count: 1, text: 'apple' }
App.type('orange');
var App = React.render(Component); // { count: 1, text: 'orange' }
실제 React의 useState는 어떻게 구현되어 있을까? useState 내부 함수를 뜯어보자. (node_modules/react/cjs/react.development.js
or ReactHooks.js)
useState
는 초기값을 넘겨받아 dispatcher
객체의 useState
함수에 넘겨준다.
dispatcher
를 반환하는 resolveDispatcher()
함수는 아래와 같다.
resolveDispatcher()
함수는 ReactCurrentDispatcher
라는 객체의 current
프로퍼티를 반환하고 있다. (중간의 if 조건문은 우리가 Hook을 컴포넌트 외부에서 사용했을 때 에러를 발생시키는 부분이다.)
그렇다면 ReactCurrentDispatcher
를 확인해보자.
ReactCurrentDispatcher
는 단지 전역에 선언된 current라는 속성을 가지는 변수다.
/**
* Keeps track of the current dispatcher.
*/
var ReactCurrentDispatcher = {
/**
* @internal
* @type {ReactComponent}
*/
current: null,
};
function resolveDispatcher() {
var dispatcher = ReactCurrentDispatcher.current;
{
if (dispatcher === null) {
error(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
);
}
} // Will result in a null access error if accessed outside render phase. We
// intentionally don't throw our own error because this is in a hot path.
// Also helps ensure this is inlined.
return dispatcher;
}
function useState(initialState) {
var dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
지금까지의 과정을 요약하면 다음과 같다.
- useState(를 포함한 모든 Hook)는 React 모듈에 선언되어 있는 함수로,
- useState가 실행될 때마다 dispatcher를 선언하고 useState 메서드를 실행해 그 값을 반환받는다.
- dispatcher는 전역 변수 ReactCurrentDispatcher로부터 가져온다.
결론적으로, 함수형 컴포넌트가 클로저
를 통해 선언되는 시점에 접근 가능했던 외부 상태 값에 계속 접근할 수 있는 것이다. 함수형 컴포넌트에서 상태값을 변경하면 외부 값이 변경되고, 리렌더링(=함수 재호출)을 통해 새로운 값을 받아오게 된다.
핵심은 useState 리턴 값의 출처가 전역에서 온다는 점이다.
여기서 우리는 리액트가 실제로 클로저를 활용해 함수 외부의 값에 접근한다는 사실을 알 수 있게 된다.
(useState 내부에 대한 더 자세한 설명은 여기서 확인할 수 있다.)
React 공식 문서에는 Hook을 사용할 때 지켜야 하는 두 가지 규칙을 제시하고 있다.
그 중 첫 번째 규칙인 “최상위(at the Top Level)에서만 Hook을 호출해야 합니다”는 위의 설명대로 컴포넌트가 렌더링될 때마다 항상 동일한 순서로 Hook이 호출되는 것을 보장하기 위한 규칙이다. 즉, 컴포넌트에서 여러 Hook을, 여러 번 호출할 때 Hook의 호출 순서를 보장해야 한다는 의미인데 왜 이러한 규칙이 필요한 것일까?
React가 Hook이 호출되는 순서에 의존하기 때문에 모든 렌더링에서 Hook의 호출 순서는 같다. 이는 다음과 같은 의미를 가진다.
// 첫 번째 렌더링
useState(1); // const [num, setNum] = useState('1');
useEffect(funcA);
useState(false); // const [bool, setBool] = useState(false);
useEffect(funcB);
// 두 번째 렌더링
useState(1); // 순서: 1
useEffect(funcA); // 순서: 2
useState(false); // 순서: 3
useEffect(funcB); // 순서: 4
...
즉, Hook의 호출 순서가 모든 렌더링에서 동일하다. 만약 이때 조건문 안에서 funcA effect를 호출한다면 어떤 일이 일어날까?
if (num > 10) {
useEffect(funcA);
}
결과는 아래와 같아진다. 조건이 false이기 때문에 useEffect(funcA) hook을 건너 뛰어 Hook의 호출 순서는 달라지게 된다.
useState(1); // 순서: 1
// useEffect(funcA); // 🔴 Hook을 건너뛴다.
useState(false); // 순서: 2
useEffect(funcB); // 순서: 3
React는 이전 렌더링 때처럼 컴포넌트 내에서 두 번째 Hook 호출이 funcA effect와 일치할 것이라 예상했지만 그렇지 않게 된다. 그 시점부터 건너뛴 Hook 다음에 호출되는 Hook이 순서가 하나씩 밀리면서 버그를 발생시키게 된다.
이것이 공식문서에서 설명하는 컴포넌트 최상위에서 Hook이 호출되어야만 하는 이유다.
https://www.netlify.com/blog/2019/03/11/deep-dive-how-do-react-hooks-really-work/
https://medium.com/humanscape-tech/자바스크립트-클로저로-hooks구현하기-3ba74e11fda7
https://ko.legacy.reactjs.org/docs/hooks-rules.html
https://velog.io/@ggong/useState-Hook과-클로저
https://seokzin.tistory.com/entry/React-useState의-동작-원리와-클로저
https://www.rinae.dev/posts/getting-closure-on-react-hooks-summary
이런 유용한 정보를 나눠주셔서 감사합니다.