Data Fetching할 때 Suspense가 작동안되는 경우

j_wisdom_h·2024년 3월 18일
0

Next.js 프로젝트

목록 보기
8/8

❗ 문제발생

  • 내가 원하는 것 : GET API를 fetch를 하는 동안 화면에 스켈레톤 코드를 그리고 싶다. 그래서 suspense를 사용했다.
  • 결과 : 잉? suspense의 fallback이 렌더링 안된다.

❗ 간과한 것

useEffect(의존성 배열이 []일때)는 suspense보다 먼저 발생한다.

  • useEffect는 컴포넌트 마운팅 이후에 실행된다.
  • suspense는 컴포넌트 렌더링 전에 데이터를 기다리도록 하여 fallback UI를 보여준다.

따라서 컴포넌트 마운팅은 렌더링 전에 일어나야 하기 때문에 useEffect는 suspense fallback보다 먼저 실행된다.

그 결과 useEffect로 데이터를 가져오는 경우, fallback UI는 보이지 않고 데이터가 도착하면 컴포넌트가 다시 렌더링된다.

React 컴포넌트 생명주기

  • 생성: 컴포넌트가 처음 생성될 때 호출됩니다.
  • 마운팅: 컴포넌트가 DOM에 추가될 때 호출됩니다.
  • 업데이트: 컴포넌트 props 또는 state가 변경될 때 호출됩니다.
  • 언마운팅: 컴포넌트가 DOM에서 제거될 때 호출됩니다.
const MyComponent = () => {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('https://api.example.com/data')
      .then(response => response.json())
      .then(data => setData(data));
  }, []);

  return (
    <Suspense fallback={<p>로딩중</p>}>
        <div>{data.message}</div> 
    </Suspense>
  )
}

위의 코드처럼 작성하면 에러나 나올 것 이다. 렌더링되는 시점에 data는 null이기 때문이다.

suspense는 하위 컴포넌트가 api호출로 상태관리되는 데이터를 받아오는 것을 완료될때까지 기다려주는 기능을 갖고 있지 않다.

⭐따라서 fetch가 완료될때까지 '로딩중'이 출력되기를 기대해서는 안된다.

  return (
    <Suspense fallback={<p>로딩중</p>}>
    	{data && <div>{data.message}</div>}
    </Suspense>
  )

이렇게 코드를 바꿀 경우, 에러는 해결될 것 이다. 그러니 여전히 Suspense의 fallback은 보이지 않는다.

Render-as-you-fetch

그 대안으로 발견한 것이다.

Render-as-you-fetch라는 방법론은 네트워크 요청을 발생시킨 직후에 컴포넌트를 렌더링한다.

핵심은 fetch을 컴포넌트 밖으로 빼내고, 컴포넌트가 렌더링전에 네트워크 요청을 보내는 것이다.

const data = fetchData();

const App = () => (
  <>
    <Suspense fallback={<p>Fetching user details...</p>}>
      <UserWelcome />
    </Suspense>

    <Suspense fallback={<p>Loading todos...</p>}>
      <Todos />
    </Suspense>
  </>
);

const UserWelcome = () => {
  const userDetails = data.userDetails.read();
  // code to render welcome message
};

const Todos = () => {
  const todos = data.todos.read();
  // code to map and render todos
};

Render-as-you-fetch방법론이란 렌더링전에 미리 필요한 데이터를 받아오는 것이라 생각한다.

위의 코드를 보고 이게 뭐지 ? 라는 의문이 들었던 것.

read() 이건 다른 라이브러리를 쓴건가? 어디서 나온걸까?

대답은 NO. 그냥 작성한 코드다.
아래 두 코드로 fetch가 완료되는 시점을 캐치 할 수 있다.

//fetchData
import wrapPromise from './wrapPromise'

function fetchData(url) {
    const promise = fetch(url)
        .then((res) => res.json())
        .then((res) => res.data)

    return wrapPromise(promise)
}

export default fetchData
// wrapPromise
function wrapPromise(promise) {
    let status = 'pending'
    let response

    const suspender = promise.then(
        (res) => {
            status = 'success'
            response = res
        },
        (err) => {
            status = 'error'
            response = err
        },
    )

    const read = () => {
        switch (status) {
            case 'pending':
                throw suspender
            case 'error':
                throw response
            default:
                return response
        }
    }
    return { read }
}

export default wrapPromise

wrapPromise는 Promise를 감싸고 Promise에서 반환되는 데이터가 준비되었는지 확인하는 메서드를 제공하는 wrapper 다.

  • 인수로 Promise를 받는다.
  • Promise가 resolve되면, resolve된 값을 반환한다.
  • Promise가 reject되면, reject된 값을 반환한다.
  • Promise가 여전히 pending 상태이면, Promise를 반환한다.

적용예시

import React, { Suspense } from "react";

import UserWelcome from "./UserWelcome";
import Todos from "./Todos";

const App = () => {
  return (
    <div className="app">
      <h2>Simple Todo</h2>

      <Suspense fallback={<p>Loading user details...</p>}>
        <UserWelcome />
      </Suspense>
      <Suspense fallback={<p>Loading Todos...</p>}>
        <Todos />
      </Suspense>
    </div>
  );
};

export default App;
import React from "react";
import fetchData from "../api/fetchData";

const resource = fetchData(
  "https://run.mocky.io/v3/d6ac91ac-6dab-4ff0-a08e-9348d7deed51"
);

const UserWelcome = () => {
  const userDetails = resource.read();

  return (
    <div>
      <p>
        Welcome <span className="user-name">{userDetails.name}</span>, here are
        your Todos for today
      </p>
      <small>Completed todos have a line through them</small>
    </div>
  );
};

export default UserWelcome;

fetchData함수의 반환 값을 변수 resource에 할당한다.
resource의 read() 메서드를 호출하여 request Promise를 쿼리할 수 있다.

  • request가 resolve되지 않았다면, resource.read()를 호출하는 것은 제일 가까운 상위 Suspense 컴포넌트로 Promise를 throw하게 되고, fallback이 렌더링 되게 된다.

  • Promise가 resolve 되었다면, resource.read()메서드 에서는 Promise에서 resolve된 데이터를 반환할 것이고, 해당 값을 통해서 렌더링을 진행하게 된다.

나의 경우

나의 경우에는 props로 전달받아야하는 인자가 있어서 컴포넌트 바깥에서 fetch하는 과정을 할 수 없었다. 그래서 suspense를 사용하지 않는 방식으로 수정했다.

function IntroductionTab({ studyId }) {
    const [studyInfo, setStudyInfo] = useState<null | IstudyAll>(null)

    useEffect(() => {
        async function loadData() {
            try {
                const data = await fetchData(studyId)
                setStudyInfo(data)
            } catch (e) {
                console.error(e)
            }
        }
        loadData()
    }, [])

    if (!studyInfo) {
        return (
            <DeferredComponent>
                <Skeleton />
            </DeferredComponent>
        )
    }
    return <IntroductionContent studyInfo={studyInfo} />
}

fetchData로부터 get요청이 완료되지 않은 경우 스켈레톤코드를 보여준다.
fetchData로부터 get요청이 완료되면 IntroductionContent가 studyInfo를 토대로 렌더링 된다.

스켈레톤이 깜박이는 문제

처음에 get요청을 보낼때는 시간이 걸리지만 캐싱을 next.js에서 지원하기 때문에 같은 요청이 다시 발생하면 빠르게 요청결과 데이터를 볼 수 있다.

그러면서 생긴 것이 스켈레톤이 깜박이는 문제다. 빠르게 데이터가 보여지면서 스켈레톤이 빠르게 보였다 사라지는 현상이 오히려 불편함을 준다.

//DeferredComponent.tsx
'use client'
import { PropsWithChildren, useEffect, useState } from 'react'

// ** 지연시간 200ms 미만일 때 스켈레톤 미노출 / 200ms 이상일 때 스켈레톤 노출 **
const DeferredComponent = ({ children }: PropsWithChildren) => {
    const [isDeferred, setIsDeferred] = useState(false)
    useEffect(() => {
        const timeOut = setTimeout(() => {
            setIsDeferred(true)
        }, 200)
        return () => clearInterval(timeOut)
    }, [])
    if (!isDeferred) return null
    return <>{children}</>
}

export default DeferredComponent

DeferredComponent는 지연시간 200ms 미만일 때 스켈레톤 미노출 / 200ms 이상일 때 스켈레톤 노출하는 방식으로 ux을 더 낫게 해준다.

profile
뚜잇뚜잇 FE개발자

0개의 댓글