side effect는 앱이 제대로 동작하기 위해 실행되어야 하지만 현재의 컴포넌트 렌더링 과정에 직접적인 영향을 미치지 않는 작업을 의미한다.
side effect를 다루는 과정에서 흔하게 발생할 수 있는 문제는 무한루프 문제가 있다.
navigator.geolocation.getCurrentPosition((position) => {
const sortedPlaces = sortPlacesByDistance(
AVAILABLE_PLACES,
position.coords.latitude,
position.coords.longitude
)
});
예를들어 위와 같은 코드는 side effect에 해당한다.
왜냐하면 모든 컴포넌트 함수의 주된 목적은 렌더링 가능한 jsx코드를 반환하는 것인데, 위의 코드는 그러한 작업과 직접적으로 연관되어 있지 않기 때문이다.
하지만 위의 작업은 즉각적으로 완료되지 않기 때문에 위의 작업이 완료되는 시점은 해당 컴포넌트가 모두 실행된 이후이다.
따라서 sortedPlaces를 통해 화면을 업데이트하기 위해서는 다음과 같이 useState를 사용하여 상태를 변화시켜야 한다.
const [availablePlaces, setAvailablePlaces] = useState<PlaceType[]>([]);
navigator.geolocation.getCurrentPosition((position) => {
const sortedPlaces = sortPlacesByDistance(
AVAILABLE_PLACES,
position.coords.latitude,
position.coords.longitude
);
setAvailablePlaces(sortedPlaces);
});
하지만 위의 코드는 무한루프를 일으키게 된다.
왜냐하면 setAvailablePlaces를 통한 상태 업데이트는 해당 상태를 사용하는 컴포넌트를 재실행 하라는 것과 같고, 컴포넌트가 재실행되면 navigator를 사용한 코드가 다시 실행되기 때문이다.
navigator.geolocation.getCurrentPosition()실행 -> setAvailablePlaces()를 통한 상태 업데이터 -> 컴포넌트 재실행 -> navigator.geolocation.getCurrentPosition()실행 ... 의 과정이 무한반복하게 된다.
이러한 경우 useEffect를 사용하여 해결할 수 있다.
useEffect의 기본 형태는 다음과 같다.
useEffect(()=>{
...
...
}, dependencies)
기본적으로 useEffect의 첫번째 인수로 전달된 함수는 매번 컴포넌트가 실행된 이후에 실행된다.
컴포넌트의 실행이 모두 완료된 후, 즉 jsx코드가 반환된 후 시점에서 useEffect 내부의 함수가 실행된다.
또한 useEffect 내부의 함수가 재실행되는 시점은 두번째 인수로 전달된 dependencies 달려있다.
두번째 인수인 의존성 배열의 값이 변화했을 경우에 한해 useEffect는 재실행 된다.
의존성 배열을 빈 배열로 놓을 경우 해당 useEffect는 한번만 실행되며, 만약 useEffect의 두번째 인수로 아무것도 넣지 않는다면 매 렌더링마다 재실행된다.
이전에 Modal을 구현할때 다음과 같이 코드를 작성하였다.
import { ReactNode, Ref, useImperativeHandle, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
ref?: Ref<HandleModal>;
children: ReactNode;
}
export interface HandleModal {
open: () => void;
close: () => void;
}
const Modal = ({ ref, children }: ModalProps) => {
const dialog = useRef<HTMLDialogElement>(null);
useImperativeHandle(ref, () => {
return {
open: () => {
dialog.current?.showModal();
},
close: () => {
dialog.current?.close();
}
};
});
const modalElement = document.getElementById("modal");
if (!modalElement) return null;
return createPortal(
<dialog className="modal" ref={dialog}>
{children}
</dialog>,
modalElement
);
};
export default Modal;
위의 코드에서는 ref와 useImperativeHandle을 사용하여 외부에서 모달을 사용할 때 showModal()과 close()를 각각 open(), close()라는 메서드로 호출할 수 있게 구현하였다.
하지만 위의 코드를 useEffect를 사용하여 더 간단하게 같은 동작을 하도록 구현할 수 있다.
import { ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
open: boolean;
children: ReactNode;
onClose: () => void;
}
const Modal = ({ open, onClose, children }: ModalProps) => {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (open) {
dialog.current.showModal();
} else {
dialog.current.close();
}
}, [open]);
const modalElement = document.getElementById("modal");
if (!modalElement) return null;
return createPortal(
<dialog className="modal" ref={dialog} onClose={onClose}>
{open ? children : null}
</dialog>,
modalElement
);
};
export default Modal;
위의 코드는 Modal의 props으로 boolean타입의 open과 함수타입의 onClose를 받고있다.
Modal컴포넌트를 호출할 때 넘겨준 open의 값이 true인 경우 useRef로 dialog태그에 연결된 dialog의 showModal()을 호출하고, false인 경우 close()를 호출한다.
그리고 해당 Modal을 사용하는 쪽에서는 다음과 같은 코드로 Modal의 open상태를 조절한다.
const [modalIsOpen, setModalIsOpen] = useState(false);
...
...
function handleStartRemovePlace(id: PlaceType["id"]) {
setModalIsOpen(true);
...
}
function handleStopRemovePlace() {
setModalIsOpen(false);
}
...
...
<Modal open={modalIsOpen} onClose={handleStopRemovePlace}>
{children}
</Modal>
handleStartRemovePlace을 통해 modalIsOpen가 true가 될 경우에는 Modal이 열리고, handleStopRemovePlace를 통해 modalIsOpen가 false가 될 경우에는 Modal이 닫힌다.
onClose를 통해 handleStopRemovePlace를 연결해준 이유는 esc를 통해 모달이 닫을 경우에 modalIsOpen의 상태를 close로 바꾸기 위해서이다.
위의 상황에서 useEffect가 사용된 이유는 무엇일까?
만약 useEffect를 사용하지 않고 다음과 같이 코드를 구현하면 어떻게 될까?
import { ReactNode, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
open: boolean;
children: ReactNode;
onClose: () => void;
}
const Modal = ({ open, onClose, children }: ModalProps) => {
const dialog = useRef<HTMLDialogElement>(null);
if (open) {
dialog.current.showModal();
} else {
dialog.current.close();
}
const modalElement = document.getElementById("modal");
if (!modalElement) return null;
return createPortal(
<dialog className="modal" ref={dialog} onClose={onClose}>
{open ? children : null}
</dialog>,
modalElement
);
};
export default Modal;
Uncaught TypeError: Cannot read properties of null (reading 'close') at Modal 과 같은 에러가 발생한다.
이러한 오류가 발생하는 이유는 처음 컴포넌트가 실행될 경우 if문 내부에서 dialog를 접근할 시점에는 useRef를 사용한 dialog가 DOM API와 연결이 되어있지 않기 때문에 에러가 발생한다.
하지만 useEffect를 사용할 경우 if문이 처음 실행되는 시점은 컴포넌트가 실행된 이후기 때문에 해당 시점에는 ref가 연결되어 에러가 발생하지 않는다.
중요
만약 typescript를 사용한다면 'dialog.current'은(는) 'null'일 수 있습니다.와 같은 오류코드를 통해 옵셔널체이닝을 사용하여 위와 같은 오류가 발생하지 않도록 구현할수도 있다.
if (open) {
dialog.current.showModal();
} else {
dialog.current.close();
}
위의 코드를 옵셔널 체이닝을 사용해 아래와 같이 구현할 경우
Uncaught TypeError: Cannot read properties of null (reading 'close') at Modal
에러는 발생하지 않는다.
if (open) {
dialog.current?.showModal();
} else {
dialog.current?.close();
}
useEffect를 통해 타이머를 관리하는것도 가능하다.
Modal의 children으로 넘겨주는 컴포넌트에서 setTimeout과 setInterval과 같은 타이머를 사용할 경우 문제가 발생할 수 있다.
왜냐하면 Modal의 경우 DOM에는 존재하지만 화면에는 보이지 않는 상태이기 때문에 처음 컴포넌트가 실행되는 시점에서 타이머가 등록되어진다.
이러한 경우 모달의 내부에서 다음과 같이 조건적으로 렌더링을 하면 해결할 수 있지만, 타이머를 멈출 수 없다는 문제점이 발생한다.
<dialog className="modal" ref={dialog} onClose={onClose}>
{open ? children : null}
</dialog>
이러한 경우도 useEffect를 사용하여 해결할 수 있다.
useEffect(() => {
console.log("TIMER SET");
const timer = setTimeout(() => {
onConfirm();
}, 3000);
return () => {
console.log("Cleaning up timer");
clearTimeout(timer);
};
}, [onConfirm]);
위의 코드는 setTimeout을 통해 3초 뒤에 onConfirm()함수를 호출하는 timer를 useEffect안에서 사용한 코드이다.
useEffect의 콜백함수 내부를 보면 return을 사용하여 함수를 반환하는 것을 볼 수 있는데, 이 부분을 cleanup함수라 한다.
해당 cleanup함수는 해당 컴포넌트가 리렌더링 될 경우 이전의 cleanup함수가 실행되며, DOM에서 사라지기 직전에도 실행되는 함수이다.
컴포넌트가 사라질 때 clearTimeout을 통해 타이머를 종료시키는 역할을 수행한다.
위의 코드에서는 의존성 배열에 onConfirm이라는 함수를 넣어주고 있는데, javascript에서 함수는 객체이므로 오류가 발생할 수 있다.
왜냐하면 함수는 컴포넌트가 실행될 때 다시 생성되며, 내부 구조는 동일해도 객체의 주소값이 다르기 때문에 이전과 다른 값으로 인식되기 때문이다.
이러한 객체를 의존성 배열에 담는 경우 useCallback을 사용하여 오류를 방지할 수 있다.
useCallback의 형태는 다음과 같다.
const cachedFn = useCallback(fn, dependencies)
useCallback은 리렌더링 간에 함수 정의를 캐싱해 주는 hook이며, useEffect와 마찬가지로 첫번째 인자로는 함수, 두번째 인자로는 dependencies를 가진다.
dependencies의 변화가 없는 경우 첫번째 인자로 들어간 함수는 cache되어 리렌더링 시에도 새로 생성되지 않는다.