[바닐라 자바스크립트] 함수형 컴포넌트 만들기 시리즈
1편: 컴포넌트 생성하기
이전 포스트에서는 컴포넌트를 생성하는 함수를 구현하고 현재 사용 중인 상태 관리 방식의 문제점들에 대해 살펴보았습니다. 특히, 외부에서 state 변수를 직접 조작하는 구조의 위험성과 복잡성에 초점을 맞추었습니다. 이런 접근은 예기치 못한 사이드 이펙트를 일으키고, 상태의 추적과 디버깅을 어렵게 만듭니다. 이러한 문제들을 해결하기 위해, 더 안정적이고 선언적인 상태 관리 방법을 도입할 필요성을 느끼게 되었습니다.
이번 포스트에서는 React의 useState
와 유사한 기능을 직접 구현하는 방법을 탐구해보려 합니다. useState
는 React에서 상태를 관리하는 가장 기본적이면서도 강력한 훅입니다. 이를 통해 상태를 보다 효과적으로 관리하고, 컴포넌트의 렌더링을 더욱 효율적으로 제어할 수 있습니다. 또, 상태 관리의 복잡성을 줄이고 코드의 가독성과 유지보수성을 향상시킬 수 있습니다.
그럼 useState
와 비슷하게 동작하는 훅을 javascript
로 단계별로 구현해보겠습니다.
이전 포스트에 이어 프로젝트를 진행하겠습니다. 지금까지 작성된 코드는 아래와 같습니다.
src/core/component.js
function createComponent(component) {
const componentInstance = component();
return componentInstance;
}
export default createComponent;
src/index.js
import createComponent from './core/component.js';
import App from './App.js';
const render = () => {
const $app = document.getElementById('app');
const appComponent = createComponent(App);
$app.innerHTML = appComponent.element;
};
render();
src/App.js
import createComponent from './core/component.js';
import Todos from './components/Todos.js';
function App() {
const state = {
todos: ['item1', 'itme2'];
};
const setState = { /* ... 생략 */};
const todosComponent = createComponent(Todos, { todos: state.todos });
return {
element: `
<main>
${todosComponent.element}
</main>
`,
};
}
export default App;
src/components/Todos.js
function Todos({ todos }) {
return {
element: `
<ul>
${todos.map((todo) => `<li>${todo}</li>`).join('')}
</ul>
`,
};
}
export default Todos;
간단한 프로젝트 세팅을 마쳤습니다. 그럼 이제 어떤 흐름으로 useState
를 구현할지 생각해보겠습니다.
우선 useState로 상태를 관리하려면 컴포넌트의 상태를 변수에 저장하는 과정이 필요합니다. 여러 컴포넌트의 다양한 상태를 저장하려면 다음과 같은 형태로 저장할 수 있을 것 같습니다.
const componentsState = {
ComponentA: [1, true, ['todos']],
ComponentB: ['name'],
};
ComponentA에서는 값이 숫자인 상태, 불리언인 상태, 배열인 상태 세 가지를 사용하고 있고 ComponentB는 값이 문자열인 상태 한 가지만 사용하고 있습니다.
componentsState
객체에 컴포넌트 이름이 키로, 상태 배열이 값으로 저장됩니다. useState
함수 내에서 현재 stateIndex
를 저장하고 stateIndex
를 1씩 늘려주면 현재 상태는 자신의 상태 index를 기억할 수 있고 다음 상태는 이전 상태 다음으로 저장될 수 있습니다.
const useState = () => {
const componentName = 현재 컴포넌트의 이름;
let stateIndex = 현재 컴포넌트의 stateIndex값;
const setState = () => {
// setState는 자신의 상위 스코프에 있는 stateIndex 값을 기억한다.
};
stateIndex += 1;
return [componentsState[componentName][stateIndex], setState];
};
이렇게 저장한다면 컴포넌트 내에서 상태들이 섞이지 않으면서 자신이 저장된 index 값을 기억할 수 있습니다.
그럼 이제 현재 렌더링 중인 컴포넌트에서 어떻게 컴포넌트 이름
과 stateIndex
를 가져와야 할지 고민해보겠습니다.
useState
에서 현재 렌더링 중인 컴포넌트의 정보를 알려면 컴포넌트가 생성될때 이름과 stateIndex
초기값을 부여할 수 있을 것 같습니다.
src/core/component.js
기존 createComponent는 단순히 컴포넌트를 호출해서 반환하는 역할을 했습니다. useState
에서 현재 렌더링중인 컴포넌트 정보를 알기 위해 컴포넌트를 호출할때 currentComponent
라는 변수에 컴포넌트 이름과 stateIndex
의 초기값을 할당해줍니다. currentComponent
를 export 하고 있기 때문에 useState
에서 import 하여 사용할 수 있습니다.
이렇게 currentComponent
변수에 현재 컴포넌트의 이름과 상태 index를 할당하면 useState
에서 활용할 수 있습니다.
그런데 현재 구조에서는 한 가지 문제점이 있습니다. 컴포넌트 내에서 다른 여러 컴포넌트를 렌더링 했을 때 currentComponent
에 저장된 값은 무엇일까요?
중첩된 컴포넌트가 어떻게 렌더링되는지 절차적으로 알아보겠습니다.
우선 다음과 같은 상황을 가정하겠습니다.
function ParentComponent() {
const [count, setCount] = useState(0);
const childComponent = createComponent(ChildComponent);
return {
element: `
<div>
${childComponent.element}
count: ${count}
</div>
`,
};
}
ParentComponent
내에서 count
라는 상태를 선언하고 그 뒤에 createComponent
를 사용하여 ChildComponent
를 생성하고 렌더링까지 하고 있습니다.
그렇다면 콘솔 로그를 찍어 currentComponent
에 어떤 값이 할당되고 있는지 확인해보겠습니다.
function ParentComponent() {
console.log(1, currentComponent);
const [count, setCount] = useState(0);
console.log(2, currentComponent);
const childComponent = createComponent(ChildComponent);
console.log(3, currentComponent); // 여기는 ParentComponent이어야 하는데...
return {
element: `
<div>
${childComponent.element}
count: ${count}
</div>
`,
};
}
결과는 아래와 같습니다.
1 {id: 'ParentComponent', stateIndex: 0}
2 {id: 'ParentComponent', stateIndex: 1}
3 {id: 'ChildComponent', stateIndex: 0}
눈에 띄는 부분은 마지막 3 {id: 'Todos', stateIndex: 0}
입니다. 분명 현재 컨텍스트는 ParentComponent
이지만 currentComponent
에는 자식 컴포넌트인 ChildComponent
가 저장되어 있습니다.
물론 setState
는 호출당시의 currentComponent
정보를 기억하고 있기 때문에 이후에 currentComponent
값이 변경돼도 잘 작동합니다. 그러나, currentComponent
에 현재 컨텍스트와 다른 정보를 저장하고 있다면 추후에 문제가 생길 가능성이 있습니다.
또, 항상 자식 컴포넌트 호출이 상태 선언 이후에 나오도록 강제하고 있지 않기 때문에 자식 컴포넌트 호출 이후 상태를 선언한다면 해당 상태는 제대로 저장되지 않습니다.
그렇기 때문에 자식 컴포넌트를 호출하고 나면 다시 부모 컴포넌트로 돌아올 수 있도록 구현해주어야 합니다.
자식 컴포넌트를 렌더링하고 현재 컴포넌트로 돌아오도록 하는 방법은 간단합니다.
src/core/component.js
export let currentComponent = null;
function createComponent(component) {
currentComponent = { id: component.name, stateIndex: 0 };
const componentInstance = component();
return componentInstance;
}
export default createComponent;
createComponent
에서 component
를 호출하고 있는데, 호출하기 전에 currentComponent
를 변수에 저장합니다. 저장한 후에 component
를 호출이 종료되면 currentComponent
에 호출 이전에 저장해둔 값을 다시 돌려줍니다.
코드로 이해해보겠습니다.
src/core/component.js
component
를 호출하기 전에 previousComponent
라는 변수에 currentComponent
를 할당합니다. 현재 컴포넌트를 호출하기 이전이니 currentComponent
에는 부모 컴포넌트에 대한 정보가 들어있습니다. 호출이 종료하고 currentComponent
에 previousComponent
를 다시 할당해주면 이제 currentComponent
에는 부모 컴포넌트가 됩니다.
이제 ParentComponent
어느 부분에서 useState
를 호출하더라도 현재 컴포넌트 상태 배열에 잘 저장됩니다. 이제 useState를 구현할 준비가 되었습니다.
앞서 컴포넌트 상태를 어떻게 저장해둘 것인지 설계를 해두었기 때문에 다음과 같이 작성할 수 있습니다.
src/core/hooks/useState.js
import { currentComponent } from '../component.js';
const componentsState = {};
const useState = (initialValue) => {
const { id, stateIndex } = currentComponent;
const state = componentsState[id][stateIndex];
const setState = () => {};
return [state, setState];
};
export default useState;
얼추 모양이 잡혔습니다. 이제 componentsState
객체에 상태 초기값들을 설정하고, setState
내부 구현만 하면 useState
완성입니다.
useState
를 호출할때 파라미터로 초기값을 전달하는데 그 초기값을 componentsState
에 저장해보겠습니다. 매우 간단하기 때문에 바로 코드로 구현하겠습니다.
src/core/hooks/useState.js
useState
를 호출한 컴포넌트가 componentsState
에 등록되어 있지 않다면 빈 배열로 초기화 해줍니다. 이 빈 배열에 해당 컴포넌트의 상태들이 차곡차곡 저장되겠죠?
이후, 상태 배열의 stateIndex
값이 존재하지 않는다면 파라미터로 받은 initialValue
로 초기화 해줍니다. 초기값 등록을 마쳤으니 이제 setState
를 구현해봅시다.
setState
의 파라미터로 들어오는 값은 두 가지로, 바꾸려고 하는 새로운 값
, 혹은 콜백 함수
입니다. 두 가지 케이스에 대해 알아보겠습니다.
setState(5); // 상태를 숫자 5로 변경
이 경우 상태를 새로운 값으로 할당해줍니다.
setState(previousState => previousState + 1); // 현재 상태에 1을 더한 값으로 상태를 업데이트
이 경우, setState
는 콜백 함수를 호출하고, 이 함수의 반환값으로 상태를 설정합니다. 이 콜백 함수는 현재 상태를 인자로 받아 새로운 상태를 계산하는 데 사용됩니다.
두 가지 케이스를 고려하여 setState
를 구현합니다.
변경된 값과 현재 상태 값이 다를 경우 상태를 변경하고 리렌더링합니다. 현재는 단순히 직접 비교를 통해 값을 변경하고 리렌더링 되고 있습니다. 객체나 배열의 경우 내용이 같더라도 참조값이 다르면 두 객체나 배열이 다르다고 평가됩니다. 추후 깊은 비교 등을 통해 개선할 수 있습니다.
기존에 각 컴포넌트마다 상태를 선언하고 setState를 만드는 방법을 개선하고자 직접 useState
를 구현해보았습니다. useState
를 설계하고 구현함으로써 리액트의 useState
함수가 콜 스택에서 pop되더라도 어떻게 상태를 유지하고 변경할 수 있는지에 대해 이해해볼 수 있었습니다.
그러나 현재 구조에서는 상태가 변경될 때마다 전체 애플리케이션이 리렌더링되는 문제가 발생하고 있습니다. 하나의 컴포넌트에서 상태가 변경되면 그 컴포넌트와 관련이 없는 컴포넌트도 모두 리렌더링 됩니다. 이러한 문제를 해결하기 위해, 다음 포스트에서는 상태 변경이 발생한 컴포넌트와 그 자식 컴포넌트들만을 대상으로 하는 리렌더링 최적화를 시도해보도록 하겠습니다.