
실제 리액트의 코드는 이와 다릅니다.
제가 작성한 코드는 학습하기 위해 작성한 코드임을 미리 알립니다!
전체 코드는 깃허브 에서 확인 해보실 수 있습니다.
이전글에서 만든 가상돔을 실제로 렌더링 해 보았습니다.
그런데 현재 상태에서는 상태가 변경되어도 리렌더링이 되질 않습니다.
왜냐하면 노드의 값을 변경 해주지 않았기 때문이죠
한번 간단하게 리렌더링부터 해볼까요?
App.tsx를 한번 수정 해 보겠습니다.
root인 노드의 자식을 replace시키는그런 예제입니다.
// App.tsx
import React from "@core/React";
let state = 1;
function App() {
const increment = () => {
state++;
const root = document.querySelector("#root");
const dom = React.createDOM(<App />);
root?.replaceChildren(dom);
};
return (
<div>
<div>Hello, JSX!</div>
<div>second child!</div>
<div>{state}</div>
<button onclick={increment}>증가버튼</button>
</div>
);
}
export default App;
위 예제에서 button을 누르면 root 하위가 전부 리렌더링 됩니다.
이를 위해 리액트는 렌더링 이전 가상돔, 현재 가상돔을 비교해 렌더링 최적화를 진행합니다.
하지만 이는 나중에 다룰 예정이므로 지금은 넘어가도록 하겠습니다.
지금은 상태를 바깥에서 관리를 해 주고 있습니다. 만약 App 컴포넌트 안에서 관리해주면 매 렌더링마다 값이 초기화 되기 때문에 밖에서 관리 해주고 있습니다.
그럼 여기서 이런 의문이 들 수 있습니다.
그럼 리액트도 useState를 사용하지 않고 함수 밖에서 관리 해주면 되는거 아닌가요?
useState는 상태를 변경 시키는것뿐 아니라 내부적으로 렌더링 최적화도 함께 다룹니다. setState를 호출한걸 모아서 한번에 처리하고, 스케줄러에 등록하는일 등을 합니다.
그런데 여기선 복잡한건 다루지 않겠습니다. 나중에 다룰 예정이니까요!
이 글에선 useState를 성공적으로 만들어서 상태를 변경하고, 리렌더링을 하는것 까지만 다루겠습니다.
useState는 함수로 구현 되어 있습니다.
함수로 내부 스코프의 변수를 유지하려면 클로저라는 개념을 알고있어야 합니다.
클로저의 개념은 mdn에서 매우 잘 설명해주고 있으므로 이곳을 참조해주세요!
리액트에서 useState는 아래처럼 사용합니다.
인자로 어떤 값을 받고, state와 setState를 배열로 리턴합니다.
const [state, setState] = useState(0);
이걸 구현하면 아래와 같은 형태가 될겁니다.
// useState.tsx
import App from "@src/App";
import React from ".";
let state: unknown;
function useState<T>(initialState: T): [T, (newState: T) => void] {
// state가 undefined일 때만 initialState로 초기화
if (typeof state === "undefined") {
state = initialState;
}
const setState = (newState: T) => {
// 상태를 변경
state = newState;
// 리렌더링
const root = document.querySelector("#root");
const dom = React.createDOM(<App />);
root?.replaceChildren(dom);
};
// state, setState를 반환
return [state as T, setState];
}
export default useState;
이제 우리는 App 컴포넌트는 아래처럼 useState를 사용할 수 있습니다.
// App.tsx
function App() {
const [state, setState] = useState(0);
const increment = () => {
setState(state + 1);
};
return (
<div>
<div>Hello, JSX!</div>
<div>second child!</div>
<div>{state}</div>
<button onclick={increment}>증가버튼</button>
</div>
);
}
그!런!데! 리액트에서 위와같은 코드를 사용하면 실제로 원하는 값으로 변경되지 않을 가능성이 존재합니다. setState는 비동기적으로 작동하기 때문이에요. 그래서 리액트가 바쁘면 최신값을 보장해주지 못합니다.
상태를 변경할 시점의 최신 상태를 사용하려면 콜백함수를 받아 그 함수에 최신 상태를 넣어줘야 합니다. 아래처럼 구현할 수 있겠네요.
// useState.tsx
type SetStateFunction<T> = (newState: T) => T;
type SetState<T> = (newState: T | SetStateFunction<T>) => void;
function isSetStateFunction<T>(
value: T | SetStateFunction<T>
): value is SetStateFunction<T> {
return typeof value === "function";
}
let state: unknown;
function useState<T>(initialState: T): [T, SetState<T>] {
// state가 undefined일 때만 initialState로 초기화
if (typeof state === "undefined") {
state = initialState;
}
const setState = (newState: T | SetStateFunction<T>) => {
// 상태를 변경
if (isSetStateFunction(newState)) {
state = newState(state as T);
} else {
state = newState;
}
// 리렌더링
const root = document.querySelector("#root");
const dom = React.createDOM(<App />);
root?.replaceChildren(dom);
};
// state, setState를 반환
return [state as T, setState as SetState<T>];
}
그리고 이제 아래처럼 함수를 넣으면 최신 상태를 받아 상태를 변경 시킬 수 있을겁니다!
// App.tsx
function App() {
const [state, setState] = useState(0);
const increment = () => {
setState((prev) => prev + 1);
};
return (
<div>
<div>Hello, JSX!</div>
<div>second child!</div>
<div>{state}</div>
<button onclick={increment}>증가버튼</button>
</div>
);
}
이제 거의 근접했는데 아직 해결하지 못한 문제가 하나 있습니다.
아래처럼 useState를 여러개 사용 해 볼까요?
function App() {
const [counter1, setCounter1] = useState(0);
const [counter2, setCounter2] = useState(0);
const increment1 = () => setCounter1((prev) => prev + 1);
const increment2 = () => setCounter2((prev) => prev + 1);
return (
<div>
<div>Hello, JSX!</div>
<div>second child!</div>
<div>{counter1}</div>
<button onclick={increment1}>증가버튼1</button>
<div>{counter2}</div>
<button onclick={increment2}>증가버튼2</button>
</div>
);
}

분명 따로따로 동작할거라 생각했는데 똑같은 값이 됩니다... 왜일까요?
이유는 지금 useState 함수가 하나의 state만 사용하기 때문이에요.
아래처럼요
// useState.tsx
...
let state: unknown; // << 여기!!
function useState<T>(initialState: T): [T, SetState<T>] {...}
그래서 useState 함수를 계속 불러도 저 값 하나만 참조하게 됩니다.
이를 해결 해 주려면 useState 함수가 호출될 때마다 다른 idx를 주는 방법이 있어요.
그런데 이렇게 하면 큰 문제 하나가 생깁니다.
그 문제는 잠깐 잊고 useState가 호출 될 때마다 다른 idx를 줘보도록 하겠습니다.
// useState.tsx
const state: unknown[] = [];
let stateIdx = 0;
function useState<T>(initialState: T): [T, SetState<T>] {
let nowIdx = stateIdx++;
// 만약 초기화를 진행한적이 없다면 초기화
if (state.length === nowIdx) {
state.push(initialState);
}
const setState = (newState: T | SetStateFunction<T>) => {
// 상태를 변경
if (isSetStateFunction(newState)) {
state[nowIdx] = newState(state[nowIdx] as T);
} else {
state[nowIdx] = newState;
}
// 다음에 리렌더링 될 때 올바른 상태를 가리키도록 인덱스를 초기화
stateIdx = 0;
// 리렌더링
...
};
// state, setState를 반환
return [state[nowIdx] as T, setState as SetState<T>];
}
이전 useState 함수에서 바뀐 부분은 useState가 호출될 때마다 idx를 1씩 추가해주고
그 idx를 기반으로 나머지 작업들을 해 주는 부분 입니다.
이제 다시 실행해보면 두개의 state가 따로 동작하는 모습을 볼 수 있습니다.
원리는 이렇습니다.
만약 state.length === nowIdx일 경우 해당 state는 초기화 된적이 없습니다.
즉, useState 함수가 불린적이 없습니다. 그래서 state 배열에 push(초기화) 해줍니다.
그 후 사용자의 액션으로 인해 setState가 실행이 된다면 stateIdx를 0으로 변경 해 주어야 합니다. 다음에 다시 리렌더링 될 때 useState의 idx를 올바르게 넣어주기 위해서 입니다.
만약 App 컴포넌트를 아래와 같이 수정하면 어떻게 작동할지 예상이 되시나요?
// App.tsx
function Counter1() {
const [counter, setCounter] = useState(0);
const increment = () => setCounter((prev) => prev + 1);
return (
<div>
<div>counter1: {counter}</div>
<button onclick={increment}>증가버튼1</button>
</div>
);
}
function Counter2() {
const [counter, setCounter] = useState(0);
const increment = () => setCounter((prev) => prev + 1);
return (
<div>
<div>counter2: {counter}</div>
<button onclick={increment}>증가버튼2</button>
</div>
);
}
function App() {
const [isCounter1Show, setIsCounter1Show] = useState(true);
const toggleCounter = () => setIsCounter1Show((prev) => !prev);
return (
<div>
<button onclick={toggleCounter}>카운터 토글</button>
{isCounter1Show ? <Counter1 /> : <Counter2 />}
</div>
);
}
리액트는 Counter1컴포넌트와 Counter2컴포넌트가 state를 공유하지 않을겁니다.
하지만 저희가 만든 useState는 아래와 같이 동작합니다.

바로 이 문제입니다!
여기에서 큰 문제 하나가 생긴다는거 기억 하시나요?
문제가 발생하는 이유는 idx 기반으로 useState를 구현했기 때문이에요.
그래서 Counter1에서 useState를 부르고 -> 토글하고 -> Counter2가 렌더링 되면 처음으로 불린 useState에서 0번째 idx의 값을 사용하게 됩니다.
이 문제를 해결하려면 Fiber객체에 대해 알아야 하는데
Fiber 하나만으로도 글이 매우 길어지니 이문제는 나중에 뒤에서 다루도록 하겠습니다.
이제 useState를 구현했으니 핵심 기능은 구현 했다고 볼 수 있습니다.
그런데 이것만으로는 매우 많이 부족합니다!!
useRef와 useEffect가 없으면 구현하지 못하는 기능이 많기 때문입니다.
그래서 이 둘을 구현한 이후 diffing 알고리즘을 구현 해보도록 하겠습니다.