이전 시간에 이어서 side effect를 해결하기 위한 방법에 대해서 이야기 해보자.
useEffect()란?
React에는 side effect를 처리하는 더 좋은 도구가 있다.
useEffect()
라는 hook으로 component함수 내부에서 실행할 수 있는 또다른 함수이다.
이 함수는 특별한 일은 하는데, useEffect()
그리고 2개의 parameter와 같이 호출된다.
- 첫 번째 parameter는 함수로, 모든 component 평가가 끝난 후에 실행되어야 한다. (주로 Arrow Function를 많이 사용함)
- 이 함수에는 어떠한 side effect 코드라도 삽입할 수 있다.
- 두 번째 prameter는 의존성으로 구성된 배열로, 의존성이 변경되는 경우마다 함수 형태의 첫 번째 parameter를 실행한다.
useEffect
는 지정된 의존성이 변경된 경우에만 실행되며, componenet가 다시 렌더링될 때는 실행되지 않는다.
지정된 의존성이 변경될 때만 실행된다는 것은 변경되지 않는다면 React가 component를 렌더링할 때 같이 실행하지 않는다는 것을 의미한다.
따라서, 첫 번째 함수에 어떤 side effect 코드라도 넣을 수 있게 된다.
주로, 시간을 많이 잡아먹는 코드들을 중심으로 활용하면 좋을 것 같다.
의존성 추가 방법
의존성 같은 경우에는, 만약 앱을 처음 실행했다면 의존성이 변경된 것을 간주한다.
또한 의존성을 비워서 사용하면, 의존성이 없는 것으로 useEffect
로 감싸져 있더라도 component가 렌더링될 때마다 같이 실행된다.
그러면 어떻게 의존성을 추가하느냐.
const [enteredEmail, setEnteredEmail] = useState('');
const [enteredPassword, setEnteredPassword] = useState('');
const [formIsValid, setFormIsValid] = useState(false);
useEffect(() => {
setFormIsValid(
enteredEmail.includes('@') && enteredPassword.trim().length > 6
);
}, [
setFormIsValid, enteredEmail, enteredPassword
]) ;
위와 같이 함수를 구성하는 요소들로 채울 수 있다.
이때 중요한 것은 setFormVaild
는 실행되어서는 안된다. 여기서 실행하면 그 실행 결과가 의존성으로 추가된다.
따라서, 함수의 포인터를 추가해서 본질적으로 함수, 그 자체를 의존성으로 추가한다.
즉, React에게 모든 로그인 component 함수 실행 후에 이 useEffect()
함수를 다시 실행하는 데, 마지막 component 렌더링 주기에서 3개의 의존성 중 하나라도 변경된 경우에만 실행하라고 말하는 것이다.
만일 셋 중에 바뀐게 하나도 없다면 이 useEffect()
의 함수는 다시 실행되지 않는다.
사실 setFormIsValid
는 의존성에서 생략할 수 있긴 하다.
이러한 state의 업데이트 함수는 기본적으로 React에 의해 절대 변경되지 않도록 보장되기 때문에 재렌더링 주기에 따라 변하지 않는다.
이와 같이 useEffect()
함수는 "모든 것"을 의존성으로 추가(모든 state 변수와 함수도 포함)해야하지만 몇 가지 예외사항이 존재한다:)
의존성 배열의 예외 사항
- 상태 업데이트 함수를 추가할 필요가 없다. [eq.
setFormIsValid
]: React는 해당 함수가 절대 변경되지 않도록 보장하므로 의존성으로 추가할 필요가 없다. - "내장" API 또는 함수를 추가할 필요가 없다. [eq.
fetch()
,localStorage
, ...]: 브라우저에 내장되어 전역적으로 사용가능한 API 및 함수들은 React component 렌더링 주기와 관련이 없으며 변경되지 않는다. - component 외부에서 정의된 변수나 함수를 추가할 필요가 없다. [eq. 별도의 파일에 새 도우미 함수를 만드는 경우]: 이러한 함수 또는 변수도 component 함수 내부에서 생성되지 않으므로 변경해도 component에 영향을 주지 않는다. 해당 변수가 변경되는 경우, 또는 그 반대의 경우에도 component는 재평가되지 않는다.
간단히 말해서, useEffect()
함수에서 사용하는 모든 "것들"을 추가해야 한다. component(또는 일부 상위 component)가 다시 렌더링 되어 이러한 "것들"이 변경될 수 있는 경우에 component 함수에 정의된 변수나 state, props, 함수는 의존성에 포함되어야 한다.
다음은 위에서 언급한 시나리오를 더 명확히 하기 위해 구성된 예시다.
import { useEffect, useState } from 'react';
let myTimer;
const MyComponent = (props) => {
const [timerIsActive, setTimerIsActive] = useState(false);
const { timerDuration } = props; // using destructuring to pull out specific props values
useEffect(() => {
if (!timerIsActive) {
setTimerIsActive(true);
myTimer = setTimeout(() => {
setTimerIsActive(false);
}, timerDuration);
}
}, [timerIsActive, timerDuration]);
};
코드 설명
timerIsActive
는 의존성으로 추가되었다. component가 변경될 때, 즉 state가 업데이트 되었을 때, 변경될 수 있는 state이다.timerDuration
은 의존성으로 추가되었다. component의 property 값이기 때문이다. 따라서 상위 component가 해당 값을 변경하면, 같이 변경될 수 있다.(하위 component도 다시 렌더링되도록 함).setTimerIsActive
는 의존성으로 추가되지 않았다. 예외조건이기 때문에 state 업데이트 기능을 추가할 수 있지만, React는 함수 자체가 절대 변경되지 않음을 보장하므로 추가할 필요가 없다.myTimer
는 의존성으로 추가되지 않는다. component 내부 변수가 아니며 외부에서 정의되고 변경되는 변수로 component가 다시 평가되도록 하지 않는다.setTimeout
은 의존성으로 추가되지 않는다. 브라우저에 내장된 API이기 때문에 React, component와 독립적이며 변경되지 않는다.
기존 방식
import React, { useState, useEffect } from 'react';
import Card from '../UI/Card/Card';
import classes from './Login.module.css';
import Button from '../UI/Button/Button';
const Login = (props) => {
const [enteredEmail, setEnteredEmail] = useState('');
const [emailIsValid, setEmailIsValid] = useState();
const [enteredPassword, setEnteredPassword] = useState('');
const [passwordIsValid, setPasswordIsValid] = useState();
const [formIsValid, setFormIsValid] = useState(false);
useEffect(() => {
setFormIsValid(
enteredEmail.includes('@') && enteredPassword.trim().length > 6
);
}, [enteredEmail, enteredPassword]) ;
const emailChangeHandler = (event) => {
setEnteredEmail(event.target.value);
};
const passwordChangeHandler = (event) => {
setEnteredPassword(event.target.value);
};
const validateEmailHandler = () => {
setEmailIsValid(enteredEmail.includes('@'));
};
const validatePasswordHandler = () => {
setPasswordIsValid(enteredPassword.trim().length > 6);
};
const submitHandler = (event) => {
event.preventDefault();
props.onLogin(enteredEmail, enteredPassword);
};
return (
<Card className={classes.login}>
<form onSubmit={submitHandler}>
<div
className={`${classes.control} ${
emailIsValid === false ? classes.invalid : ''
}`}
>
<label htmlFor="email">E-Mail</label>
<input
type="email"
id="email"
value={enteredEmail}
onChange={emailChangeHandler}
onBlur={validateEmailHandler}
/>
</div>
<div
className={`${classes.control} ${
passwordIsValid === false ? classes.invalid : ''
}`}
>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={enteredPassword}
onChange={passwordChangeHandler}
onBlur={validatePasswordHandler}
/>
</div>
<div className={classes.actions}>
<Button type="submit" className={classes.btn} disabled={!formIsValid}>
Login
</Button>
</div>
</form>
</Card>
);
};
export default Login;
그런데 위와 같이 사용자 인풋이 변경될 때마다 valid를 검사하는 것은 옳지 못하다.
만약 추가적으로 http request를 보낸다고 했을 때, 사용자 인풋이 변경될 때마다 request를 보내는 것이니 불필요한 네트워크 트래픽을 생산할 것이다. 이런 경우는 당연히 피해야겠다.
대신, 일정량의 키 입력을 수집하거나 키 입력 후 일정 시간 동안 일시 중지되는 것을 기다리는 것은 할 수 있다.
중지 시간이 충분하다면 무슨 일이든 할 수 있을 것이다.
위의 예를 들어, 사용자가 빠르게 타이핑하는 동안에 이메일 주소가 유효한지 확인하고 싶지 않을 것이다.
따라서, 사용자의 타이핑이 멈출 때를 기다린다. 그럼 500ms 이상 멈춘다면 그제서야 확인해보자.
사용자가 다 입력한 것 같으니, 유효한지 확인하자는 것이다. (비밀번호도 동일하게)
이런 작업을 디바운스(debounce)이라고 하는 기술이다.
Debounce
Debounce는 event를 그룹화하여 특정시간이 지난 후에 마지막 event만 실행하도록 하는 기술이다.
즉, 순차적으로 호출되었다면 하나로 "그룹화"할 수 있다.
앞선 코드를 수정하여 단지 키가 입력될 때마다 뭔가를 하는 것이 아닌, 사용자가 타이핑을 일시 중지했을 때 실행하도록 구현하고 싶다.
이런 동작은 useEffect
와 setTimeout()
을 사용하면 구현하기 용이하다. setTimeout()
은 브라우저 내장 함수로 react에 영향을 받지 않는다.
setFormIsValid()
를 setTimeout()
안에 사용해서 500ms정도 후에 작업을 수행하게 만들어보자.
useEffect(() => {
setTimeout(() => {
console.log("Checking form validity!") ;
setFormIsValid(
enteredEmail.includes("@") && enteredPassword.trim().length > 6
);
}, 500);
}, [enteredEmail, enteredPassword]);
하지만, 위의 코드대로 제작하여 log를 찍어보면 그다지 효과가 없어보일지도 모른다.
그저 500ms 정도 지연되서 결과가 보인다는 것뿐이다. 왜냐하면 모든 키 입력에 대해서 타이머를 지정했기 때문이다.
사용자의 입력이 들어와도 500ms 안에 다른 입력이 들어온다면 기존의 타이머는 지우고 새로운 타이머를 저장하도록 해야한다.
즉, 마지막 타이머가 완료될 때만 실행되게 만들어야 할 것이다.
CleanUp function
앞서서 그룹화(Debounce)를 제대로 구현하기 위해서, 기존의 useEffect()
내부 함수에 return
을 추가해서 함수를 반환하게 해보자.
예를 들어 Arror Function으로, 물론 이때 정의된 함수를 써도 무관하다.
이 함수를 cleanUp 함수라고 하는데 useEffect()
는 처음 실행되는 경우를 제외하고 재실행되기 전에 cleanUp 함수가 실행된다.
이 cleanUp 함수는 특정한 component가 DOM에서 mount가 해제될 때마다 실행된다. 즉, component가 재사용될 때마다 실행된다.
모든 새로운 side effect 함수가 실행되기 전에, component가 제거되기 전에 실행된다.
그리고 첫 번째 side effect 함수가 실행되기 전에 실행되지 않으며 그 이후에 실행된다.
이해를 돕기 위해 아래 코드를 살펴보자.
useEffect(() => {
const identifier = setTimeout(() => {
console.log("Checking form validity!") ;
setFormIsValid(
enteredEmail.includes("@") && enteredPassword.trim().length > 6
);
}, 500);
return () => { // cleanUp function
console.log('CLEANUP') ;
clearTimeout(identifier) ;
} ;
}, [enteredEmail, enteredPassword]);
기본적으로 브라우저 내장 함수인 clearTimeout()
을 사용하여 이 timeout의 identifier
를 전달한다.
이렇게 하면 가장 최근의 cleanUp 함수가 실행될 때마다 이전에 설정된 타이머들을 제거하며 새로운 타이머를 지정하게 된다.
즉, 새로운 타이머를 설정하기 전에 마지막 타이머를 지우는 과정이다.
아래의 Gif를 살펴보면 console.log()
가 찍히는 것으로 이해할 수 있다.
결과적으로, setTimeout()
안에 있는 코드는 사용자의 타이핑이 종료되고 500ms 이후에 마지막 한번만 실행되는 것을 볼 수 있다.
(물론 페이지를 새로고침 했을 때 한번은 무조건 실행된다.)
만약 이 동작이 http request를 보내는 동작이었다면 수십번이 아니라 단 한번만 보내게 됬을 것이다.
이 예제는 cleanUp 함수를 언제 어떻게 써야하는 가를 잘 보여주는 예이기도 하다.
의존성 설정 시 주의할 점
추가적으로 한가지를 더보자 (2023.02.02 추가됨)
useReducer()
에 대해서 다루다 enteredEmail
과 emailIsValid
state가 emailState
의 객체로 변경되었다.
때문에 코드를 다음과 같이 변경해주었다. 왜? 어떠한 까닭으로 바꿨는지 의문이 들지도 모른다.
useEffect()
안에 들어가는 의존성은 항상 내부에서 사용되는 변수로 작성해줘야한다.
그럼 emailState
나 passwordState
로 하면 안되나..고 생각할 수 있다.
나 또한 그렇게 생각했으나, state 자체가 객체 형태라면, 전체 객체 대신에 특정 속성을 의존성으로 전달해야한다.
왜냐하면 useEffect()
함수는 해당 객체가 변경될 때마다 재실행될 것이기 때문이다.
특히나 객체가 가진 한 속성 값만을 필요로 한다면, 전체 객체로 의존성을 전달한 경우 잦은 업데이트로 인한 잦은 실행이 발생할 수 있으며, 이렇게 되는 경우 useEffect()
를 사용해서 얻는 이점이 사라진다.
const { isValid : emailIsValid } = emailState ;
const { isValid : passwordIsValid } = passwordState ;
useEffect(() => {
const identifier = setTimeout(() => {
console.log("Checking form validity!");
setFormIsValid(emailIsValid && passwordIsValid);
}, 500);
return () => {
console.log("CLEANUP");
clearTimeout(identifier);
};
}, [emailIsValid, passwordIsValid]); // [emailState, passwordState]);
정리
useEffect()
는 useState()
이외에 가장 중요한 React hook이기 때문에 확실히 이해할 필요성이 있다.
useEffect()
의 어떤 부분이 시작되고 실행되는지 정리해보자.
useEffect(() => {
console.log('EFFECT RUNNING') ;
}) ;
위와 같이 첫번째 parameter만 사용해도 무관하다. 이런 경우에 의존성에 대한 정보가 없으므로 state가 업데이트될 때마다 실행된다.
물론 useEffect를 이런 식으로는 사용하지 않는다.
useEffect(() => {
console.log('EFFECT RUNNING') ;
}, []) ;
이제 이 함수는 component가 처음 mount되고 렌더링될 때만 실행된다.
그러나 그 이후 component가 다시 렌더링되어도 실행되지 않는다.
component가 마운트되는 것 이외에, 아무런 의존성이 없기 때문이다.
useEffect(() => {
console.log('EFFECT RUNNING') ;
}, [enteredPassword]) ;
또한, 이렇게 의존성을 추가할 수도 있다.
위에서 살펴본 몇 가지 예외사항을 제외하고 말이다.
이제 이 함수는 enteredPassword가 변경될 때마다 실행될 것이다.