티스토리 뷰
배경
리액트를 클래스형으로는 꽤 많이 구현해 보고 블로그에 글도 적었다. 언젠가 한 번쯤은 함수형을 구현해봐야지라고 생각했는데 생각보다 좀 늦었다. 너무 많은 내용을 적으면 글을 쓰는 사람도 읽는 사람도 힘들 것 같아서 조금씩 나눠서 몇차례에 거쳐 글을 써보려고 한다.
첫번째 목표: 하나의 useState 만들기
1-1.js
function useState(initialState) {
let state = initialState;
const setState = (nextState) => {
state = nextState;
render();
};
return [state, setState];
}
function Counter() {
const [count, setCount] = useState(1);
console.log('count: ', count);
window.increase = () => setCount(count + 1);
window.decrease = () => setCount(count - 1);
return /*html*/ `
<div>
count: ${count}
<button onclick="increase()">increase</button>
<button onclick="decrease()">decrease</button>
</div>
`;
}
function render() {
const $root = document.getElementById('root');
$root.innerHTML = Counter();
}
render();
일단 useState로 count 예제를 만들어보겠다.
useState라는 함수를 만들고 setState 실행하면 state가 변경되고 리렌더링이 발생한다.
결과는 어떨까?
count가 변경되지 않는다. 왜 그럴까?
함수형 컴포넌트는 리렌더링이 일어날 때마다 함수가 다시 호출된다.
즉, 버튼을 누르면 count가 증가하지만 `let state = initialState`가 다시 실행되어서 count는 변하지 않는 것이다.
1-2.js
let state;
function useState(initialState) {
if (state === undefined) {
state = initialState;
}
const setState = (nextState) => {
state = nextState;
render();
};
return [state, setState];
}
// 1-1과 동일해서 생략
함수가 재실행되지만 기존의 state는 유지되어야한다. 이를 구현하기 위해 클로저를 사용했다.
첫번째 목표 완료!
두번째 목표: 여러개의 useState 동작하게 만들기
2-1.js
let state;
function useState(initialState) {
if (state === undefined) {
state = initialState;
}
const setState = (nextState) => {
state = nextState;
render();
};
return [state, setState];
}
function Input() {
const [input, setInput] = useState('');
console.log('input: ', input);
window.changeInput = () => setInput(document.querySelector('input').value);
return /*html*/ `
<div>
input: ${input}
<input oninput="changeInput()"/>
</div>
`;
}
function render() {
const $root = document.getElementById('root');
$root.innerHTML = `
${Counter()}
${Input()}
`;
}
render();
하나가 성공을 했으면 두 개를 시도해보자.
count를 변경했는데 input이 변경되고 input을 변경했는데 count가 변경된다. 즉 두 개의 상태는 항상 같은 값을 가진다.
다시 한번 useState를 살펴보면 이유를 쉽게 알 수 있다. 클로저가 하나의 값으로 이루어져 있다. 그렇기 때문에 매번 변경되는 게 당연하다. useState를 다시 구현해보자.
2-2.js
let states = [];
let statesIndex = 0;
function useState(initialState) {
if (states.length === statesIndex) {
states.push(initialState);
}
const currentIndex = statesIndex;
const state = states[currentIndex];
const setState = (nextState) => {
states[currentIndex] = nextState;
render();
};
// 버그 테스트1
// const state = states[statesIndex];
// const setState = (nextState) => {
// console.log('setState index', statesIndex);
// states[statesIndex] = nextState;
// render();
// };
statesIndex += 1;
return [state, setState];
}
function render() {
const $root = document.getElementById('root');
$root.innerHTML = `
${Counter()}
${Input()}
`;
statesIndex = 0;
// 버그 테스트2: statesIndex 재할당하지 않기
}
render();
이제 조금씩 어려워진다.
useState는 한 번의 렌더 페이지에 여러 번 호출되어야 한다. 그렇기 때문에 기존의 클로저를 배열로 변경했다.
배열로만 관리를 하면 setState가 발생할 때 몇 번재 index에 접근해야 할지 모르기 때문에 index 변수도 필요해진다.
위 구현에는 크게 두 가지 포인트가 있다.
point1. useState 내부에 currentIndex라는 변수를 선언하기
만약 stateIndex를 그대로 사용하게 되면 input이 변경되지 않고 count가 변경된다.
이걸 해석해 보면 setState내부에서 statesIndex는 0으로 간주된다는 것을 알 수 있다.(버그 테스트1 주석을 실행해서 테스트 가능)
오래된 클로저
setState 내부의 stateIndex는 함수가 선언될 당시의 값을 기억한다.
useState가 여러번 실행되더라도, 이미 만들어진 클로저 내부에서는 이전 값을 계속 참조하게 된다.
그러면 states는 왜 제대로 동작하냐는 의문이 들 수 있다.
states는 원시값이 아닌 객체다. 그렇기 때문에 동일한 값을 참조하게 된다.(객체는 주소값이니까.)
statesIndex는 원시값이기 때문에 값이 복사되고, 클로저 내부의 sattesIndex는 함수 선언 당시의 값을 계속 유지하게 된다.
point2. index 초기화
redner 될 때마다 statesIndex를 0으로 재선언하지 않으면 statesIndex는 계속 늘어나게 된다.
그래서 0으로 초기화가 꼭 필요하다.
두번째 목표 완료!
세번째 목표: 하나의 state와 useEffect 동작하게 만들기
3.js
let prevDeps;
function useEffect(effect, deps) {
const hasNoDeps = !deps;
const hasChangedDeps = !prevDeps || deps.some((dep, i) => !Object.is(dep, prevDeps[i]));
if (hasNoDeps || hasChangedDeps) {
effect();
}
prevDeps = deps;
}
function Counter() {
const [count, setCount] = useState(1);
console.log('count: ', count);
window.increase = () => setCount(count + 1);
window.decrease = () => setCount(count - 1);
window.render = render;
useEffect(() => {
console.log('useEffect: count changed', count);
}, [count]);
return /*html*/ `
<div>
count: ${count}
<button onclick="increase()">increase</button>
<button onclick="decrease(event)">decrease</button>
<button onclick="render()">rerender</button>
</div>
`;
}
클로저로 prevDeps를 정의하고 prevDeps와 currentDeps를 비교해서 하나라도 변경이 되었다면 effect를 실행하면 된다.
참고로 리액트에서는 값을 비교할 때 Object.is로 비교한다. 참고링크
세번째 목표 완료!
네번째 목표: 여러개의 state와 useEffect 동작하게 만들기
4-1.js
function App() {
const [count, setCount] = useState(1);
const [input, setInput] = useState('');
console.log('count: ', count);
console.log('input: ', input);
window.increase = () => setCount(count + 1);
window.decrease = () => setCount(count - 1);
window.changeInput = () => setInput(document.querySelector('input').value);
window.render = render;
useEffect(() => {
console.log('useEffect: count changed', count);
}, [count]);
useEffect(() => {
console.log('useEffect: input changed', input);
}, [input]);
return /*html*/ `
<div>
count: ${count}
<button onclick="increase()">increase</button>
<button onclick="decrease(event)">decrease</button>
<button onclick="render()">rerender</button>
</div>
<div>
input: ${input}
<input oninput="changeInput()"/>
</div>
`;
}
이번에는 하나의 컴포넌트에 두개의 state를 넣고 useEffect를 테스트해보자.
count를 변경했는데 input 의존성을 넣은 useEffect가 실행된다. 실패했다.
이건 useEffect안에 `console.log(prevDeps, deps);`를 넣어서 테스트해 보면 금방 알 수 있다.
useState와 유사하게 prevDeps가 배열이 아니어서 발생하는 문제다.(deps가 배열이기 때문에 사실 prevDeps도 배열이기는 하다. 다만 이렇게 되면 2차원 배열이 되어야 한다.)
4-2.js
let effects = [];
let effectsIndex = 0;
function useEffect(effect, deps) {
const currentIndex = effectsIndex; // 오래된 클로저문제 방지
const hasNoDeps = !deps;
const prevDeps = effects[currentIndex];
const hasChangedDeps = !prevDeps || deps.some((dep, i) => !Object.is(dep, prevDeps[i]));
if (hasNoDeps || hasChangedDeps) {
effect();
effects[currentIndex] = deps;
}
effectsIndex += 1;
}
function render() {
const $root = document.getElementById('root');
$root.innerHTML = `
${App()}
`;
statesIndex = 0;
effectsIndex = 0;
}
render();
useState와 이전의 useEffect를 이해했다면 위 코드를 충분히 이해할 수 있다.
네번째 목표 완료!
다섯번째 목표: useRef 구현하기
사실 원래 쓰려고 했던 글을 다썼다. 근데 좀 아쉬워서 useRef까지만 구현해보려고 한다.
let states = [];
let statesIndex = 0;
function useState(initialState) {
if (states.length === statesIndex) {
states.push(initialState);
}
const currentIndex = statesIndex;
const state = states[currentIndex];
const setState = (nextState) => {
states[currentIndex] = nextState;
render();
};
statesIndex += 1;
return [state, setState];
}
let refs = [];
let refsIndex = 0;
function useRef(initialValue) {
if (refs.length === refsIndex) {
refs.push({ current: initialValue });
}
const ref = refs[refsIndex];
refsIndex += 1;
return ref;
}
function App() {
const [input, setInput] = useState('');
const countRef = useRef(0);
console.log('count: ', countRef.current);
console.log('input: ', input);
window.increase = () => (countRef.current = countRef.current + 1);
window.decrease = () => (countRef.current = countRef.current - 1);
window.changeInput = () => setInput(document.querySelector('input').value);
window.render = render;
useEffect(() => {
console.log('useEffect: count changed', countRef.current);
}, [countRef.current]);
useEffect(() => {
console.log('useEffect: input changed', input);
}, [input]);
return /*html*/ `
<div>
count: ${countRef.current}
<button onclick="increase()">increase</button>
<button onclick="decrease(event)">decrease</button>
<button onclick="render()">rerender</button>
</div>
<div>
input: ${input}
<input oninput="changeInput()"/>
</div>
`;
}
function render() {
const $root = document.getElementById('root');
$root.innerHTML = `
${App()}
`;
statesIndex = 0;
effectsIndex = 0;
refsIndex = 0;
}
render();
useState를 이해했다면 useRef를 이해하는 건 어렵지 않다.
거의 대부분의 코드가 동일하다.
다른점1. useState는 initialValue를 추가한다면 useRef는 객체를 추가한다.
다른점2. setState 대신 ref.current를 이용해서 상태를 변경한다.
이 몇 줄의 차이로 리렌더링되지 않는 useState(useRef)를 만들 수 있다.
(html에 ref를 적용하는 건 v-dom을 구현해야 할 수 있기 때문에 일단 생략한다.)
increase를 여러번 누르고 rerender 버튼 혹은 input 이벤트를 발생시키면 count가 변경되는 것을 볼 수 있다.
다섯번째 목표 완료!
이후 목표
위 코드에서 jsx, v-dom, diff 알고리즘, 컴포넌트 생명주기를 추가하고 가벼운 투두리스트를 만들기
참고 글
https://hewonjeong.github.io/deep-dive-how-do-react-hooks-really-work-ko/
[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까? — Hewon Jeong
Deep dive: How do React hooks really work?을 저자, Swyx의 허락을 받고 번역한 글입니다. 오타, 오역은 제보해주시면 수정하도록 하겠습니다.👍🏻 클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(L
hewonjeong.github.io
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Make-useSate-hook/
Vanilla Javascript로 React UseState Hook 만들기 | 개발자 황준일
본 포스트는 React의 useState Hook의 작동방식에 대해 고민해보고, 구현해보고, 최적화하는 내용을 다룹니다. 필자는 React를 사용할 때 hook api들을 보면서 항상 신기했다. function Counter () { const [count, s
junilhwang.github.io
전체 코드
TIL/blog/함수형_리액트 at main · yoonminsang/TIL
Today I Learned. Contribute to yoonminsang/TIL development by creating an account on GitHub.
github.com
'기술' 카테고리의 다른 글
비동기 함수 효율적으로 처리하기 (0) | 2025.02.12 |
---|---|
3년차 프론트엔드 개발자가 보는 테스트코드(feat. 유의미한 테스트코드) (5) | 2024.09.12 |
컴포넌트를 잘 만드는 방법 2편(리액트) (2) | 2024.02.13 |
라이브러리없이 리액트 만들기(클래스형 컴포넌트) (0) | 2022.08.14 |
컴포넌트를 잘 만드는 방법(리액트) (7) | 2022.07.16 |