React의 useState는 함수형 컴포넌트에서 상태를 관리할 수 있게 해주는 Hook이다.
useState의 내부 로직은 추상화되어 있어 개발자가 선언적으로 상태를 관리할 수 있게 해준다.
이번 글에서는 자바스크립트의 Closure를 활용해 useState를 직접 구현해보면서, 동작 원리에 대해 알아보려고 한다!
useState의 기본 인터페이스
useState를 구현하기에 앞서 인자 및 반환 값 그리고 기능을 정리해보자.
const [state, setState] = useState(initialValue);
매개변수
- initialState: state의 초기 설정값.
- 이 인수는 처음 렌더링 때 state를 초기화하고, 이후 렌더링에서는 무시된다.
- 원시값이나 객체를 전달하면 해당 값을 그대로 state의 초기값으로 사용한다.
- 함수를 전달할 경우 초기화 함수로 처리된다. 이 함수는 순수 함수여야 하며, 매개변수를 받지 않고 반드시 값을 반환해야 한다. 컴포넌트 초기화 시점에 이 함수를 실행하여 반환된 값을 state의 초기값으로 사용한다.
반환값
- [state, setState] : 현재 state와 해당 state를 업데이터를 배열로 반환.
- state: 현재 state 입니다. 첫 번째 렌더링 중에는 전달한 initialState와 일치한다.
- setState: state를 다른 값으로 업데이트하고 리렌더링을 촉발할 수 있는 set함수이다.
주요 기능
- 초기화: 컴포넌트가 처음 렌더링될 때, useState는 initialValue로 상태를 초기화한다.
- 상태 저장: React는 이 상태를 컴포넌트 인스턴스와 함께 저장하여, 이후에 상태가 변경되어도 값을 유지한다.
- 상태 업데이트: setState를 호출하면 React는 컴포넌트를 리렌더링하고 새로운 상태 값을 적용한다.
구현 과정
1. 렌더링 시스템 구현
기본적으로 컴포넌트를 렌더링하는 React 코어 함수가 필요하다.
또란 useState의 기능으로 setState 이후 자동으로 리렌더링하기 위해서도 렌더링하는 함수가 필요하다.
그래서 기본적인 렌더링 함수를 구현해보겠다.
// core/MyReact.js
export default function MyReact() {
let _root = null;
function createRoot(root) {
_root = root;
return { render };
}
function render(Component) {
const comp = Component();
_root.innerHTML = comp;
}
return { createRoot, render };
}
export const { createRoot, render } = MyReact();
이 코드는 간단한 렌더링 시스템을 구현한다.
- createRoot: 루트 DOM 요소 설정
- render: 컴포넌트를 실행하고 DOM에 렌더링
기본 index.html 에서 다음과 같이 사용할 수 있다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My React</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import MyReact, { createRoot } from "./core/MyReact.js";
const App = () => {
return `<h1>Hello World!</h1>`;
};
const root = createRoot(document.getElementById("root"));
root.render(App);
</script>
</body>
</html>
위의 렌더링 시스템을 렌더링하기 위해서 무조건 child component가 필요하다.
하지만 setState 이후 리렌더링에서 childCompoenet를 넣어주기 힘드므로 코드를 개선하겠다.
// core/MyReact.js
export default function MyReact() {
let _root = null;
let _rootComponent = null;
function createRoot(root) {
_root = root;
return { render };
}
function render(Component) {
_rootComponent = Component;
_render();
}
function _render() {
const comp = _rootComponent();
_root.innerHTML = comp;
}
return { createRoot, render };
}
export const { createRoot, render } = MyReact();
- _rootComponent를 내부 변수로 저장하여 컴포넌트를 추적한다.
- 실제 렌더링 로직을 _render 함수로 분리했다.
- render 함수는 이제 자식 컴포넌트를 저장하고 _render를 호출하는 역할을 한다.
setState 호출 후 리렌더링이 필요할 때 _render 함수를 호출하여 리렌더링할 수 있다!
2. 단일 변수로 상태를 관리하는 간단한 useState 구현
이제 간단한 useState를 구현해보겠다.
export default function MyReact() {
/* 생략 */
let _state = null;
function useState(initialValue) {
_state = _state || initialValue;
function setState(newValue) {
_state = newValue;
_render();
}
return [_state, setState];
}
return { createRoot, render, useState };
}
export const { createRoot, render, useState } = MyReact();
위에서 설명했던 주요 기능을 토대로 코드를 설명해보겠다!
- 상태 저장: _state 변수를 통해 컴포넌트의 상태를 저장하고 리렌더링 시에도 값을 유지한다.
- 상태 초기화: _state = _state || initialValue로 첫 렌더링 시에만 초기값을 설정한다.
- 상태 업데이트: setState 함수를 통해 상태를 업데이트하고 리렌더링을 트리거한다.
- 인터페이스 유지: [state, setState] 배열을 반환하여 React의 useState와 동일한 인터페이스를 제공합니다.
이렇게 구현된 useState는 아래와 같이 사용할 수 있다.
<!DOCTYPE html>
<html lang="ko">
<body>
<div id="root"></div>
<script type="module">
import MyReact, { createRoot, useState } from "./core/MyReact.js";
const App = () => {
const [count, setCount] = useState(0);
window.increase = () => setCount(count + 1);
window.decrease = () => setCount(count - 1);
return `
<div>
<h1>Hello World!</h1>
<p>count : ${count}</p>
<button onclick="increase()">+1</button>
<button onclick="decrease()">-1</button>
</div>`;
};
const root = createRoot(document.getElementById("root"));
root.render(App);
</script>
</body>
</html>
현재 코드는 하나의 변수를 참조하여서 관리함으로 한 개의 상태만 관리할 수 있다.
밑의 코드 예시를 보자.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My React</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import MyReact, { createRoot, useState } from "./core/MyReact.js";
const App = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
window.increase = () => setCount(count + 1);
window.decrease = () => setCount(count - 1);
window.changeText = (event) => setText(event.target.value);
return `
<div>
<h1>Hello World!</h1>
<p>count : ${count}</p>
<button onclick="increase()">+1</button>
<button onclick="decrease()">-1</button>
<p>text : ${text}</p>
<input value="${text}" onchange="changeText(event)" />
</div>`;
};
const root = createRoot(document.getElementById("root"));
root.render(App);
</script>
</body>
</html>
현재 코드에서는 useState를 여러 번 호출할 때마다 동일한 변수에 상태를 저장하게 된다.
예를 들어 count 상태를 변경하면 text 상태에도 영향을 미칠 수 있고, text 상태를 변경하면 count 상태에도 영향을 미칠 수 있다.
이렇게 각 상태가 서로 간섭하는 현상이 발생하는 이유는 현재 구현에서 하나의 상태 변수만을 사용하고 있기 때문이다.
따라서 상태를 독립적으로 관리할 수 있도록 하나의 원시값이 아닌 배열로 저장할 수 있도록 변경하자!
3. 배열로 상태를 관리하도록 useState 개선
// core/MyReact.js
export default function MyReact() {
/* 생략 */
let _hooks = [],
_currentHookIdx = 0;
function _render() {
const comp = _rootComponent();
_root.innerHTML = comp;
_currentHookIdx = 0;
}
function useState(initialValue) {
_hooks[_currentHookIdx] = _hooks[_currentHookIdx] ?? initialValue;
const hookIdx = _currentHookIdx;
function setState(newValue) {
_hooks[hookIdx] = newValue;
_render();
}
return [_hooks[_currentHookIdx++], setState];
}
return { createRoot, render, useState };
}
export const { createRoot, render, useState } = MyReact();
변경 사항을 살펴보자.
- _hooks 배열 : 기존에는 단일 변수로 상태를 관리하였지만 지금은 배열을 통해 관리하도록 변경하였다.
- _currentHookIdx : 현재 처리 중인 hook의 인덱스를 추적한다.
- _render 함수 : 컴포넌트를 다시 렌더링할 때마다 _currentHookIdx를 0으로 초기화한다.
달라진 useState의 동작 방식도 살펴보자.
- 첫 렌더링 시, _hooks[_currentHookIdx]가 undefined라면 initialValue를 저장한다.
- 현재 Hook의 인덱스를 hookIdx에 저장하여 setState 함수가 올바른 상태를 업데이트하도록 한다.
- setState 함수는 저장해둔 hookIdx를 사용하여 정확한 상태를 업데이트한다.
- 현재 상태값과 setState 함수를 반환하고, 다음 Hook을 위해 _currentHookIdx를 증가한다.
이렇게 구현하면 각 useState 호출이 독립적인 상태 공간을 가지게 되어, 서로 다른 상태들이 독립적으로 동작할 수 있다!
이 구현의 핵심은 컴포넌트가 리렌더링될 때마다 Hooks가 항상 동일한 순서로 호출된다는 것을 전제로 한다.
이것이 React에서 "Hooks는 항상 최상위에서 호출되어야 한다"는 규칙이 존재하는 이유이다.
4. 함수를 통한 초기화 및 업데이트가 가능하도록 useState 개선
React의 useState는 초기값 설정과 상태 업데이트를 함수를 통해 할 수 있는 기능을 제공한다.
이를 구현하기 위해 코드를 개선해보겠다!
// core/MyReact.js
export default function MyReact() {
/* 생략 */
let _hooks = [],
_currentHookIdx = 0;
function useState(initialValue) {
_hooks[_currentHookIdx] =
_hooks[_currentHookIdx] ??
(typeof initialValue === "function" ? initialValue() : initialValue);
const hookIdx = _currentHookIdx;
function setState(newValue) {
if (typeof newValue === "function")
_hooks[hookIdx] = newValue(_hooks[hookIdx]);
else _hooks[hookIdx] = newValue;
_render();
}
return [_hooks[_currentHookIdx++], setState];
}
return { createRoot, render, useState };
}
export const { createRoot, render, useState } = MyReact();
typeof 키워드를 통하여 초기값과 업데이트로 들어오는 인자가 함수인지 판단한다.
함수가 아닌 경우에는 기존과 동일하게 바로 상태에 저장하였고, 함수일 경우에는 실행 후 반환되는 값을 상태에 저장하였다.
이로써, 간단한 useState를 구현해봤다!!
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My React</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import MyReact, { createRoot, useState } from "./core/MyReact.js";
const App = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState(() => "asdf");
window.increase = () => setCount((prev) => prev + 1);
window.decrease = () => setCount((prev) => prev - 1);
window.changeText = (event) => setText(event.target.value);
return `
<div>
<h1>Hello World!</h1>
<p>count : ${count}</p>
<button onclick="increase()">+1</button>
<button onclick="decrease()">-1</button>
<p>text : ${text}</p>
<input value="${text}" onchange="changeText(event)" />
</div>`;
};
const root = createRoot(document.getElementById("root"));
root.render(App);
</script>
</body>
</html>
// core/MyReact.js
export default function MyReact() {
let _root = null;
let _rootComponent = null;
let _hooks = [],
_currentHookIdx = 0;
function createRoot(root) {
_root = root;
return { render };
}
function render(Component) {
_rootComponent = Component;
_render();
}
function _render() {
const comp = _rootComponent();
_root.innerHTML = comp;
_currentHookIdx = 0;
}
function useState(initialValue) {
_hooks[_currentHookIdx] =
_hooks[_currentHookIdx] ??
(typeof initialValue === "function" ? initialValue() : initialValue);
const hookIdx = _currentHookIdx;
function setState(newValue) {
if (typeof newValue === "function")
_hooks[hookIdx] = newValue(_hooks[hookIdx]);
else _hooks[hookIdx] = newValue;
_render();
}
return [_hooks[_currentHookIdx++], setState];
}
return { createRoot, render, useState };
}
export const { createRoot, render, useState } = MyReact();
후기
이번 글에서는 클로저를 활용하여 React의 useState를 간단히 알아보았다.
Virtual DOM을 사용하지 않음, 컴포넌트 간 부모-자식 관계 구현이 없음, 상태 변경을 일괄 처리(batch update)하지 않고 즉시 렌더링함, diff 알고리즘을 통한 최적화가 없는 등 실제 React와는 차이점이 있다.
하지만 이러한 단순화된 구현을 통해 useState의 기본적인 동작 원리와 인터페이스를 이해할 수 있었다.
특히 클로저를 활용하여 상태를 관리하는 방식은 React의 핵심 개념을 잘 보여준다고 생각한다!
깃헙에도 기록해놓았기 때문에 각각의 과정에서 전체 코드가 궁금하다면 llddang/javascript-my-react PR:2에서 볼 수 있다.
참조
'💻 개발 > JavaScript' 카테고리의 다른 글
var, let, const의 5가지 차이점 (재선언, 재할당, 스코프, 호이스팅, 전역 객체) (0) | 2025.02.11 |
---|---|
바닐라 JS에서 알아보는 useEffect 동작 원리 및 간단한 구현 (0) | 2025.02.03 |
ECMAScript와 함께 알아보는 실행 컨텍스트 (0) | 2025.01.23 |
[JS] API 요청 제한기 구현 과정 : Rate Limiter (Sliding Window Counter) (0) | 2025.01.19 |
JavaScript로 구현하는 Rate Limiter : 효율적인 API 요청 (0) | 2025.01.17 |