하위 태스크 1 ~ 2

Suspense 기본 사용

컴포넌트를 Suspense로 감싸기

fallback 구현

로딩 중 표시할 UI 작성

app/(with-searchbar)/layout.tsx:

import { ReactNode, Suspense } from "react";
import Form from "next/form";
import { load } from "@/utils"
 
async function SearchBar() {
  const books = await load<Book[]>("http://localhost:3005/books");
 
  return (
    <Form className="flex gap-x-1" action="/search">
      <input
        className="bg-slate-400 border border-white"
        type="text"
        name="q"
        required
        placeholder={`총 ${books.length}개의 책 보유 중...`}
      />
      <button className="px-1 bg-slate-50 border border-white" type="submit">검색</button>
    </Form>
  );
}
 
export default function SearchableLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <div className="flex w-80 p-2 mx-auto bg-slate-300">
        <Suspense fallback={<div>검색창 로딩 중...</div>}>
          <SearchBar />
        </Suspense>
      </div>
      {children}
    </>
  );
}

로딩 전:

로딩 후:

하위 태스크 3 ~ 5

스켈레톤 UI 디자인

실제 콘텐츠와 유사한 로딩 UI

스켈레톤 애니메이션

펄스 효과 등 애니메이션 추가

병렬 스트리밍

여러 독립적인 데이터 동시 스트리밍

components/BookListSkeleton.tsx:

export default function BookListSkeleton() {
  return (
    <ul className="pl-4 list-disc underline">
      <li className="w-40 my-1 bg-slate-200 animate-pulse"></li>
      <li className="w-40 my-1 bg-slate-200 animate-pulse"></li>
      <li className="w-40 my-1 bg-slate-200 animate-pulse"></li>
      <li className="w-40 my-1 bg-slate-200 animate-pulse"></li>
    </ul>
  );
}

app/(with-searchbar)/page.tsx:

import type { Book } from "@/domains";
import { Suspense } from "react";
import Link from "next/link";
import BookListSkeleton from "@/components/BookListSkeleton";
import { load } from "@/utils";
 
async function loadAllBooks() {
  return load<Book[]>("http://localhost:3005/books");
}
 
async function loadRecoBooks(){
  const books = await load<Book[]>("http://localhost:3005/books");
  const shuffledBooks = books.sort(() => Math.random() - 0.5);
 
  return shuffledBooks.slice(0, books.length / 2);
}
 
async function AllBooks() {
  const allBooks = await loadAllBooks();
  
  return (
    <ul className="pl-4 list-disc underline">
      {allBooks.map(({ id, title }) => <li key={id}><Link href={`/book/${id}`}>{title}</Link></li>)}
    </ul>
  );
}
 
async function RecoBooks() {
  const recoBooks = await loadRecoBooks();
 
  return (
    <ul className="pl-4 list-disc underline">
      {recoBooks.map(({ id, title }) => <li key={id}><Link href={`/book/${id}`}>{title}</Link></li>)}
    </ul>
  );
}
 
 
export default async function TestPage() {
  return (
    <main className="p-4">
      <h2 className="mb-4 font-bold">전체 출판물</h2>
      <Suspense fallback={<BookListSkeleton />}>
        <AllBooks />
      </Suspense>
      <h2 className="my-4 font-bold">추천 출판물</h2>
      <Suspense fallback={<BookListSkeleton />}>
        <RecoBooks />
      </Suspense>
    </main>
  );
}

하위 태스크 6 ~ 8

loading.tsx 구현

페이지 레벨 로딩 UI

로딩 우선순위 설정

중요한 콘텐츠 우선 표시

사용자 경험 최적화

로딩 경험 개선

app/(with-searchbar)/search/loading.tsx:

export default function SearchLoading() {
  return <p className="p-4">콘텐츠 로딩 중...</p>;
}

app/(with-searchbar)/page.tsx:

import type { Book } from "@/domains";
import Link from "next/link";
import { load } from "@/utils";
 
export default async function SearchPage() {
  const allBooks = await load<Book[]>("http://localhost:3005/books");
 
  return (
    <main className="p-4">
      <h2 className="mb-2 font-bold">검색</h2>
      <input className="mb-2 bg-slate-200 border border-slate-500" type="text" placeholder="검색어" />
      <ul className="pl-4 list-disc underline">
        {allBooks.map(({ id, title }) => <li key={id}><Link href={`/book/${id}`}>{title}</Link></li>)}
      </ul>
    </main>
  );
}

로딩 전:

로딩 후: