Search
2️⃣

fix: strict mode에서 useUpdateEffect가 정상적으로 동작 하지않는 버그 수정 (feat. 리액트 19 변경사항)

PR링크
레포
react-use
상태
PR 업로드
유형
동작 수정

레포지토리 설명

react 프로젝트에서 사용하기 좋은 다양한 hook을 제공하는 라이브러리

ISSUE링크

2611
issues

PR링크

문제 상황

onMount 상황을 제외하고 호출되는 useEffect인 useUpdateEffect 에 대한 이슈

원인 파악

정확한 확인을 위해 로컬에서 CRA를 사용해 리액트 프로젝트를 재현 후 체크
react, react-dom이 18버전인 경우 onMount 시 호출되지 않음
react, react-dom이 19버전인 경우 onMount 시 두 번 호출 됨
원인을 좁히기 위해 useUpdateEffect 의 코드를 살펴보았다
// react-use/src/useUpdateEffect.ts import { useEffect } from 'react'; import { useFirstMountState } from './useFirstMountState'; const useUpdateEffect: typeof useEffect = (effect, deps) => { const isFirstMount = useFirstMountState(); // 첫 번째 마운트 여부 확인 useEffect(() => { if (!isFirstMount) { / 첫 번째 마운트가 아닐 경우에만 effect 실행 return effect(); } }, deps); // 의존성 배열 deps가 변경될 때 실행됨 }; export default useUpdateEffect;
JavaScript
복사
// react-use/src/useFirstMountState.ts import { useRef } from 'react'; export function useFirstMountState(): boolean { const isFirst = useRef(true); // 첫번째 랜더링 유뮤를 판별 if (isFirst.current) { // isFirst.current 값이 true라면 (첫 번째 렌더링 시) isFirst.current = false; // 첫 번째 렌더링 후, false로 변경 return true; // 첫 번째 렌더링이면 true 반환 } return isFirst.current; // 이후 렌더링에서는 항상 false 반환 }
JavaScript
복사
useRef를 통해 첫번째 랜더링 여부를 관리하고 useEffect에서 분기처리를 하고 있었다
리액트 19 업데이트 문서를 보면서 useRef , useEffect 변경사항 위주로 살펴보았다
직접 언급 된 변경사항은 없어 문서를 읽던 중 strict mode 내용이 눈에 들어왔다

StrictMode changes

React 19 includes several fixes and improvements to Strict Mode.
When double rendering in Strict Mode in development, useMemo and useCallback will reuse the memoized results from the first render during the second render. Components that are already Strict Mode compatible should not notice a difference in behavior.
As with all Strict Mode behaviors, these features are designed to proactively surface bugs in your components during development so you can fix them before they are shipped to production. For example, during development, Strict Mode will double-invoke ref callback functions on initial mount, to simulate what happens when a mounted component is replaced by a Suspense fallback.
변경사항이 있다면 두 번 호출되는 부분strict mode 의 동작을 의심할만하다는 생각이 들었다
단순히 react 19 뿐만 아니라 strict mode 사용이 이슈의 발동조건이었다!

해결방안

ahook에는 이슈에서 언급 된 것처럼 동일한 기능을 하는 useUpdateEffect 훅이 있었다
use-hook과 비슷한 기능을 제공하지만 useEffect를 사용해 구현하여 19에서도 문제가 없었다.
첫번째 랜더링 여부를 바꾸어주는 로직이 useEffect 안에 존재하면서 문제가 해결된 것으로 보였다
의존성 배열에 [] 를 사용해 한 번만 동작하는 것을 더 확실히 보장하거나
useEffect의 특성상 타이밍이 달라지면서인 것으로 생각되었다
더 정확히 18 ver. 와 19 ver. 에서의 동작 차이를 보기 위해 콘솔을 찍어 보았다
// src/useUpdateEffect import { useEffect } from 'react'; import { useFirstMountState } from './useFirstMountState'; const useUpdateEffect: typeof useEffect = (effect, deps) => { const isFirstMount = useFirstMountState(); console.log('update', isFirstMount); useEffect(() => { console.log('update-useEffect', isFirstMount); if (!isFirstMount) { return effect(); } }, deps); }; export default useUpdateEffect;
JavaScript
복사
import { useRef } from 'react'; export function useFirstMountState(): boolean { const isFirst = useRef(true); if (isFirst.current) { isFirst.current = false; console.log('first-if', isFirst.current); return true; } return isFirst.current; }
JavaScript
복사
import { useLayoutEffect, useState } from 'react'; import { useUpdateEffect } from 'react-use'; function App() { console.log('App rendered'); const [count, setCount] = useState(0); useUpdateEffect(() => { console.warn('useUpdateEffect triggered'); }, [count]); useLayoutEffect(() => { console.log('useLayoutEffect: DOM updated'); }); return ( <button onClick={() => setCount((count) => count + 1)}> count is {count} </button> ); } export default App;
JavaScript
복사
수정 전 코드에 대한 리액트 버전별 차이
수정 전 (React 18 + strict mode)
수정 전 (React 19 + strict mode)
수정 후 코드에 대한 리액트 버전별 차이
import { useRef, useEffect } from 'react'; export function useFirstMountState(): boolean { const isFirst = useRef(true); useEffect(() => { isFirst.current = false; console.log('first-useEffect', isFirst.current); return () => { isFirst.current = true; }; }, []); return isFirst.current }
JavaScript
복사
수정 후 (React 18 + strict mode)
수정 후 (React 19 + strict mode)
수정을 통해 strict mode에서도 동일하게 동작하도록 수정을 완료 하였다

추가 탐구

ahook의 코드을 참고하여 쉽게 힌트를 얻기는 했지만, 정확한 원인은 이해가 잘 되지 않았다
ahook 코드의 git blame을 보면 3년전 수정 내용으로, 특별히 react 19에 맞춘 수정은 없었다
수정 전 (React 19 + strict mode)
또한, 리액트 19에서의 두번째 실행의 콘솔을 보면 첫번째 랜더링 시에 상황이 이상하였다
첫번째 랜더링 여부를 나타내는 isFirstMount 가 이미 false 로 시작되고
첫번째 랜더링처럼 useFirstMountState 내부에 있는 first-if 콘솔이 실행되지도 않았다
첫번째와 두번째 실행이 정확하게 동일한 콘솔을 보여주는 리액트 18과 완전히 다른 모습이다
수정 전 (React 18 + strict mode)
업데이트 문서에는 간략하게만 적혀있어 리액트 19.0.0의 릴리즈 노트를 살펴보았다.
Notable Changes
StrictMode changes: useMemo and useCallback will now reuse the memoized results from the first render, during the second render. Additionally, StrictMode will now double-invoke ref callback functions on initial mount.
All Changes
Changes to StrictMode
Handle infogroup, and groupCollapsed in StrictMode logging (#25172 by @timneutkens)
Refs are now attached/detached/attached in StrictMode (#25049 by @sammy-SC)
Fix useSyncExternalStore() hydration in StrictMode (#26791 by @sophiebits)
Always trigger componentWillUnmount() in StrictMode (#26842 by @tyao1)
Restore double invoking useState() and useReducer() initializer functions in StrictMode (#28248 by @eps1lon)
Reuse memoized result from first pass (#25583 by @acdlite)
Fix useId() in StrictMode (#25713 by @gnoff)
Add component name to StrictMode error messages (#25718 by @sammy-SC)
all changes에서 의심이 가는 PR은 일부 있었지만, 확신을 가질 정도의 설명이나 예시는 없었다
Restore double invoking useState() and useReducer() initializer functions in StrictMode (#28248 by @eps1lon)
→ react 19에 해당하는 PR 중 react 18에 이미 존재하는 코드가 삭제 되 복구하는 PR
→ 즉, 이미 19 릴리즈 전 수정이 끝난 내용으로 18과 19버전의 차이에 해당되는 내용이 아님
Refs are now attached/detached/attached in StrictMode (#25049 by @sammy-SC)
→ ref 프로퍼티의 콜백 함수가 두 번 실행된다는 내용
Reuse memoized result from first pass (#25583 by @acdlite)
→ 값을 재사용하는 것은 상황에 맞지만 memoized result로 대상이 제한 됨
→ useRef에 대한 직접적인 언급은 없음
조금 더 살펴보고 싶었지만 PR의 주요 코드가 담긴 파일이 길고 방대하여 쉽지 않은 상황이다
ReactFiberHooks.js (5774 lines)
나중에 여유가 생기면 조금 더 살펴보아야 겠다

느낀점

완전히 동일한 hook을 구현한 두 개의 라이브러리를 보면서 비교해 배울 수 있었다
ahook의 경우 useEffect와 useLayoutEffect를 의식해 hook을 인자로 받도록 구성하였다
import { useRef } from 'react'; import type { useEffect, useLayoutEffect } from 'react'; type EffectHookType = typeof useEffect | typeof useLayoutEffect; export const createUpdateEffect: (hook: EffectHookType) => EffectHookType = (hook) => (effect, deps) => { const isMounted = useRef(false); // for react-refresh hook(() => { return () => { isMounted.current = false; }; }, []); hook(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; export default createUpdateEffect;
JavaScript
복사
hook을 인자로 전달하는 것이 새로우면서 상황에 잘 맞는 해결책이라는 생각이 들었다
아무래도 오픈소스의 경우 추상화 레벨이 높다보니 코드를 살펴보는 것이 공부가 많이 된다
PR을 올리면서 공통 모듈을 위한 레포지토리를 관리하는 법을 배울 수 있었다
### Commit messages This repo uses [semantic-release](https://github.com/semantic-release/semantic-release) and [conventional commit messages](https://conventionalcommits.org) so prefix your commits with `fix:` or `feat:` if you want your changes to appear in [release notes](https://github.com/streamich/react-use/blob/master/CHANGELOG.md).
Markdown
복사
PR의 prefix에 따라 릴리즈노트에 보여진다는 내용이었는데
공통 라이브러리의 긴 릴리즈 노트를 보면서 어떻게 관리하나 싶었는데 방법이 다 있었다
기업 레벨의 레포라도 규모에 따라 유용하게 사용할 수 있을 것 같아서 좋은 내용을 배웠다
평소 유용하게 잘 사용하던 라이브러리이지만 기여를 하면서 조금 아쉽다는 생각이 들었다
많은 PR과 이슈가 조금 방치된 것처럼 계속해서 시간이 흐르고 있다는 점
PR 업로드 시에 Github Action을 통한 테스트나 린트, 타입 체크 등의 과정이 없다는 점
메인테이너가 많은 레포를 관리하고 있어 현실적으로 관리가 어려워 보인다는 점
특히, 레포의 갯수도 그렇지만 프론트엔드 외 관심사가 많아 보이는 느낌이다
그래서 앞으로의 적용은 신중하게 진행할 것 같다는 생각이 들었다
반면에 ahook은 이번에 코드를 보면서 좋은 인상을 받았지만
antd를 사용하면서 중국이 주도하는 라이브러리는 중국어의 벽이 있다는 것을 느껴
특정적인 부분에 대해 중국어로 작성되어 있으면 에러핸들링이 어려울 것 같은 생각이다
기술을 고를 때마다 느끼는 거지만 항상 쉽지 않다는 느낌이다..