
실제 리액트의 코드는 이와 다릅니다.
제가 작성한 코드는 학습하기 위해 작성한 코드임을 미리 알립니다!
전체 코드는 깃허브 에서 확인 해보실 수 있습니다.
공식문서에서는 아래와 같이 설명하고 있습니다.



위 내용을 정리하면 아래와 같습니다.
1. useEffect는 모든 렌더링이 종료된 후에 실행된다.
2. 의존성 배열이 없다면 렌더링 후에 실행된다.
3. 의존성 배열이 비었다면 마운트될 때 실행된다.
4. 의존성 배열이 들어있다면 마운트될 때, 의존성 배열의 값중 하나라도 변경되면 실행된다.
여기에 추가적으로 컴포넌트가 언마운트될 때 특정 작업을 하려면 클린업 함수를 아래처럼 작성해 주면 됩니다!
useEffect(()=>{
// 클린업
return () => {...};
});
만약 소켓을 연결하거나 이벤트 리스너를 추가하는 등의 행위를 useEffect 안에서 했다면 연결을 끊어줄 때 클린업을 해주면 됩니다. 그렇지 않으면 메모리 누수등의 문제가 발생할 수 있습니다.
이제 위 내용을 토대로 useEffect를 구현할 차례입니다.
이전글처럼 idx를 기반으로 구현하기에 아래처럼 조건에 따라 컴포넌트를 렌더링하면 문제가 발생합니다. 그런데 이 문제 역시 모든 부분을 구현하고 리액트 코드를 살펴본 이후 해결해 볼 예정입니다.
// App.tsx
function App() {
const [isShowComponent1, setIsShowComponent1] = useState(false);
return (
<>
{isShowComponent1 ? <Component1/> : <Component2/>
</>
);
}
위 내용들을 토대로 useEffect 함수를 만들어보면 아래와 같은 모습을 갖습니다.
const effects: { deps?: unknown[] }[] = [];
let effectsArrayAfterRender: { callback: () => void; isRuned?: boolean }[] = [];
let effectsIdx = 0;
export function runSideEffects(): void {
effectsArrayAfterRender.forEach((effect) => {
// 이미 실행된 effect라면 실행하지 않음
if (effect?.isRuned) return;
effect.isRuned = true;
effect.callback();
});
effectsArrayAfterRender = [];
}
export default function useEffect(callback: () => void, deps?: unknown[]) {
// 이전 deps와 현재 deps가 다른지 비교하는 함수
const isDifferentDeps = (dep: unknown, idx: number) => dep !== deps?.[idx];
// 최초 실행시에만 effects에 넣어줌.
if (effects.length === effectsIdx) {
effects.push({ deps });
effectsArrayAfterRender.push({ callback });
}
// deps를 넣지 않았거나 ||
// 이전 effect와 비교했을 때 deps의 값중 하나라도 다르다면
else if (!deps || effects[effectsIdx]?.deps?.some(isDifferentDeps)) {
effectsArrayAfterRender.push({ callback });
effects[effectsIdx] = { deps };
}
effectsIdx++;
}
export function resetEffectIdx() {
effectsIdx = 0;
}
const effects: { deps?: unknown[] }[] = [];
let effectsArrayAfterRender: { callback: () => void; isRuned?: boolean }[] = [];
let effectsIdx = 0;
effects는 useEffect가 처음 실행될 때 마다 여기에 넣어줍니다.
그리고 effect의 deps가 변경될 경우 이 배열 특정 idx의 deps를 변경 해 줍니다.
effectsArrayAfterRender는 렌더링이 종료된 후 실행될 effect들을 모아둔 배열입니다.
useEffect를 호출할 때 deps를 넣어주지 않거나, 변경될 경우 이 배열에 추가됩니다.
마지막으로 effectsIdx는 useEffect를 호출할 때마다 1씩 증가합니다.
if (effects.length === effectsIdx) {
effects.push({ deps });
effectsArrayAfterRender.push({ callback });
}
useState를 구현할때와 비슷합니다.
최초로 실행된 useEffect일 경우 effects에 넣어줍니다.
추가적으로 의존성 배열과 관계없이 effectsArrayAfterRender에 넣어 렌더링이 끝나면 실행될 수 있도록 해줍니다.
else if (!deps || effects[effectsIdx]?.deps?.some(isDifferentDeps)) {
effectsArrayAfterRender.push({ callback });
effects[effectsIdx] = { deps };
}
위의 else if문은 deps가 없거나(모든 렌더링때 실행) deps가 변경되었을 때 effectsArrayAfterRender에 넣어 함수들을 렌더링 후 실행해줍니다.
export function runSideEffects(): void {
effectsArrayAfterRender.forEach((effect) => {
// 이미 실행된 effect라면 실행하지 않음
if (effect?.isRuned) return;
effect.isRuned = true;
effect.callback();
});
effectsArrayAfterRender = [];
}
보면 isRuned가 true일 경우 실행하지 않도록 하고있습니다.
이유는 useEffect 안에서 state가 변경되면 아래와 같이 계속 순환됩니다.
useEffect 실행 -> beRunEffects에 등록 -> mounted -> beRunEffects실행 -> state변경으로 인한 리렌더링 ->
useEffect 실행 -> .... -> ... -> ...
그래서 이 순환고리를 끊어주기 위해서 effect를 실행하기전 체크를 해주고, 다음 주기에 해당 effect가 실행됐다면 해당 effect는 실행하지 않습니다.
맨 위에서 useEffect는 렌더링이 끝나고 실행 되어야 한다고 했습니다.
이를 위해서 useState에서 setState시 리렌더링 하는 부분을 함수로 분리 해주도록 하겠습니다.
export function render() {
// 리렌더링
const root = document.querySelector("#root");
const dom = React.createDOM(<App />);
root?.replaceChildren(dom);
// useEffect를 통해 등록된 함수들을 실행
runSideEffects();
}
render함수가 실행되기 전에 idx를 초기화 해 주어야 하기때문에 resetStateIdx 함수를 추가적으로 작성 해 주었습니다.
...
function useState<T>(initialState: T): [T, SetState<T>] {
...
const setState = (newState: T | SetStateFunction<T>) => {
...
render();
};
...
}
...
export function resetStateIdx() {
stateIdx = 0;
}
리렌더링 하기 전에 useState와 useEffect의 idx들을 0으로 초기화 해주어야 정상적으로 작동합니다. 그러지 않으면 계속 배열의 idx가 증가해 이전 값을 가져올 수 없습니다.
export function render() {
resetStateIdx();
resetEffectIdx();
...
}
제대로 되는지 확인을 해볼려면 아래 세가지를 확인 해보면 됩니다.
기존 App.tsx의 코드를 조금 추가해줍니다.
...
function App() {
const [isCounter1Show, setIsCounter1Show] = useState(true);
const toggleCounter = () => setIsCounter1Show((prev) => !prev);
useEffect(() => {
console.log("every render");
});
useEffect(() => {
console.log("only first render");
}, []);
useEffect(() => {
console.log("only first render and counter1 change");
}, [isCounter1Show]);
return (
<div>
<button onclick={toggleCounter}>카운터 토글</button>
{isCounter1Show ? <Counter1 /> : <Counter2 />}
</div>
);
}
이제 이 코드를 테스트 해보면 아래와 같이 의도한대로 잘 작동하는걸 확인할 수 있습니다!

이제 중요하다고 생각하는 훅의 두가지가 마무리 되었습니다.
다음 글에서는 useRef 훅을 간단하게 구현한 후에
마지막으로 렌더링 최적화 하는 글에서 구현 해보기 시리즈는 마무리 하려고 합니다.
그 다음 시리즈에서 리액트를 구현 해보며 발생했던 문제들을
리액트의 코드를 살펴보며 해결하는 시간을 가져보도록 하겠습니다.
https://ko.react.dev/reference/react/useEffect
https://ko.react.dev/learn/synchronizing-with-effects