useState
에 대해서
useState
구현 내용을 살펴보기 전에, 구현해야 할 훅에 대해 간단하게 정리해 봤다.
리액트에는 상태가 변경되면 화면을 리렌더링 해주는 '선언적 UI 방식'이 사용된다. 그리고, 이를 위해 리액트는 자신이 관리해야 할 상태가 어떤 것인지를 알아야 한다.
컴포넌트 내부에서 선언된 일반 변수는 리액트가 관리하는 상태로 등록되지 않았기 때문에 리액트가 변화를 추적하지 못하고, 값의 변화에 대한 리렌더링 동작을 처리해주지 못한다. 이런 상황에서 useState
가 사용된다. useState
는 리액트가 변화를 추적할 상태를 생성할 때 사용된다.
import { useState } from 'react'
const App = () => {
const [count, setCount] = useState(0);
return (
<div>{count}</div>
)
}
위와 같이 상태를 정의해 주면 count
라는 변수를 리액트 라이브러리가 직접 관리해 주고, count
의 값이 변경되면 화면을 자동으로 리렌더링 해준다.
useState
로 생성한 값을 변경할 땐 setCount
처럼 useState
를 통해 반환된 Setter 함수를 사용해야 한다. 이는 리액트에게 '관리되는 상태의 값이 변경될 거다'라고 알려주는 행위라고 생각하면 된다. setCount
처럼 useState
로부터 반환된 Setter 함수를 사용하지 않고 값을 변경하면 리렌더링이 발생하지 않고, 여러 컴포넌트에 상태가 공유된 상태라면 컴포넌트 간 상태의 참조가 바뀌는 문제가 발생한다.
useState
직접 구현을 위해, 훅의 동작 중 핵심적인 부분 몇 가지를 아래와 같이 정리해 봤다.
- 길이가 2인 배열을 반환한다. 0번 인덱스에는 관리해야 할 상태를, 1번 인덱스에는 상태를 업데이트하기 위한 Setter 함수를 값으로 갖는다.
- 반환하는 상태는 실제 값이 아니라, 값을 참조해 주는 Getter 함수의 실행 결과이다.
useState
가 반환해 주는 상태를 직접 변경하려고 할 때 상태에 대한 참조가 끊어진다. - 초기 값이
undefined
이거나, 어떠한 값을 갖거나, 또는 콜백 함수이다. 콜백 함수를 매개 변수로 받을 경우 리턴 값으로 초기화된다. - 어떠한 타입의 값도 상태로 저장될 수 있지만, 한 번 초기화된 값의 타입을 계속 따라야 한다.
- 반환된 상태 업데이트 함수로 값이 전달되어 상태가 변경되면 화면을 리렌더링 한다.
실제 useState
에선 상태 변경 시 변경된 상태가 포함된 컴포넌트를 Virtual DOM 트리에서 확인하여 변경 지점에서부터 하위 컴포넌트들만 리렌더링을 해주지만, 일단 이번 구현에선 상태 변경 시 전체 DOM을 리렌더링 해주는 걸로 하고 작업을 진행했다.
useState 직접 구현해 보기
구현해 본 전체 코드는 아래와 같다.
import App from 'src/App';
import render from './render';
/* DOM 전체를 리렌더링, diffing은 다음에 구현해보기 */
const rerender = () => {
const $app = document.getElementById('app');
if ($app instanceof HTMLElement) {
$app.innerHTML = '';
render($app, App()); // 이번 구현에선 일단 상태 변경 시 전체 DOM 리렌더링으로 처리
}
};
// 즉시 실행 함수를 반환해서 states와 cursor를 클로저로 관리
const useState = (() => {
// useState의 실행 순서에 따라 정의된 상태들 배열로 관리
const states: unknown[] = [];
let cursor = 0;
return <T>(init?: T | (() => T)) => {
let currentCursor = cursor;
cursor += 1;
// states[currentCursor]가 undefined면 초기화
if (!states[currentCursor]) {
states[currentCursor] = typeof init === 'function' ? (init as () => T)() : init;
}
// 즉시 실행 함수로 필요한 상태 값 반환, state에 직접 접근 못하도록 getState로 래핑해서 내려보내기
const getState = () => {
return states[currentCursor] as T;
};
const setState = (newState: T) => {
states[currentCursor] = newState;
cursor = 0; // 상태 업데이트 후 리렌더링 될 때 useState도 다시 처음부터 재실행
rerender();
};
// getState의 실행 결과와 setState 함수를 반환
return [getState(), setState] as [T, (newState: T) => T];
};
})();
export default useState;
기본 컨셉은 관리되어야 하는 상태를 클로저 형태로 외부에 전달해서 상태에 직접 접근은 방지하고, 대신 setStatae
함수로만 값을 변경할 수 있도록 해준 것이다. 단계별로 하나씩 살펴보자.
상태 관리를 위한 배열 생성
useState
는 하나의 애플리케이션에도 여러 번 정의될 수 있다. 때문에, useState
함수 내부에서 관리해 줄 애플리케이션 내 정의된 상태들은 배열로 관리되도록 했다.
const useState = (() => {
// useState의 실행 순서에 따라 정의된 상태들 배열로 관리
const states: unknown[] = [];
let cursor = 0;
// ...
만약 useState
내부에서 상태 값이 일반 변수로 정의된다면(let state: unknown = init
), useState
가 실행될 때마다 이전에 정의됐던 상태가 다음 초기화 값으로 계속 덮어 씌워질 수 있다.
따라서, 상태들은 useState
내부에서 배열로 관리하고, 대신 useState
가 실행되는 순서에 따라 cursor
의 인덱스에 해당하는 공간에 값을 저장해 값이 덮어 씌워지는 걸 방지했다.
클로저로 외부에 값 반환하기
정의된 states
의 값을 외부에 직접 return
해주면 setState
를 거치지 않고 값을 변환하는 동작이 발생할 수 있다. 따라서 값에 직접 접근을 방지하기 위해 useState
의 반환 값을 함수로 처리해 주고, 해당 반환 함수의 return
값에 getState
의 실행 결과(값)와 setState
함수를 배열로 담아 전달해 준 후에, useState
를 즉시 실행 함수로 처리해 줘서 반환 함수의 실행 결괏값이 외부로 바로 전달되게 처리해 줬다.
const useState = (() => {
// ...
return <T>(init?: T | (() => T)) => {
// ... 내부 로직
return [getState(), setState] as [T, (newState: T) => T];
};
})();
이렇게 하면 useState
함수의 최상단에서 정의된 states
에는 외부에서 직접 접근하지 못하면서, 대신 해당 값을 조회 및 setState
를 통해 변경하는 것은 가능해진다.
상태 초기화하기
states
는 현재 cursor
, 즉 몇 번째 useState
가 실행된 시점인지를 고려해 해당 시점의 상태를 배열의 인덱스 값으로 저장한다. 이때, useState
에 전달된 매개 변수에 따라 값을 초기화해 준다.
const useState = (() => {
// ...
return <T>(init?: T | (() => T)) => {
let currentCursor = cursor;
cursor += 1;
// states[cursor]가 undefined면 초기화
if (!states[currentCursor]) {
states[currentCursor] = typeof init === 'function' ? (init as () => T)() : init;
}
// ...
};
})();
먼저, 초기화 값은 어떠한 타입으로도 들어올 수 있기 때문에, 제네릭 변수 T
를 사용해 줬다. 이제 초기화된 값의 첫 타입이 정해지면 제네릭 변수 T
는 해당 타입을 가리키게 되고, 이후 들어오는 값의 타입을 적절하게 검사해 준다.
참고로 useState
의 매개 변수로는 undefined
, 어떠한 값, 어떠한 값을 반환 값으로 가지는 콜백 함수가 올 수 있기 때문에, 해당 상황을 고려해서 초기 값 init
을 옵셔널 파라미터로 정의해 주고, 유니온 타입으로 값-콜백 함수가 오는 경우를 모두 대응해 줬다.
return <T>(init?: T | (() => T)) => {
currentCursor
는 말 그대로 useState
가 몇 번째 실행된 시점인지를 나타내며, states[currentCursor]
에 값을 저장하기 위한 인덱스로 사용된다. 만약에 states[currentCursor]
의 값이 undefined
라면 이는 해당 useState
가 처음 실행된 것을 의미하며, 해당 상황에선 매개 변수로 전달된 초기 값을 states
에 저장해 준다(초기 값이 함수면 함수 실행한 결과를, 값이면 값을 저장해 준다).
if (!states[currentCursor]) {
states[currentCursor] = typeof init === 'function' ? (init as () => T)() : init;
}
useState
는 함수형 컴포넌트가 리렌더링 될 때마다 재실행된다. 해당 상황에서 위의 분기 처리를 해주지 않는다면 관리되던 상태가 리렌더링 될 때마다 계속 초기 값으로 롤백하는 상황이 벌어질 것이다. 위에서 states[currentCursor]
의 값이 undefined
일 경우에만 초기화하는 것으로 상황을 좁혀줬기 때문에, 전체 애플리케이션이 아예 초기화되지 않는 이상 상태가 관리되던 중간에 초기값으로 다시 회귀하는 상황은 방지된다.
상태 Getter/Setter 함수 정의하기
외부에서 useState
에서 관리되는 상태 states
에 직접 접근하지 못하도록, 값을 한번 래핑 해서 내려보내준다. 이렇게 처리해 주면 states
는 클로저로 관리되고, 값의 변경을 setState
를 활용하는 것으로만 제한할 수 있다(값을 직접 변경하더라도 states
의 값이 바뀌는 게 아니라, 상태의 참조가getState()
에서 재설정한 값으로 변경되는 것이기 때문에, 기존에 관리되던 상태는 유지된다).
// 즉시 실행 함수를 반환해서 states와 cursor를 클로저로 관리
const useState = (() => {
// ...
return <T>(init?: T | (() => T)) => {
// ...
// 즉시 실행 함수로 필요한 상태 값 반환, state에 직접 접근 못하도록 getState로 래핑해서 내려보내기
const getState = () => {
return states[currentCursor] as T;
};
// ...
다음으로, setState
함수를 정의해 준다. setState
는 타입이 제네릭 변수 T
인 newState
를 매개 변수로 받고, states
의 currentCursor
번째 인덱스에 있는 값을 변경해 준 다음, 화면을 리렌더링 해주는 rerender
함수까지 호출해준다(rerender
함수는 아래에서 다시 살펴보겠다.
const useState = (() => {
// ...
return <T>(init?: T | (() => T)) => {
// ...
const getState = () => {
return states[currentCursor] as T;
};
const setState = (newState: T) => {
states[currentCursor] = newState;
cursor = 0; // 상태 업데이트 후 리렌더링 될 때 useState도 다시 처음부터 재실행
rerender();
};
// ...
이제, 만들어진 getState
를 배열에 담아서 아래와 같이 리턴해준다. 이때, 배열의 0번째 인덱스는 타입이 제네릭 변수 T
인 값이고, 1번째 인덱스는 newState
를 매개 변수로 받아서 void
를 리턴하는 함수이다. 이렇게 서로 다른 타입의 두 변수를 배열에 담아서 내리는 것이기 때문에, 내려가는 반환 값을 as [T, (newState: T) => T]
형태의 튜플 타입으로 한번 더 타입을 단언해 줬다.
return [getState(), setState] as [T, (newState: T) => T];
rerender
함수 정의하기
rerender
함수는 간단하다. 앱의 진입점이 되는 $app
요소를 선택자로 가져온 다음, 해당 선택자에 있던 innerHTML
값을 ''
로 비워준 후, render
함수로 다시 그려주면 된다. 참고로, 이때 사용한 render
함수는 이전 포스트에서 직접 구현해 본 커스텀 render
함수를 사용했다.
/* DOM 전체를 리렌더링, diffing은 다음에 구현해보기 */
const rerender = () => {
const $app = document.getElementById('app');
if ($app instanceof HTMLElement) {
$app.innerHTML = '';
render($app, App());
}
};
커스텀 render
함수는 앱의 진입점이 되는 요소와, JSX 구문을 분석한 Virtual DOM 객체를 매개 변수로 전달받는다. 이때, 이번 useState
구현에서는 Virtual DOM을 탐색하여 변경이 발생한 부분만 리렌더링을 처리해 주는 Diffing 알고리즘은 반영하지 않았고, 때문에 앱의 전체 진입점이 되는 최상단 컴포넌트 App()
을 호출하여 커스텀한 createElement
함수를 통해 만들어지는 Vritual DOM을 인자로 넘기는 것으로 했다.
이후에 Diffing 알고리즘을 구현할 때에는 render
를 발생시키는 부분을 최상단 앱이 아닌, 변경이 필요한 부분으로 한정해 주는 코드를 추가해 주는 게 필요할 것 같다.
아무튼, 이렇게 해서 일단은 useState
훅이 처리해 주는 주요 기능들(상태 정의 및 초기화, 상태 관리, 상태 변경 등)은 간단하게 구현 완료됐다.
결론
useState
를 직접 구현해 보면서 해당 훅의 핵심 동작에 대해 나름대로 정리해 볼 수 있었다. 구현하면서 가장 고민을 많이 한 부분은 '상태를 클로저로 캡슐화해서 관리한다'라는 것과 'useState
가 여러 번 재호출 되더라도 초기화 시점이 아니라면 기존 상태를 기억하게 만든다'는 것이었다.
실제 useState
훅 코드를 까보진 않았지만, 아마 직접 구현한 코드보다 더 많은 상황에 대한 예외 처리가 꼼꼼하게 돼있을 것이라고 생각된다. 나도 아직 내가 직접 만든 훅을 여러 상황에 활용해 본 게 아니라서 어떤 부분에서 어떤 문제가 발생할지 감이 잘 안 잡힌다. 가능하면 여러 차례 훅을 사용해 보면서 부족한 부분을 개선해 봐야겠다. 끝.
'React' 카테고리의 다른 글
React render 함수 구현해보기 (2) | 2024.12.19 |
---|---|
React createElement 직접 구현하기: 동작 구현 (0) | 2024.12.16 |
React createElement 직접 구현하기: 환경 설정 (1) | 2024.12.14 |
useState 상태 업데이트와 useEffect 실행 시점 (1) | 2024.12.06 |
BrowserRouter와 createBrowserRouter (1) | 2024.12.03 |