최근에 “AI가 작성해주는 React 코드는 2020~2021년 부근의 오래된 문서/예제 코드를 학습해서 레거시 패턴이 섞일 수 있다”는 글을 봤다.
요즘은 AI가 만들어주는 코드를 아예 외면하기도 어려운 만큼, 결국 중요한 건 내가 그 코드를 검증하고 더 나은 방향으로 고칠 수 있는 기준을 갖는 일이라고 느꼈다.
마침 Vercel에서 React 코드의 모범 사례를 정리한 Best Practices GitHub Repo를 공개했다.
이를 하나씩 공부해가며 몰랐던 개념도 깨우치고, 대충 쌓아올린 기초 지식들도 다시 점검해보는 시간을 가져야겠다.
콜백(핸들러) 함수가 바뀌더라도 useEffect가 매번 다시 구독(리스너 재등록)하지 않게 해야 하는 경우, 콜백을 ref에 저장해서 사용한다.
아래 코드는 handler가 바뀔 때마다 useEffect가 다시 실행되면서addEventListener/removeEventListener가 계속 반복된다.
function useWindowEvent(event: string, handler: (e: any) => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}
핵심은 두 가지다.
function useWindowEvent(event: string, handler: (e: any) => void) {
const handlerRef = useRef(handler)
useEffect(() => {
handlerRef.current = handler // handler가 바뀔 때마다 ref만 업데이트
}, [handler])
useEffect(() => {
const listener = (e: any) => handlerRef.current(e)
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}, [event]) // 실제 구독(리스너 등록)은 event가 바뀔 때만
}
useEffectEvent는 같은 패턴을 더 깔끔하게 쓸 수 있게 해주는 API다.
즉, 함수 참조는 안정적으로 유지하면서, 내부적으로는 항상 최신 핸들러를 호출해준다.
import { useEffect, useEffectEvent } from 'react'
function useWindowEvent(event: string, handler: (e: any) => void) {
const onEvent = useEffectEvent(handler)
useEffect(() => {
window.addEventListener(event, onEvent)
return () => window.removeEventListener(event, onEvent)
}, [event])
}
콜백 안에서 최신 값(query)을 쓰고 싶을 때, 보통 useEffect의 deps 배열에 onSearch를 넣게 된다.
하지만 그러면 콜백이 바뀔 때마다 effect가 다시 실행될 수 있다.useEffectEvent를 사용하면 deps 배열에 콜백을 넣지 않아도 되고, stale closure도 피하면서 effect 재실행도 막을 수 있다.
아래 코드는 [query, onSearch]를 deps로 두기 때문에, onSearch가 바뀌면 useEffect가 다시 돌면서 타이머가 재설정된다.
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
}
useEffectEvent(onSearch)로 안정적인 함수인 onSearchEvent를 만든다.
이 함수는 참조가 안정적이라 deps에 넣을 필요가 없고, 호출 시점에는 최신 로직/값을 사용하도록 도와준다.
따라서 deps는 [query]만 유지해도 된다.
import { useEffectEvent } from 'react';
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchEvent = useEffectEvent(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchEvent(query), 300)
return () => clearTimeout(timeout)
}, [query])
}
서로 독립인 작업은 await로 줄 세우지 말고, 먼저 시작해놓고 나중에 한 번에 기다린다.
export async function GET(request: Request) {
const session = await auth() // 도착할 경우 다움 문장 실행
const config = await fetchConfig() // 도착할 경우 다음 문장 실행
const data = await fetchData(session.user.id)
return Response.json({ data, config })
}
export async function GET(request: Request) {
// 둘 다 동시에 시작
const sessionPromise = auth()
const configPromise = fetchConfig()
// 둘 중 더 오래 걸리는 것까지 도착하면 종료
const session = await sessionPromise
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id),
])
return Response.json({ data, config })
}
await는 흐름을 멈춘다. 그래서 “어차피 안 탈 수도 있는 분기(early return)” 앞에서 미리 기다리지 말고,
실제로 필요한 분기 안에서만 await 한다.
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId)
if (skipProcessing) {
return { skipped: true } // 사실 userData 필요 없는데 굳이 기다렸다가 실행해버림
}
return processUserData(userData)
}
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
return { skipped: true } // 필요없는 경우는 바로 리턴해서 처리
}
const userData = await fetchUserData(userId)
return processUserData(userData)
}
작업들 중 일부만 서로 의존한다면, “가능한 것부터” 최대한 병렬로 돌려야 빠르다.
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig(),
])
const profile = await fetchProfile(user.id) // profile은 config까지 기다릴 필요가 없음!
const userPromise = fetchUser()
// userPromise 끝난 후 fetchProfile 바로 실행
const profilePromise = userPromise.then(user => fetchProfile(user.id))
// fetchConfig는 기다릴 필요 없으니까 시작부터 함께 실행
const [user, config, profile] = await Promise.all([
userPromise,
fetchConfig(),
profilePromise,
])
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
// user 작업의 promise
return fetchProfile((await this.$.user).id)
},
})
const a = await fetchA() // 3ms
const b = await fetchB() // 5ms
// total 8ms
const aPromise = fetchA() // 3ms
const bPromise = fetchB() // 5ms
const [a, b] = await Promise.all([aPromise, bPromise]) // 동시 실행 : total 5ms
페이지 컴포넌트에서 데이터를 먼저 await 해버리면 화면 전체가 막힐 수 있기 때문에,<Suspense fallback=<Skeleton />>로 감싸서 바깥 레이아웃은 먼저 보여주고, 데이터가 필요한 부분만 나중에 채운다.
async function Page() {
const data = await fetchData()
// await이기 때문에 도착할 때까지 이 아래 전체 렌더링 x
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<DataDisplay data={data} />
</div>
<div>Footer</div>
</div>
)
}
function Page() {
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<Suspense fallback={<Skeleton />}>
<DataDisplay />
</Suspense>
</div>
<div>Footer</div>
</div>
)
}
async function DataDisplay() {
const data = await fetchData()
return <div>{data.content}</div>
}
function Page() {
const dataPromise = fetchData()
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<Suspense fallback={<Skeleton />}>
<DataDisplay dataPromise={dataPromise} />
<DataSummary dataPromise={dataPromise} />
</Suspense>
<div>Footer</div>
</div>
)
}
function DataDisplay({ dataPromise }: { dataPromise: Promise<any> }) {
const data = use(dataPromise)
return <div>{data.content}</div>
}
function DataSummary({ dataPromise }: { dataPromise: Promise<any> }) {
const data = use(dataPromise)
return <div>{data.summary}</div>
}
뭔가 당연한 듯 하면서도 자연스럽게 나오지 않는 코드 형식들인 것 같아서 지금까지의 코드 작성에 대해 뒤돌아볼 수 있었다. 특히, 비동기에 대해 깊게 생각하지 않으며 코드를 작성해서 비효율적인 코드를 작성하는 일이 많았는데 앞으로 개선해나가야겠다는 생각을 했다.
다음은 bundle - barrel - imports 파트부터 공부하며 2편에 이어 작성 할 예정이다.