이제부터 리액트의 훅에 대해서 제대로 알아보고자 한다.
훅은 클래스형 컴포넌트에서만 가능했던 state,ref 등 리액트의 핵심 기능을 함수에서도 가능하게 만들어 준 것이다. 리액트로 웹 서비스를 만드는 개발자라면 훅이 어떻게 동작하는지에 대해서는 이해할 필요가 있다.
useState
함수형 컴포넌트 내부에서 상태를 정의하고 이 상태를 관리할 수 있게 해주는 훅이다.
아래처럼 생겼다.
import {useState} from 'react'
const [state,setState] = useState(initialState)
useState()의 인수로는 state의 초깃값을 넘겨주면 된다.
만약 useState를 사용하지 않으면 어떻게 될까?
import React from 'react'
const Component = () => {
let state = 'hello'
function handleButtonClick() {
state = 'hi'
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
/* STYLE */
export default Component
리액트의 렌더링 과정을 생각해보자. 리액트는 함수형 컴포넌트의 return을 실행한 후 이전의 리액트 트리와 비교하여 업데이트를 한다. 하지만 위와 같이 코드를 작성하면 리렌더링의 조건을 충족시키지 못한다.
아래 코드도 보자.
import React from 'react'
const Component = () => {
const [,triggerRender] = useState()
let state = 'hello'
function handleButtonClick() {
state = 'hi'
triggerRender()
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
/* STYLE */
export default Component
위코드도 동작하지 않는다. 리액트가 렌더링이 일어나면 함수를 다시 새롭게 실행되게 된다. 따라서 함수가 새로 실행될때마다 state는 hello로 초기화 되기 때문에 값의 변화가 반영되지 않는다.
실제 useState는 아니지만 useState를 구현한 원리가 담겨있는 아래 예시를 봐보자.
const MyReact = function() {
const global = {}
let index = 0
function useState(initialState){
if(!global.states) {
global.states = []
}
const currentState = global.states[index] || initialState
global.states[index] = currentState
const setState = (function() {
//클로저로 index를 가둬두어서 동일한 index에 접근이 가능
let currentIndex = index
return function(value){
global.states[currentIndex] = value
//컴포넌트 렌더링이 들어가는 부분
}
})()
index = index + 1
return [currentState,setState]
}
실제 구현은 useReducer을 이용하여 구현되어 있다. 이는 나중에 조금 더 자세히 알아보자.
실제 리액트 훅의 구성
훅에 대한 구현체를 github에서 타고 올라가다보면 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 라는 문구를 만나게된다. (살벌하다 ㅋㅋ)
일반 사용자의 접근을 차단하고 실제 프로덕션 코드에서 사용하지 못하게 막은 것 같다.
해당 책의 훅에 대한 예제는 Preact라는 리액트의 경량화 버전으로 모든 코드를 볼 수 있는 라이브러리를 기준으로 하고 있다고 한다.
useState는 JS특징 중 하나인 클로저에 의존되어 구현되어 있으며 이를 활용하여 값을 노출시키지 않으며 함수형 컴포넌트가 실행되더라도 useState에서 이전의 값을 꺼내쓸 수 있다.
게으른 초기화
useState에 변수가 아닌 함수를 넘기는 것도 가능한데 이를 게으른 초기화(lazy initialization) 이라고 한다.
//useState
const [count,setCount] = useState(Number.parseInt(window.localStorage.getItem(cacheKey)))
//게으른 초기화
const [count,setCount] = useState(()=> Number.parseInt(window.localStorage.getItem(cacheKey)),)
리액트 공식문성에서는 이런 방법은 useState의 초기값이 무겁거나 복잡할때 사용하라고 되어 있다.
이 게으른 초기화 함수는 state가 처음 만들어질때만 사용되며 리렌더링이 발생해도 해당 함수의 실행은 무시된다.
리액트에서 렌더링이 실행될때마다 함수형 컴포넌트의 함수가 다시 실행된다. 즉 useState의 값도 재실행되게 된다.인수를 넣는 과정에서 함수로 복잡한 과정을 실행시킨다면 비용이 많이 발생하지만 위와같이 useState내부에 함수가 있다면 최초 렌더링 이후 실행되지 않게 된다.
무거운 연산의 예시는 localStorage,sessionStorage , map,filter,find등등 실행 비용이 많이 드는 경우 사용하는 것이 좋을 것 같다.
useEffect
대부분의 리액트 개발자들은 useEffect를 아래와 같이 생각할 것이다. (나역시)
1. useEffect는 두개의 인수를 가지며 첫번째는 콜백, 두번째는 의존성 배열이며 의존성 배열의 값이 변경될때 콜백함수를 실행한다.
2. 의존성 배열에 빈 배열을 넣으면 컴포넌트가 마운트 될때마다 실행된다.
3. 클린업 함수를 반환할 수 있으며 컴포넌트가 언마운트 될때 실행된다.
물론 위 설명이 틀린건 아니지만 완전 정확하지는 않다. useEffect는 애플리케이션 내 컴포넌트의 여러 값들을 활용하여 동기적으로 부수효과를 만드는 메커니즘이다.
부수효과가 '언제' 일어나는지보다 어떤 상태값과 실행되는지가중요하다.
보통 useEffect는 아래와 같이 사용한다.
function Component(){
useEffect(() => {
//do
},[props,state])
}
useEffect가 의존성 배열이 변경되는 것을 알고 실행할 수 있는 원리는 무엇일까? 자, 우리는 함수형 컴포넌트가 매번 함수를 실행하여 렌더링을 수행한다는 것을 알고있다.
import React, { useState } from 'react'
const Component = () => {
const [counter, setCounter] = useState(0)
function handleClick() {
setCounter((prev) => prev + 1)
}
useEffect(() => {
console.log(counter)
})
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
export default Component
간단한 카운터 예제에서 우리가 + 버튼을 누르는 순간 함수는 아래처럼 작동한다.
import React, { useState } from 'react'
const Component = () => {
counter = 1
//...
useEffect(() => {
console.log(counter) //2,3,4,5,,,,
})
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
export default Component
즉 useEffect는 옵저버,proxy,데이터 바인딩 등등 특별한 기능을 통해 변화를 관찰하는 것이 아닌 렌더링할때마다 의존성 안의 값을 확인하면서 이전과 다른게 있다면 부수효과를 실행시키는 평범한 함수이다. (그런데도 굉장히 자주 사용된다.)
클린업 함수
useEffect에서 반환되는 클린업 함수는 뭘까? 보통 클린업 함수는 이벤트를 등록,삭제할때 많이 사용된다.
import React, { useEffect, useState } from 'react'
const Component = () => {
const [counter, setCounter] = useState(0)
function handleClick() {
setCounter((prev) => prev + 1)
}
useEffect(() => {
function addMouseEvent() {
console.log(counter)
}
window.addEventListener('click', addMouseEvent)
return () => {
console.log('cleanup')
window.removeEventListener('click', addMouseEvent)
}
})
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
export default Component
위 코드를 실행하면 아래와 같이 나오게 된다.
cleanup 함수는 이전 counter값 즉 이전 state를 참조하여 실행한다. 클린업 함수는 새로운 값과 렌더링이 일어난 후에 실행된다. 클린업 함수는 새로운 값을 기반으로 렌더링 된 이후 실행되긴 하지만 변경된 값이 아닌 함수가 정의된 당시에 선언된 이전 값을 본다는 것에 주의하자.
따라서 useEffect는 콜백함수가 실행될때마다 이전의 클린업 함수가 존재한다면 클린업 함수를 먼저 실행하고 콜백을 실행하게 되기때문에 이벤트 등이 무한이 증가하는걸 막을 수 있다.
의존성 배열
의존성 배열은 빈배열,아무것도 없는 값, 사용자가 원하는 값등등 여러가지 값을 넣을 수 있다.
1. 빈배열
useEffect가 비교할 의존성이 없다고 판단하여 최초의 렌더링 이후에는 실행되지 않는다.
2. 아무런 값이 없는 경우
의존성을 비교하지 않고 렌더링 될때마다 실행된다. 보통 렌더링 유무를 확인하기 위해 사용한다.
useEffect(() => {
console.log('render!!')
})
2번에서 useEffect()를 쓰고 안쓰고가 차이가 있을까?
function Component(){
console.log('render!!')
}
function Component(){
useEffect(() => {
console.log('render!!')
})
}
차이점은 아래와 같다.
1. 서버사이드 렌더링 관점에서 useEffect()는 클라이언트 사이드에서 실행되는 것을 보장해준다.
2. useEffect내부에서는 window객체의 접근에 의존하는 코드사용이 가능하다.
3. 컴포넌트렌더링의 부수효과 이후에 실행된다. 직접실행은 컴포넌트가 렌더링 되는 중에 실행되기에 성능을 해칠 수 있다.
useEffect 구현
const MyReact = (function () {
const global = {};
let index = 0;
function useEffect(callback, dependencies) {
const hooks = global.hooks;
let previouseDependencies = hooks[index];
let isDependenciesChanged = previouseDependencies
? dependencies.some(
(val, idx) => !Object.is(val, previouseDependencies[idx])
)
: true;
if (isDependenciesChanged) callback();
hooks[index] = dependencies;
index++;
}
return { useEffect };
})();
여기서 의존성 배열의 이전 값과 현재 값은 얕은 비교를 하고 있음을 확인하자.
이전 의존성 배열과 현재 의존성 배열의 값의 변화가 있다면 callback으로 선언한 부수효과를 실행하는 구조를 가지고 있다.
사용 주의점
useEffect는 리액트 코드를 작성하는 경우 정말 많이 사용한다. 하지만 그만큼 가장 주의해야할 훅이기도 하다. 예기치 못한 버그 , 무한루프에 빠질 수 있기 때문이다.
1. eslint-disable-line react-hooks/exhaustive-eps 주석 자제
useEffect 인수 내부에서 사용하는 값 중 의존성 배열에 있지 않는 경우 경고를 발생시켜주는데 의도치 못한 버그발생의 원인이 되곤 한다.
꼭!! useEffect의 의존성 배열 내부의 값과 부수효과로 일어날 콜백함수의 행동 간의 연결고리를 잘 이어주자.
2. useEffect의 첫번째 인수에 함수명 부여
useEffect를 사용하는 많은 코드에서 useEffect의 첫 인수로 익명 함수를 부여한다. 리액트 공식문서 마찬가지다. 하지만 useEffect의 수가 많아지거나 로직이 복잡해지면 적절한 이름을 붙여주자.
useEffect(function logActivieUser() {
logging(user.id)
},[user.id],)
3. 거대한 useEffect를 만들지 말것
useEffect의 부수효과가 커질수록 성능에 악영향을 미친다. 따라서 useEffect는 최대한 간결하게 만드는것이 좋으며 만약 큰 useEffect를 만들더라도 작은 useEffect들로 분리하는것이 좋다.
의존성 배열에 여러 변수가 들어가야한다면 useCallback,useMemo등으로 정제된 내용들이 useEffect에 들어가게 하도록 하자.
4. 불필요한 외부 함수를 만들지 말것
useFfect가 실행하는 콜백또한 불필요하게 존재하면 안된다. 이 콜백이 거대해진 경우 코드가독성을 많이 해치는데 최대한 sueEffect외부 함수를 내부에 가져와서 사용하자.
useEffect 콜백으로 비동기함수를 못넣는 이유
나도 항상 궁금했던 부분이다.. 책에서는 useEffect의 인자로 비동기 함수 사용이 가능하다면 함수의 응답속도에 결과가 이상하게 나올 수 있다고 한다. useEffect에서 비동기로 함수를 호출하는 경우 경쟁상태가 나올 수 있어서 막아둔다고 한다. 물론 useEffect 안에서 비동기 함수 실행자체는 문제가 되지 않는다.
이 비동기 함수를 실행하는 경우에는 클린업 함수를 통해서 이전 비동기 함수에 대한 처리를 해주는 것이 좋다.
만약 fetch를 사용해서 API요청을 하고있다면 abortController등으로 이전 요청을 취소하도록 하자.
간단하게 정리하자면 비동기 useEffect는 state의 경쟁 상태를 야기할 수 있고 cleanup함수 실행 순서를 보장할 수 없기 때문에 만들지 않는다.
'FrontEnd > Deep Dive' 카테고리의 다른 글
[React] Deep Dive 모던 리액트(10) useContext,useReducer,기타 훅들 (1) | 2023.12.23 |
---|---|
[React] Deep Dive 모던 리액트(9) useMemo,useCallback,useRef (0) | 2023.12.22 |
[React] Deep Dive 모던 리액트(7) 렌더링 & 메모이제이션 (1) | 2023.12.20 |
[React] Deep Dive 모던 리액트(6) 클래스형&함수형 컴포넌트 (0) | 2023.12.19 |
[React] Deep Dive 모던 리액트(5) 가상 DOM & 리액트 파이버 (1) | 2023.12.18 |