- 게시됨
안녕, React, 안녕 useEffect (그렇게 되기를 바라며)
- 작성자
- 이름
- Imamuzzaki Abu Salam
- https://x.com/ImBIOS_Dev
React에서 useEffect를 대체하는 방법
이 글에서는 대부분의 경우에 useEffect를 대체하는 방법을 알려드리겠습니다.
David Khoursid의 "Goodbye, useEffect" 영상을 보았는데, 🤯 놀라운 😀 좋은 방식으로 제 마음을 사로잡았습니다. 저는 useEffect가 너무 많이 사용되어 코드가 지저분해지고 유지 관리하기 어려워진다는 데 동의합니다. 저는 오랫동안 useEffect를 사용해 왔고, 잘못 사용한 경험도 있습니다. React에는 코드를 더 깔끔하고 유지 관리하기 쉽게 만드는 기능들이 있다는 것을 확신합니다.
useEffect란 무엇인가요?
useEffect는 함수형 컴포넌트에서 부수 효과를 수행할 수 있도록 하는 훅입니다. componentDidMount, componentDidUpdate, componentWillUnmount를 하나의 API로 결합합니다. 많은 작업을 수행할 수 있게 해주는 강력한 훅이지만, 동시에 많은 버그를 유발할 수 있는 위험한 훅이기도 합니다.
왜 useEffect는 위험할까요?
다음 예를 살펴보겠습니다.
import React, { useEffect } from 'react'
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1)
}, 1000)
return () => clearInterval(interval)
}, [])
return <div>{count}</div>
}
1초마다 증가하는 간단한 카운터입니다. useEffect를 사용하여 간격을 설정하고 컴포넌트가 언마운트될 때 간격을 지웁니다. 위 코드 스니펫은 useEffect의 널리 사용되는 사례입니다. 간단한 예이지만, 동시에 심각한 문제를 야기할 수 있는 예입니다.
이 예제의 문제점은 컴포넌트가 다시 렌더링될 때마다 간격이 설정된다는 것입니다. 컴포넌트가 어떤 이유로든 다시 렌더링되면 간격이 다시 설정됩니다. 간격이 1초에 두 번 호출됩니다. 이 간단한 예에서는 문제가 되지 않지만, 간격이 더 복잡해지면 큰 문제가 될 수 있습니다. 메모리 누수를 일으킬 수도 있습니다.
해결 방법
이 문제를 해결하는 방법은 여러 가지가 있습니다. 한 가지 방법은 useRef를 사용하여 간격을 저장하는 것입니다.
import React, { useEffect, useRef } from 'react'
const Counter = () => {
const [count, setCount] = useState(0)
const intervalRef = useRef()
useEffect(() => {
intervalRef.current = setInterval(() => {
setCount((c) => c + 1)
}, 1000)
return () => clearInterval(intervalRef.current)
}, [])
return <div>{count}</div>
}
위 코드는 이전 예제보다 훨씬 좋습니다. 컴포넌트가 다시 렌더링될 때마다 간격을 설정하지 않습니다. 하지만 여전히 개선의 여지가 있습니다. 여전히 다소 복잡하고, 여전히 매우 위험한 훅인 useEffect를 사용하고 있습니다.
useEffect는 효과를 위한 것이 아닙니다
useEffect가 componentDidMount, componentDidUpdate, componentWillUnmount를 하나의 API로 결합한다는 것을 알고 있습니다. 예시를 들어보겠습니다.
useEffect(() => {
// componentDidMount?
}, [])
useEffect(() => {
// componentDidUpdate?
}, [something, anotherThing])
useEffect(() => {
return () => {
// componentWillUnmount?
}
}, [])
이해하기 쉽습니다. useEffect는 컴포넌트가 마운트, 업데이트, 언마운트될 때 부수 효과를 수행하는 데 사용됩니다. 하지만 부수 효과를 수행하는 데만 사용되는 것은 아닙니다. 컴포넌트가 다시 렌더링될 때 부수 효과를 수행하는 데에도 사용됩니다. 컴포넌트가 다시 렌더링될 때 부수 효과를 수행하는 것은 좋은 생각이 아닙니다. 많은 버그를 유발할 수 있습니다. 컴포넌트가 다시 렌더링될 때 부수 효과를 수행하려면 다른 훅을 사용하는 것이 좋습니다.
import React, { useState, useEffect } from 'react'
const Example = () => {
const [value, setValue] = useState('')
const [count, setCount] = useState(-1)
useEffect(() => {
setCount(count + 1)
})
const onChange = ({ target }) => setValue(target.value)
return (
<div>
<input type="text" value={value} onChange={onChange} />
<div>Number of changes: {count}</div>
</div>
)
}
useEffect는 상태 설정자가 아닙니다
import React, { useState, useEffect } from 'react'
const Example = () => {
const [count, setCount] = useState(0)
// componentDidMount와 componentDidUpdate와 유사합니다.
useEffect(() => {
// 브라우저 API를 사용하여 문서 제목을 업데이트합니다.
document.title = `You clicked ${count} times`
}) // <-- 여기가 문제입니다. 😱 의존성 배열이 빠져 있습니다.
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
다음 문서를 읽어보는 것을 권장합니다: https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects
명령형 vs 선언형
명령형: 무언가가 발생하면 이 효과를 실행합니다.
선언형: 무언가가 발생하면 상태가 변경되고 상태의 어떤 부분이 변경되었는지에 따라 (의존성 배열) 이 효과가 실행됩니다. 하지만 일부 조건이 true인 경우에만 실행됩니다. React는 아무 이유 없이 동시 렌더링을 위해 다시 실행할 수 있습니다.
개념 vs 구현
개념:
useEffect(() => {
doSomething()
return () => cleanup()
}, [whenThisChanges])
구현:
useEffect(() => {
if (foo && bar && (baz || quo)) {
doSomething()
} else {
doSomethingElse()
}
// 앗, 클린업을 잊었네요
}, [foo, bar, baz, quo])
실제 구현:
useEffect(() => {
if (isOpen && component && containerElRef.current) {
if (React.isValidElement(component)) {
ionContext.addOverlay(overlayId, component, containerElRef.current!);
} else {
const element = createElement(component as React.ComponentClass, componentProps);
ionContext.addOverlay(overlayId, element, containerElRef.current!);
}
}
}, [component, containerElRef.current, isOpen, componentProps]);
useEffect(() => {
if (removingValue && !hasValue && cssDisplayFlex) {
setCssDisplayFlex(false)
}
setRemovingValue(false)
}, [removingValue, hasValue, cssDisplayFlex])
이 코드를 작성하는 것은 무섭습니다. 게다가 이 코드는 코드베이스에서 일반적으로 사용되고 엉망진창이 될 것입니다. 😱🤮
효과는 어디에 위치해야 할까요?
React 18은 마운트 시 두 번 효과를 실행합니다 (strict mode에서). 마운트/효과 (╯°□°)╯︵ ┻━┻ -> 언마운트 (시뮬레이션)/클린업 ┬─┬ /( º _ º /) -> 리마운트/효과 (╯°□°)╯︵ ┻━┻
컴포넌트 외부에 배치해야 할까요? 기본 useEffect? 음... 어색하네요. 음... 🤔 렌더링에 배치할 수는 없는데, 렌더링은 수학 방정식의 오른쪽과 같기 때문입니다. 계산 결과만 표시해야 합니다.
useEffect는 무엇을 위한 것일까요?
동기화
useEffect(() => {
const sub = createThing(input).subscribe((value) => {
// value로 무언가를 수행합니다.
})
return sub.unsubscribe
}, [input])
useEffect(() => {
const handler = (event) => {
setPointer({ x: event.clientX, y: event.clientY })
}
elRef.current.addEventListener('pointermove', handler)
return () => {
elRef.current.removeEventListener('pointermove', handler)
}
}, [])
액션 효과 vs 활동 효과
발사 후 잊어버리기 동기화
(액션 효과) (활동 효과)
0 ---------------------- ----------------- - - -
o o | A | o o | A | A
o o | | | o o | | | |
o o | | | o o | | | |
o o | | | o o | | | |
o o | | | o o | | | |
o o | | | o o | | | |
o o V | V o o V | V |
o-------------------------------------------------------------------------------->
Unmount Remount
액션 효과는 어디에 위치해야 할까요?
이벤트 핸들러. 어쩌면요.
<form
onSubmit={(event) => {
// 💥 부수 효과!
submitData(event)
}}
>
{/* ... */}
</form>
Beta React.js에는 훌륭한 정보가 있습니다. 읽어보는 것을 권장합니다. 특히 "Can event handlers have side effects?" 부분.
물론입니다! 이벤트 핸들러는 부수 효과를 위한 가장 좋은 장소입니다.
다른 훌륭한 리소스로는 Where you can cause side effects가 있습니다.
React에서 부수 효과는 일반적으로 이벤트 핸들러 내부에 있어야 합니다.
다른 모든 옵션을 다 사용했는데도 부수 효과에 맞는 이벤트 핸들러를 찾을 수 없다면 컴포넌트의 반환된 JSX에 useEffect 호출을 사용하여 부수 효과를 연결할 수 있습니다. 이렇게 하면 React가 렌더링 후 부수 효과를 허용하는 시점에 나중에 실행하도록 지시합니다. 하지만 이 방법은 최후의 수단으로 사용해야 합니다.
"효과는 렌더링 외부에서 발생합니다." - David Khoursid.
(state) => UI
(state, event) => nextState // 🤔 효과?
UI는 상태의 함수입니다. 현재 모든 상태가 렌더링되면 현재 UI가 생성됩니다. 마찬가지로 이벤트가 발생하면 새 상태가 생성됩니다. 그리고 상태가 변경되면 새 UI가 생성됩니다. 이 패러다임은 React의 핵심입니다.
효과는 언제 발생할까요?
상태 전환. 항상.
(state, event) => nextState
|
V
(state, event) => (nextState, effect) // 여기
![Rerender illustration image](https://media.slid.es/uploads/174419/images/9663683/CleanShot_2022-06-22_at_20.24.08_2x.png align="left")
액션 효과는 어디에 위치해야 할까요? 이벤트 핸들러. 상태 전환.
이벤트 핸들러와 동시에 실행됩니다.
효과가 필요하지 않을 수 있습니다.
useEffect를 사용할 수 있는 이유는 이 문제를 해결할 수 있는 React의 기본 API가 있다는 것을 모르기 때문입니다.
이 주제에 대한 훌륭한 리소스를 소개합니다: You Might Not Need an Effect
데이터 변환을 위해 useEffect가 필요하지 않습니다.
useEffect ➡️ useMemo (대부분의 경우 useMemo가 필요하지 않더라도)
const Cart = () => {
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
useEffect(() => {
setTotal(items.reduce((total, item) => total + item.price, 0))
}, [items])
// ...
}
다시 한번 신중하게 읽고 생각해 보세요 🧐.
const Cart = () => {
const [items, setItems] = useState([])
const total = useMemo(() => {
return items.reduce((total, item) => total + item.price, 0)
}, [items])
// ...
}
useEffect
를 사용하여 합계를 계산하는 대신 useMemo
를 사용하여 합계를 메모리에 저장할 수 있습니다. 변수가 비용이 많이 드는 계산이 아니더라도 useMemo
를 사용하여 메모리에 저장할 필요가 없습니다. 기본적으로 성능을 메모리와 교환하는 것입니다.
useEffect
에서 setState
가 보이면 단순화할 수 있다는 경고 신호입니다.
useSyncExternalStore
외부 저장소를 사용한 효과?useEffect ➡️ useSyncExternalStore
❌ 잘못된 방법:
const Store = () => {
const [isConnected, setIsConnected] = useState(true)
useEffect(() => {
const sub = storeApi.subscribe(({ status }) => {
setIsConnected(status === 'connected')
})
return () => {
sub.unsubscribe()
}
}, [])
// ...
}
✅ 최상의 방법:
const Store = () => {
const isConnected = useSyncExternalStore(
// 👇 구독
storeApi.subscribe,
// 👇 스냅샷 가져오기
() => storeApi.getStatus() === 'connected',
// 👇 서버 스냅샷 가져오기
true
)
// ...
}
부모와 통신을 위해 useEffect가 필요하지 않습니다.
useEffect ➡️ 이벤트 핸들러
❌ 잘못된 방법:
const ChildProduct = ({ onOpen, onClose }) => {
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (isOpen) {
onOpen()
} else {
onClose()
}
}, [isOpen])
return (
<div>
<button
onClick={() => {
setIsOpen(!isOpen)
}}
>
Toggle quick view
</button>
</div>
)
}
📈 더 나은 방법:
const ChildProduct = ({ onOpen, onClose }) => {
const [isOpen, setIsOpen] = useState(false)
const handleToggle = () => {
const nextIsOpen = !isOpen;
setIsOpen(nextIsOpen)
if (nextIsOpen) {
onOpen()
} else {
onClose()
}
}
return (
<div>
<button
onClick={}
>
Toggle quick view
</button>
</div>
)
}
✅ 최상의 방법은 사용자 지정 훅을 만드는 것입니다.
const useToggle({ onOpen, onClose }) => {
const [isOpen, setIsOpen] = useState(false)
const handleToggle = () => {
const nextIsOpen = !isOpen
setIsOpen(nextIsOpen)
if (nextIsOpen) {
onOpen()
} else {
onClose()
}
}
return [isOpen, handleToggle]
}
const ChildProduct = ({ onOpen, onClose }) => {
const [isOpen, handleToggle] = useToggle({ onOpen, onClose })
return (
<div>
<button
onClick={handleToggle}
>
Toggle quick view
</button>
</div>
)
}
전역 싱글톤 초기화를 위해 useEffect가 필요하지 않습니다.
useEffect ➡️ justCallIt
❌ 잘못된 방법:
const Store = () => {
useEffect(() => {
storeApi.authenticate() // 👈 이 코드가 두 번 실행됩니다!
}, [])
// ...
}
🔨 수정해 보겠습니다.
const Store = () => {
const didAuthenticateRef = useRef()
useEffect(() => {
if (didAuthenticateRef.current) return
storeApi.authenticate()
didAuthenticateRef.current = true
}, [])
// ...
}
➿ 다른 방법:
let didAuthenticate = false
const Store = () => {
useEffect(() => {
if (didAuthenticate) return
storeApi.authenticate()
didAuthenticate = true
}, [])
// ...
}
🤔 만약 다음과 같다면:
storeApi.authenticate()
const Store = () => {
// ...
}
🍷 SSR인가요?
if (typeof window !== 'undefined') {
storeApi.authenticate()
}
const Store = () => {
// ...
}
🧪 테스트?
const renderApp = () => {
if (typeof window !== 'undefined') {
storeApi.authenticate()
}
appRoot.render(<Store />)
}
모든 것을 컴포넌트 내부에 배치할 필요는 없습니다.
데이터 가져오기를 위해 useEffect가 필요하지 않습니다.
useEffect ➡️ renderAsYouFetch (SSR) 또는 useSWR (CSR)
❌ 잘못된 방법:
const Store = () => {
const [items, setItems] = useState([])
useEffect(() => {
let isCanceled = false
getItems().then((data) => {
if (isCanceled) return
setItems(data)
})
return () => {
isCanceled = true
}
})
// ...
}
💽 Remix 방식:
import { useLoaderData } from '@renix-run/react'
import { json } from '@remix-run/node'
import { getItems } from './storeApi'
export const loader = async () => {
const items = await getItems()
return json(items)
}
const Store = () => {
const items = useLoaderData()
// ...
}
export default Store
⏭️🧹 Next.js (appDir) with async/await in Server Component way:
// app/page.tsx
async function getData() {
const res = await fetch('https://api.example.com/...')
// The return value is *not* serialized
// You can return Date, Map, Set, etc.
// Recommendation: handle errors
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Page() {
const data = await getData()
return <main></main>
}
⏭️💁 Next.js (appDir) with useSWR in Client Component way:
// app/page.tsx
import useSWR from 'swr'
export default function Page() {
const { data, error } = useSWR('/api/data', fetcher)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data}!</div>
}
⏭️🧹 Next.js (pagesDir) in SSR way:
// pages/index.tsx
import { GetServerSideProps } from 'next'
export const getServerSideProps: GetServerSideProps = async () => {
const res = await fetch('https://api.example.com/...')
const data = await res.json()
return {
props: {
data,
},
}
}
export default function Page({ data }) {
return <div>hello {data}!</div>
}
⏭️💁 Next.js (pagesDir) in CSR way:
// pages/index.tsx
import useSWR from 'swr'
export default function Page() {
const { data, error } = useSWR('/api/data', fetcher)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data}!</div>
}
🍃 React Query (SSR way:
import { getItems } from './storeApi'
import { useQuery } from 'react-query'
const Store = () => {
const queryClient = useQueryClient()
return (
<button
onClick={() => {
queryClient.prefetchQuery('items', getItems)
}}
>
See items
</button>
)
}
const Items = () => {
const { data, isLoading, isError } = useQuery('items', getItems)
// ...
}
⁉️ 정말 ⁉️ 무엇을 사용해야 할까요? useEffect? useQuery? useSWR?
아니면... 그냥 use() 🤔
use()는 개념적으로 await와 유사한 약속을 받는 새로운 React 함수입니다. use()는 컴포넌트, 훅, Suspense와 호환되는 방식으로 함수에서 반환된 약속을 처리합니다. React RFC에서 use()에 대한 자세한 내용을 확인하세요.
function Note({ id }) {
// 이 코드는 비동기적으로 메모를 가져오지만 컴포넌트 작성자에게는
// 동기 작업처럼 보입니다.
const note = use(fetchNote(id))
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
</div>
)
}
useEffect에서 데이터 가져오기 문제
🏃♂️ 경쟁 조건
🔙 즉시 뒤로 가기 버튼 없음
🔍 SSR 또는 초기 HTML 콘텐츠 없음
🌊 폭포 추적
- Reddit, Dan Abramov
결론
데이터 가져오기부터 명령형 API와의 싸움까지 부수 효과는 웹 애플리케이션 개발에서 가장 큰 좌절감의 원천 중 하나입니다. 그리고 솔직히 말해서 모든 것을 useEffect 훅에 넣는 것은 약간의 도움만 될 뿐입니다. 다행히도 상태 머신과 상태 차트에서 공식화된 부수 효과에 대한 과학(정확히 말하면 수학)이 있습니다. 이는 복잡성에 관계없이 효과를 선언적으로 조율하는 방법을 시각적으로 모델링하고 이해하는 데 도움이 될 수 있습니다.