[바닐라 자바스크립트] 함수형 컴포넌트 만들기: useState 만들기

jaemin·2023년 12월 5일
2
post-thumbnail

[바닐라 자바스크립트] 함수형 컴포넌트 만들기 시리즈
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에서 상태 저장 방법

📍 컴포넌트의 상태 저장하기

우선 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를 가져와야 할지 고민해보겠습니다.

📍 컴포넌트의 이름과 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에는 부모 컴포넌트에 대한 정보가 들어있습니다. 호출이 종료하고 currentComponentpreviousComponent를 다시 할당해주면 이제 currentComponent에는 부모 컴포넌트가 됩니다.

이제 ParentComponent 어느 부분에서 useState를 호출하더라도 현재 컴포넌트 상태 배열에 잘 저장됩니다. 이제 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 초기값 저장하기

useState를 호출할때 파라미터로 초기값을 전달하는데 그 초기값을 componentsState에 저장해보겠습니다. 매우 간단하기 때문에 바로 코드로 구현하겠습니다.

  • src/core/hooks/useState.js

useState를 호출한 컴포넌트가 componentsState에 등록되어 있지 않다면 빈 배열로 초기화 해줍니다. 이 빈 배열에 해당 컴포넌트의 상태들이 차곡차곡 저장되겠죠?

이후, 상태 배열의 stateIndex 값이 존재하지 않는다면 파라미터로 받은 initialValue로 초기화 해줍니다. 초기값 등록을 마쳤으니 이제 setState를 구현해봅시다.

📍 setState 구현하기

setState의 파라미터로 들어오는 값은 두 가지로, 바꾸려고 하는 새로운 값, 혹은 콜백 함수입니다. 두 가지 케이스에 대해 알아보겠습니다.

  1. 새로운 값인 경우
setState(5); // 상태를 숫자 5로 변경

이 경우 상태를 새로운 값으로 할당해줍니다.

  1. 콜백 함수인 경우
setState(previousState => previousState + 1); // 현재 상태에 1을 더한 값으로 상태를 업데이트

이 경우, setState는 콜백 함수를 호출하고, 이 함수의 반환값으로 상태를 설정합니다. 이 콜백 함수는 현재 상태를 인자로 받아 새로운 상태를 계산하는 데 사용됩니다.

두 가지 케이스를 고려하여 setState를 구현합니다.

변경된 값과 현재 상태 값이 다를 경우 상태를 변경하고 리렌더링합니다. 현재는 단순히 직접 비교를 통해 값을 변경하고 리렌더링 되고 있습니다. 객체나 배열의 경우 내용이 같더라도 참조값이 다르면 두 객체나 배열이 다르다고 평가됩니다. 추후 깊은 비교 등을 통해 개선할 수 있습니다.

✅ 정리

기존에 각 컴포넌트마다 상태를 선언하고 setState를 만드는 방법을 개선하고자 직접 useState를 구현해보았습니다. useState를 설계하고 구현함으로써 리액트의 useState 함수가 콜 스택에서 pop되더라도 어떻게 상태를 유지하고 변경할 수 있는지에 대해 이해해볼 수 있었습니다.

그러나 현재 구조에서는 상태가 변경될 때마다 전체 애플리케이션이 리렌더링되는 문제가 발생하고 있습니다. 하나의 컴포넌트에서 상태가 변경되면 그 컴포넌트와 관련이 없는 컴포넌트도 모두 리렌더링 됩니다. 이러한 문제를 해결하기 위해, 다음 포스트에서는 상태 변경이 발생한 컴포넌트와 그 자식 컴포넌트들만을 대상으로 하는 리렌더링 최적화를 시도해보도록 하겠습니다.

profile
프론트엔드 개발자가 되기 위해 공부 중입니다.

0개의 댓글