requestAnimationFrame 활용하기

animation을 사용할 때에는 Timer 함수보다는 requestAnimationFrame을 사용해야 한다.

js의 메인쓰레드가 아닌 애니메이션 프레임에서 실행되기 때문에 애니메이션 프레임이 소실되거나 여러개의 애니메이션을 실행할 경우에 서로 충돌하는 경우를 방지하기 위해서이다.

최근 숫자가 증가하는 애니메이션을 구현하였는데, 요구사항은 다음과 같다.

  1. 증가량이 점점 감소할 것(목표치에 가까워지면 조금씩 증가)
  2. 2초 동안 동작할 것

1번 요구사항때문에 increment라는 변수를 두어 step 함수의 실행마다 step*=0.9를 해주고 setCurNumber(maxNumber-increment)를 통해 구현하였다.

그리고 2번 요구사항으로 인해 Timer 함수가 아닌 requestAnimationFrame을 사용하게 되었는데, 이렇게 구현하게 되면 2번 요구사항을 만족할 수 없었다.

requestAnimationFrame의 호출주기는 주사율에 따라 결정되고 주사율은 다 다르기 떄문에 기기마다 같은 시간동안 호출되는 횟수도 다르고 시간제한을 넣게되면 호출 횟수, 시간, 현재 값의 세 변수에 따라 실행하게 되는데 이러한 점화식을 찾지 못하였다.

결론적으로 말하자면 위에서 말한 점화식(ease함수)를 만들어 가중치를 계산하여 증가속도를 구현하였다.

ease함수는 여기를 참조하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useState, useEffect, useRef, useCallback } from "react";

function easeOutExpo(t) {
return t == 1 ? 1 : -Math.pow(2, -10 * t) + 1;
}

export const useIncreaseNumber = (
maxNumber,
initialNumber = 0,
duration = 2000
) => {
const [curCount, setcurCount] = useState(initialNumber);
const curTime = useRef(null);

const step = useCallback((timeStamp) => {
if (curTime.current === null) curTime.current = timeStamp;

const progress = timeStamp - curTime.current;

const nextCount = Math.ceil(
(maxNumber - initialNumber) * easeOutExpo(progress / duration)
);

const nextCurCount = nextCount > maxNumber ? maxNumber : nextCount;

setcurCount(nextCurCount);

if (progress < duration) {
window.requestAnimationFrame(step);
}
}, []);

useEffect(() => {
window.requestAnimationFrame(step);
}, []);

return curCount;
};

주사율을 제한하여 구현하는 아이디어도 생각했는데, 이에 대해서 정리해보려고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useState, useEffect, useRef, useCallback } from "react";

function easeOutExpo(t) {
return t == 1 ? 1 : -Math.pow(2, -10 * t) + 1;
}

export const useIncreaseNumber2 = (
maxNumber,
initialNumber = 0,
duration = 1000,
fpsHz = 1000 / 30
) => {
const [curCount, setcurCount] = useState(initialNumber);
const curTime = useRef(null);
const lastTime = useRef(null);
const cnt = useRef(0);

const step = useCallback((timeStamp) => {
if (curTime.current === null && lastTime.current === null) {
curTime.current = window.performance.now();
lastTime.current = window.performance.now();
}

const totalElapsed = window.performance.now() - curTime.current;
// 총 실행시간으로 duration과 비교하여 rAF를 탈출하기위해 사용한다.

if (totalElapsed > duration) {
return;
}
// duration일경우 탈출

const elapsed = timeStamp - lastTime.current;
// 마지막 상태변경과의 시간 차이

if (elapsed >= fpsHz) {
// 마지막 상태변경과의 시간차이가 주사율 이상일 경우에만 값을 반영한다.
const nextCount = Math.ceil(
(maxNumber - initialNumber) * easeOutExpo(totalElapsed / duration)
);
// ease 함수를 이용하여 가중치를 계산하여 반영한다.

const nextCurCount = nextCount > maxNumber ? maxNumber : nextCount;
// 호출 횟수에 따른 보정을 해준다.

console.log(++cnt.current);
// 몇 번 렌더링 되었는지 출력한다.
setcurCount(nextCurCount);
// state를 바꾼다
lastTime.current = timeStamp - (elapsed % fpsHz);
// 마지막 상태변경시간을 업데이트한다.
}
window.requestAnimationFrame(step);
}, []);

useEffect(() => {
window.requestAnimationFrame(step);
}, []);

return curCount;
};
// cnt.current의 마지막 값은 29가 출력된다.

setInterval의 경우 두 번째 parameter가 호출 주기이기 때문에 반환값을 통해서 쓰로틀링을 충분히 구현할 수 있지만, requestAnimationFrame의 경우 window.perfomance.now()를 통해 이를 직접 구현해줘야한다.

상태를 변경하여 브라우저에 변경사항이 반영되는 횟수를 제한하였다.

신경써야 할 점은 lastTime.current 계산식이다.

timeStamp는 마지막 호출의 시간이고 elapsed는 timeStamp와 마지막 상태 변경시간의 차이이다.

상태를 변경하는 조건은 elapsed가 fpsHz보다 클 경우이므로 lastTime을 timeStamp로 하게 되면 elapsed-fpsHz만큼의 값이 축적되어 계산되게 된다.

lastTime.current에 timeStamp를 그냥 저장하게 되면 elapsed-fpsHz만큼의 값이 축적되어 계산되게 된다. 떄문에 lastTime.current에는 timeStamp-(elapsed-fpsHz)를 저장해주어야 한다.

elapsed값은 계속 증가하는 값이기 때문에 나누기 연산으로 계산해주었다.

반응형 rem 미세팁

웹 접근성과 반응형을 위해 마진, 패딩등의 단위로 rem을 많이 사용한다.

rem은 html의 font-size 기준으로 기본 font-size는 16px이다.

그래서 계산이 조금 어려운데, 다음과 같이 수정해주면 계산을 쉽게 할 수 있다.

1
2
3
html {
font-size: 10px;
}

html의 font-size가 10px이 되기 때문에 계산을 좀 더 편하게 할 수 있다.

하지만 이렇게 고정값을 줄 경우 브라우저에서 font-size 관련 설정을 larger 등으로 바꾸어뒀을 경우에도 고정되는 문제가 생긴다.

1
2
3
4
html {
font-size: 62.5%;
}
/* 16*0.625===10 */

다음과 같이 작성해주면 브라우저의 larger 설정으로 font-size가 20이 되더라도 그걸 반영하여 적용되게 된다.

바닐라 js로 browser router 만들기

SPA를 만들기 위해선 router 구현이 필요하다.

vanillaJS로 browser router를 구현해보았다.

기본적으로 SPA의 동작원리는 다음과 같다.

key를 url path로 value를 component로 하는 객체 리터럴을 만든다.

페이지 이동을 할 때에 이 객체 리터럴에서 페이지에서 파싱한 값을 통해 component를 조회하여 렌더링한다

페이지 이동을 정의하는 방법엔 두 가지가 있다. 함수를 통해 pushState로 바꿔주고 innerHTML을 바꾸어주거나 혹은 이 작업을 커스텀 이벤트로 정의하는 방식이다.

두 번째( 커스텀 이벤트를 정의하는 방식 )으로 browser router를 구현해보았다.

Router 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Router {
constructor() {}
// 생성자
initEvent() {}
// 이벤트 초기화
onRouterChangeHandler() {}
// route가 변경되었을 때의 이벤트 핸들러
hasRoute() {}
// 올바른 라우트인지 검증
getRoute() {}
// 해당 라우트 가져오기
renderPage() {}
// 페이지 렌더링
push() {}
// 라우터 push
}

Router 초기화

1
2
3
4
5
6
7
8
9
10
11
12
13
constructor({
$app,routes,fallback='/'
}){
this.$app=$app;
this.fallback=fallback;
this.routes=[];

routes.forEach(route=>{
this.routes=[route.path]=route.page;
})

this.initEvent();
}

$app,fallback,route를 초기화하고 initEvent를 실행한다.

커스텀 이벤트 추가

browser router는 history API를 사용하여 주소를 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
initEvent() {
document.addEventListener(
"moveRoutes",
this.moveRoutesHandler.bind(this) as EventListener
);
}

moveRoutesHandler(event: CustomEvent) {
const path: string = event.detail.path;
history.pushState(event.detail, "", path);

this.renderPage(path);
}

moveRoutes라는 이벤트를 추가해주어야 한다.

1
2
3
4
5
6
7
export const customEventEmitter = (eventType: string, detail?: object) => {
document.dispatchEvent(
new CustomEvent(eventType, {
detail,
})
);
};

커스텀 이벤트를 정의하는 함수를 만든다.

페이지 렌더링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
hasRoute(path: string) {
return typeof this.routes[path] !== "undefined";
}

getRoute(path: string) {
return this.routes[path];
}

renderPage(path: string) {
let route;
const regex = /\w{1,}$/;

if (this.hasRoute(path)) {
route = this.getRoute(path);
// 경로가 존재할경우
} else if (regex.test(path)) {
route = this.getRoute(path.replace(regex, ":id"));
// 경로가 존재하지 않을 경우 동적 라우팅(id)
} else {
route = this.getRoute(this.fallback);
// 그 외의 주소에 대해서는 fallback 실행
}
new route(this.$app, {});
// route 객체의 인스턴스를 만듬
}

push(path: string) {
customEventEmitter("moveRoutes", {
...history.state,
path,
});
}

라우터 export

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export let router;

export function initRouter($app, routes, fallback) {
const routerObj = new Router($app, routes, fallback);

router = {
push: (path) => routerObj.push(path),
};

customEventEmitter(
"moveRoutes",
history.state ?? {
path: "/",
}
);
}

모듈패턴을 이용하여 push를 제외한 나머지는 private로 두었다.

그리고 초기에 customEventEmitter를 통해 루트 페이지가 렌더링 될 수 있도록 하였다.

사용하기

1
2
3
4
5
6
7
const routes = [
{ path: "/", page: App },
{ path: "/detail/:id", page: Detail },
];

const target = document.querySelector(".App");
initRouter(target, routes);

Ref

바닐라JS(TS)로 리액트 SPA 구현하기 | (4) 클래스로 BrowserRouter 구현
Build a custom SPA Router using VanillaJS

input 태그의 width

input에 width:100%을 주어도 overflow가 일어난다.

1
2
3
input {
width: 100%;
}

input태그는 기본적으로 border가 있기 때문에 box-sizing을 바꾸어주어야 한다.

1
2
3
input {
box-sizing: border-box;
}

클릭 이벤트의 발생 순서

최근 진행한 프로젝트에서 click 이벤트 대신 mousedown 이벤트를 사용하여 클릭 관련 문제를 해결하였다.

1
2
3
4
<form>
<input />
<div></div>
</form>
1
2
3
4
5
6
7
8
9
10
11
12
form {
position: relative;
}
div {
display: none;
position: absolute;
top: 100%;
...;
}
form:focus-within div {
display: block;
}

form 내부의 div는 평소에 display:none으로 보이지 않지만 form에 focus-within 선택자를 이용하여 자신이나 자식이 focus되었을 경우 div를 보이게 하였다.

그리고 이 div요소에 클릭 이벤트를 등록하였는데, 클릭 이벤트가 작동하지 않았다.

focus가 해제되는 blur이벤트가 click이벤트보다 먼저 발생했기 때문이다.

클릭 이벤트는 mousedown과 mouseup이 연달아 발생하는 이벤트로 mousedown -> mouseup -> click 순서대로 발생한다.

mousedown이 감지되면 blur이벤트가 발생하고 div요소에 display:none이 적용된 후 mouseup, click이벤트가 발생하기 때문에 작동하지 않았던 것이였다.

click대신 mousedown에 이벤트를 등록하여 이벤트가 정상적으로 작동하도록 하였다.

Ref

마우스 이벤트

React 요소 바깥 클릭 감지하기

React에서 요소 바깥을 클릭할 경우에 이벤트를 발생시켜야 하는 경우가 있다.

useRef로 DOM element를 참조하여 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const wrapper = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (e.target instanceof Element && wrapper.current?.contains(e.target)) {
//wrapper 내부의 요소일 경우의 callback 실행
return;
}
//wrapper 외부의 요소일 경우의 callback 실행
};
window.addEventListener("click", (e) => handleClickOutside(e));
return () => {
window.removeEventListener("click", (e) => handleClickOutside(e));
};
}, [wrapper.current]);

wrapper로 내부와 외부를 나눌 요소를 정의한다.

정의한 후 window 전역에 이벤트 핸들러를 추가한다.

이벤트 내부에서 이벤트 전파의 원리를 이용하여 contains 메소드를 통해 이벤트의 발생 위치를 검사한다.

이벤트의 발생 위치에 따라 콜백을 실행하면 된다.

그 후 useEffect에서 cleanup 함수로 전역에 추가된 이벤트를 제거해주면 된다.

React Context API 렌더링 성능 개선방안

React에서 Context API와 상태관리에서 언급했듯이 Context API는 context의 참조하지 않는 값이 변경되더라도 전체가 렌더링되는 성능 관련 이슈가 있다.

이에 대한 해결방안을 알아보았다.

1. Context 분할

Context 분할을 통해 해결할 수 있다.

1
2
3
4
5
6
7
const state1 = {
familyname: "park",
};

const state2 = {
firstname: "sunghyeon",
};

하지만 상태가 많아져 유지보수에 큰 어려움을 겪게될 수 있다.

2. React.memo

React.memo를 사용하는 것으로도 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const FirstName = () => {
const [state, dispatch] = React.useContext(NameContext);
console.log("render FirstName");
return (
<div>
First Name:
<input
value={state.firstName}
onChange={(event) => {
dispatch({ type: "setFirstName", firstName: event.target.value });
}}
/>
</div>
);
};
export default React.memo(FirstName);

위와같이 단순히 export 시점에 React.memo를 추가하여도 해당 컴포넌트에더 useContext를 통해 NameContext가 반환되고 있어 NameContext가 바뀔 시 렌더링이 진행된다.

때문에 props drilling을 섞어서 해결해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const InnerFirstName = React.memo(({ firstName, dispatch }) => (
<div>
First Name:{" "}
<input
value={firstName}
onChange={(event) => {
dispatch({ type: "setFirstName", firstName: event.target.value });
}}
/>
</div>
));
const FirstName = () => {
const [state, dispatch] = React.useContext(NameContext);
console.log("render FirstName");
return <InnerFirstName firstName={state.firstName} dispatch={dispatch} />;
};
export default FirstName;

이렇게 작성하게 되면 FirstName 컴포넌트는 여전히 리렌더링 되지만 NameContext의 firstName값이 변경되지 않는다면 InnerFirstName 컴포넌트는 리렌더링되지 않는다.

단순히 Wrapper Component를 만드는 것이므로 성능상의 문제가 되지 않는다.

3. useMemo

React.memo가 다른 부가적인 값에 의해 의도한 대로 동작하지 않는 경우 useMemo를 사용하여 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const FirstName = () => {
const [state, dispatch] = React.useContext(NameContext);
console.log("render FirstName");
return useMemo(
() => (
<div>
First Name:{" "}
<input
value={state.firstName}
onChange={(event) => {
dispatch({ type: "setFirstName", firstName: event.target.value });
}}
/>
</div>
),
[firstName, dispatch]
);
};
export default FirstName;

방법 2와 동일하게 FirstName 컴포넌트의 렌더를 막지는 못하지만, return에서 useMemo를 사용함으로써 렌더링을 강제로 막는 모습이다.

하지만 위 방법은 관리하는 state,props가 늘어날 때마다 useMemo의 dependency에도 추가를 해줘야해서 가장 유지보수가 힘든 방법이다.

Ref

ContextAPI 렌더링 이슈

EventTarget 형식에 ... 속성이 없습니다. 대처법

event와 target의 타입을 분리해서 지정해주면 된다.

1
2
3
4
5
const onDragEnd = (e: React.DragEvent) => {
if (!wrapper.current) return;
const target = e.target as HTMLDivElement;
...
}

React Hooks의 동작원리

순수함수로 작성된 함수형 컴포넌트는 어떻게 내부 상태를 관리하는지 궁금했었는데, 얼마전 클로저를 이용하여 관리한다는 것을 알게되었다.
더 확실하게 알고자 Hooks의 동작원리에 대해 알아보았다.

클로저

클로저는 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 떄에도 이 스코프에 접근할 수 있게 하는 기능을 뜻한다.

이는 렉시컬 스코핑과 관련이 있다.

아래의 코드는 예시 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function useState(initialValue) {
var _val = initialValue;
function state() {
return _val;
}
function setState(newVal) {
_val = newVal;
}
return [state, setState];
}
var [foo, setFoo] = useState(0);
console.log(foo()); // 0
setFoo(1);
console.log(foo()); // 1

useState 내부에서 _val이라는 지역변수를 만들고 state 함수와 setState함수로 지역변수를 노출하지 않고 변경한다.

state는 getter 함수로 구현해야 하지만, foo와 setFoo를 사용하여 내부 변수에 접근하고 조작할 수 있다는 것이다.

이 둘은 useState의 스코프에 대한 접근권한을 가지고 있고, 이를 클로저라고 한다.

함수형 컴포넌트에서 사용하기

아래의 코드는 위에서 만든 useState 복제본을 함수형 컴포넌트에 적용한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = useState(0);
return {
click: () => setCount(count() + 1),
render: () => console.log("render:", { count: count() }),
};
}
const C = Counter();
C.render(); // render: { count: 0 }
C.click();
C.render(); // render: { count: 1 }

DOM으로 렌더링하는 대신 상태를 console.log로 출력하도록 하였다.

오래된 클로저

오래된 클로저는 클로저로 인해 갇혀있던 값이 업데이트 되지 않고 남아있는 현상이다.

원시값의 경우 참조가 없기 때문에 발생한다.

실제 React API와 동일하게 만들려면 state가 함수가 아닌 변수여야 한다. 단순히 _val을 함수로 감싸지 않고 노출하면 버그가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
function useState(initialValue) {
var _val = initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
}
var [foo, setFoo] = useState(0);
console.log(foo); // 0
setFoo(1); //
console.log(foo); // 0

_val은 원시값이기 때문에 참조가 없다.

때문에 useState의 호출에서 _val을 참조하고 다시는 변경되지 않는다.
즉 foo는 initialValue값만을 가지고 setFoo는 lexical scope의 _var를 수정한다.

때문에 setFoo를 사용하여 _var를 수정하여도 foo의 주소는 바뀌지 않는다.

모듈 안에서의 클로저

이는 우리의 클로저를 또 다른 클로저의 내부로 이동시켜서 해결할 수 있다. 즉 다른 실행컨텍스트에 _val을 묶어둠으로 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const MyReact = (function () {
let _val;
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();

모듈패턴을 사용하여 구성된 코드로, React와 마찬가지로 컴포넌트의 상태를 추적한다. 또한 매번 항상 올바른 클로저를 통해 내부의 _val값을 할당할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = MyReact.useState(0);
return {
click: () => setCount(count + 1),
render: () => console.log("render:", { count }),
};
}
let App;
App = MyReact.render(Counter); // render: { count: 0 }
App.click();
App = MyReact.render(Counter); // render: { count: 1 }

useEffect 복제하기

useEffect도 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const MyReact = (function () {
let _val, _deps;
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const hasChangedDeps = _deps
? !depArray.every((el, i) => el === _deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
_deps = depArray;
}
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();

function Counter() {
const [count, setCount] = MyReact.useState(0);
MyReact.useEffect(() => {
console.log("effect", count);
}, [count]);
return {
click: () => setCount(count + 1),
noop: () => setCount(count),
render: () => console.log("render", { count }),
};
}
let App;
App = MyReact.render(Counter);
// 이펙트 0
// render {count: 0}
App.click();
App = MyReact.render(Counter);
// 이펙트 1
// render {count: 1}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1}
App.click();
App = MyReact.render(Counter);
// 이펙트 2
// render {count: 2}

의존성 배열이 변경될 때 useEffect가 다시 실행되므로, 이를 추적하는 별도의 변수 _deps를 추가하였다.

마법이 아니라 배열일 뿐

지금까지 만든 복제본은 잘못 구현된 싱글톤 패턴이다.
즉 각각 하나 이상이 존재하면 버그가 발생한다.

여러개의 상태를 버그 없이 관리하기 위해선 배열형태로 관리할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const MyReact = (function () {
let hooks = [],
currentHook = 0;
return {
render(Component) {
const Comp = Component();
Comp.render();
currentHook = 0;
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const deps = hooks[currentHook]; // type: array | undefined
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
hooks[currentHook] = depArray;
}
currentHook++;
},
useState(initialValue) {
hooks[currentHook] = hooks[currentHook] || initialValue; // type: any
const setStateHookIndex = currentHook;
const setState = (newState) => (hooks[setStateHookIndex] = newState);
return [hooks[currentHook++], setState];
},
};
})();

setStateHookIndex는 setState에서 currentHook 변수가 덮어 씌워지는 것을 방지하는데에 사용된다.

이를 제거할 경우 오래된 클로저 문제를 일으켜 setState를 다시 호출했을 때에 동작하지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Counter() {
const [count, setCount] = MyReact.useState(0);
const [text, setText] = MyReact.useState("foo"); // 두번 째 상태 Hook!
MyReact.useEffect(() => {
console.log("effect", count, text);
}, [count, text]);
return {
click: () => setCount(count + 1),
type: (txt) => setText(txt),
noop: () => setCount(count),
render: () => console.log("render", { count, text }),
};
}

let App;
App = MyReact.render(Counter);
// 이펙트 0 foo
// render {count: 0, text: 'foo'}
App.click();
App = MyReact.render(Counter);
// 이펙트 1 foo
// render {count: 1, text: 'foo'}
App.type("bar");
App = MyReact.render(Counter);
// 이펙트 1 bar
// render {count: 1, text: 'bar'}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1, text: 'bar'}
App.click();
App = MyReact.render(Counter);
// 이펙트 2 bar
// render {count: 2, text: 'bar'}

위와 같이 기본적인 개념은 Hooks의 배열과 Hook이 호출될 때 증가하고 컴포넌트가 렌더링될 때 초기화되는 인덱스를 갖는 것이다.

Hook 규칙 도출하기

이러한 동작원리가 최상위에서만 Hook을 호출해야하는 이유이다.

Ref

Learning JavaScript Design Patterns
[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까?

React에서 Context API와 상태관리

Context API를 이용하면 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있다.

한계

다시 렌더링 할지 여부를 정할 때 참조를 확인하기 때문에 Provider의 부모가 렌더링 될 때마다 불필요하게 하위 컴포넌트가 다시 렌더링되는 문제가 발생할 수 있다. 예를 들어 아래의 코드는 value가 바뀔 때 마다 매번 새로운 객체가 생성되므로 Provider가 렌더링 될 때마다 하위에서 구독하고 있는 컴포넌트 모두가 렌더링 된다.

1
2
3
4
5
6
7
8
9
10
11
12
class App extends React.Component {
render() {
return (
<MyContext.Provider
value={{ something: "something", somthing2: "something2" }}
>
<Toolbar />
<Toolbar2 />
</MyContext.Provider>
);
}
}

즉 Toolbar 에서는 value.something을 Toolbar2에서는 value.something2를 사용한다고 하더라도 value가 갱신될 때 마다 새 객체를 만들어 내므로 실제 사용하는 props와 상관없이 다시 렌더링 된다.

그럼 어떨 때 사용해야 하나

상태관리에 적합하지 않은 것이 한계이므로, 자주 바뀌지 않는 상태를 어플리케이션 전역에서 사용하려 할 때는 유용하다.

예를 들자면 @emotion/react와 같은 라이브러리에서 ThemeProvider를 Context api로 구현해놓았다.

Ref

React 에서 Context API 와 상태관리