[4] Web Storage 및 Zustand 상태 관리

Kim TaeHyeong·3일 전

UMC

목록 보기
9/10

session Storage하면 탭 바뀌거나 다른 창에서 데이터 연결이 안됨
그래서 local Storage 씀

주의할 점은 local Storage는 string 받는데, 이 때문에 JSON => str로 바꾸고
str => JSON으로 받는 거 필요
물론 local Storage가 너무 쉽게 접근 및 수정이 가능해서 보안 이슈 주의

또, 쉽게 데이터 관리할 수 있게 zustand 같은 라이브러리 씀.
간편하게 전역변수 관리 가능해서 Context 의존도 줄음

사실 zustand 안에 Context API 쓰는 거여서 편리

middle ware 써서 local Storage 같은 곳에 저장하면 좋음

  • Web Storage
    • localStorage와 sessionStorage는 저장값의 수명과 공유 범위가 어떻게 다른가요? localStorage : 브라우저 닫아도 있음. sessionStorage : 브라우저 탭에서만 유효. 탭 단위
    • 객체나 배열을 저장할 때 JSON 문자열로 바꿔야 하는 이유는 무엇일까요? Storage 단위가 String인데 쉽게 저장하려고
    • Web Storage에는 어떤 데이터를 저장하는 것이 적절할까요? 안 민감한 것들과 타 페이지랑 소통해야 할 때. 대용량은 ㄴㄴ 저장공간 때문에
  • 클라이언트 상태와 전역 상태 관리
    • 컴포넌트 상태와 전역 상태는 사용하는 범위가 어떻게 다른가요? 컴포넌트 : local UI 상태, 입력 값 같은 거. useState 전역상태 : 전체로 쓰는 거. 계정 정보나 데이터 정보
    • 여러 화면에서 사용하는 북마크 상태를 전역으로 관리하면 어떤 장점과 비용이 생길까요? 장점 : 일관성, 페이지 이동해도 즉시 반영 비용 : 복잡해지고 렌더링 증가.
    • Zustand와 Web Storage는 북마크 상태를 관리할 때 각각 어떤 역할을 할까요? Zustand : 전역 상태 관리 편함. 미들웨어 처리. 컴포넌트 접근 쉬움 Web Storage : 페이지 새로고침해도 유지. 용량 제한과 보안이슈

필수 미션

  • 북마크 상태를 Zustand로 공유하기
    • 목록, 검색과 상세 화면이 같은 북마크 상태를 사용하도록 연결해요.
    • 영화를 북마크에 추가하거나 제거할 수 있게 작성해요.
    • 한 화면에서 상태를 바꾼 뒤 다른 화면에서도 같은 결과가 보이는지 확인해요.
  • 북마크 상태를 Web Storage에 유지하기
    • 북마크한 영화 ID가 Web Storage에 저장되고 앱을 다시 열 때 복원되게 작성해요.
    • 새로고침과 브라우저 재실행 뒤에도 북마크 상태가 유지되는지 확인해요.
    • Application 패널에서 umcine-bookmark-store 저장값을 확인하고, 저장값을 지웠을 때 초기 빈 상태로 돌아오는지 확인한 뒤 pnpm build를 실행해요.

선택 미션

  • sessionStorage로 저장소를 바꾸고 탭을 닫았을 때의 차이를 기록해 보세요.
  • 영화 카드 크기나 정렬처럼 브라우저에만 저장할 화면 설정을 하나 추가해 보세요.

//bookmark-button.tsx

import { useBookmarkStore } from "../stores/bookmark-store";
import { cn } from "../utils/cn";

interface BookmarkButtonProps {
  movieId: number;
}

export function BookmarkButton({ movieId }: BookmarkButtonProps) {
    const isBookmarked = useBookmarkStore((state) =>
        state.bookmarkedMovieIds.includes(movieId), 
    );
    //큰 state 안에 값 접근
    const toggleBookmark = useBookmarkStore(
        (state) => state.toggleBookmark,
    );

    return (<button
        type="button"
        onClick={() => toggleBookmark(movieId)}
        aria-pressed={isBookmarked}
        aria-label={isBookmarked ? "북마크 해제" : "북마크하기"}
        className={cn(
            "absolute right-2 top-2 rounded-md border p-1",
            isBookmarked ? "border-brand bg-brand" : "border-white bg-black/60",
        )}>
        <img src={isBookmarked ? "/icons/bookmark.svg" : "/icons/bookmark-outline.svg"} alt="" className="size-5 invert" />
    </button>);

//   return (
//     <button type="button" onClick={() => toggleBookmark(movieId)}>
//       {isBookmarked ? "북마크 해제" : "북마크 추가"}
//     </button>
//   );
}

export function BookmarkButtonDetailPage({ movieId }: BookmarkButtonProps) {
    const isBookmarked = useBookmarkStore((state) =>
        state.bookmarkedMovieIds.includes(movieId), 
    );
    //큰 state 안에 값 접근
    const toggleBookmark = useBookmarkStore(
        (state) => state.toggleBookmark,
    );

    return (<button
        type="button"
        onClick={() => toggleBookmark(movieId)}
        aria-pressed={isBookmarked}
        className="mt-4 flex items-center gap-2 rounded-lg bg-brand px-4 py-2 text-sm font-bold text-white"
    >
        <img
            src={isBookmarked ? "/icons/bookmark.svg" : "/icons/bookmark-outline.svg"}
            alt=""
            className="size-4 invert"
        />
        {isBookmarked ? "즐겨찾기 해제" : "즐겨찾기"}
    </button>);
}
//movie-card.tsx

import { type Movie } from "../../types/movie";
import { Link } from "@tanstack/react-router";
import { BookmarkButton } from "../bookmark-button";

type MovieCardProps = {
    movie : Movie
}

export default function MovieCard({movie} : MovieCardProps) {
    return (
        <article className="relative min-w-0">
            <Link to="/movies/$movieId" params={{movieId : String(movie.id)}}>
                <img className="block aspect-[221/250] w-full rounded-xl object-cover"
                    src={movie.posterPath}
                    alt={`${movie.title} 포스터`} />
                <h3 className="mt-2 truncate text-sm font-bold">{movie.title}</h3>
                <p className="text-xs text-gray-400">{movie.releaseDate}</p>
            </Link>
            <BookmarkButton movieId={movie.id} />
        </article>
    )
}
//movie-detail-page.tsx

import { Link, useParams } from "@tanstack/react-router";
import { useContext } from "react";
import { MovieContext } from "../../contexts/movie-context";
import { BookmarkButtonDetailPage } from "../../components/bookmark-button";

export function MovieDetailPage() {
    const {movieId} = useParams({from: "/movies/$movieId"});
    const {movies} = useContext(MovieContext);

    const movie = movies.find((m) => m.id === Number(movieId));
    if (!movie) {
        return (
            <main className="mx-auto w-full max-w-[1170px] flex-1 py-10">
                <p>해당하는 영화를 찾을 수 없어요.</p>
                <Link to="/" className="text-brand underline">영화 목록</Link>
            </main>
        )
    }

    return (
        <main className="flex-1">
            <section className="relative h-[330px] text-white">
                <img src={movie.backdropPath} alt="" aria-hidden="true" className="absolute inset-0 size-full object-cover" />
                {/* aria-hidden:true 단순 꾸미기 용도. 스크린 리더는 이거 건너뛰셈 */}
                <div className="absolute inset-0 bg-gradient-to-t from-black/70 to-black/10" />
                <div className="relative mx-auto flex h-full max-w-[1170px] flex-col justify-between py-6">
                    <Link to="/" className="flex w-fit items-center gap-1 text-xs font-bold">
                        <img src="/icons/chevron-left.svg" alt="" className="size-4 invert" />
                        영화 목록
                    </Link>
                    <div>
                        <h1 className="text-5xl font-extrabold">{movie.title}</h1>
                        <p className="mt-2 text-sm">{movie.originalTitle}</p>
                        <p className="mt-1 text-xs font-bold">
                            {movie.releaseDate} <span className="ml-1">{movie.genres.join(" · ")}</span> <span className="ml-1">{movie.runtime}</span>
                        </p>
                    </div>
                </div>
            </section>

            <div className="mx-auto grid max-w-[1170px] gap-8 py-5 md:grid-cols-[183px_1fr]"> {/*_300px*/}
                <img
                    src={movie.posterPath}
                    alt={`${movie.title} 포스터`}
                    className="w-[183px] rounded-lg shadow-lg"
                />
                <div>
                    <h2 className="text-xl font-bold">{movie.tagline}</h2>
                    <p className="mt-3 text-sm leading-relaxed text-gray-600">{movie.overview}</p>
                    <BookmarkButtonDetailPage movieId={movie.id}/>
                </div>
                {/* <div className="md:border-l md:border-gray-200 md:pl-6">
                    
                </div> */}
            </div>
        </main>
    );
}
//bookmark-store.ts

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";

interface BookmarkStore {
  bookmarkedMovieIds: number[];
  toggleBookmark: (movieId: number) => void;
}

export const useBookmarkStore = create<BookmarkStore>()(
  persist(
    (set) => ({
      bookmarkedMovieIds: [],
      toggleBookmark: (movieId) =>
        set((state) => ({
          bookmarkedMovieIds: state.bookmarkedMovieIds.includes(movieId)
            ? state.bookmarkedMovieIds.filter((id) => id !== movieId)
            : [...state.bookmarkedMovieIds, movieId],
        })),
    }),
    {
      name: "umcine-bookmark-store", //저장 key
      storage: createJSONStorage(() => localStorage), //저장 위치
      partialize: (state) => ({
        bookmarkedMovieIds: state.bookmarkedMovieIds, //저장할꺼
      }),
    },
  ),
);

현재 페이지(footer의 page selector)를 저장함

PageProvider 지우고 zustand로 변경하면서 기본 값만 이용

위 필수미션의 코드 + 밑 내용

//movie-grid.tsx

import { useContext } from "react";
import { MovieContext } from "../../contexts/movie-context";
import MovieCard from "./movie-card";
import { PAGE_SIZE } from "../../contexts/page-bar-context";
import { usePageBarStore } from "../../stores/page-bar-store";

export default function MovieGrid() {
    const {movies} = useContext(MovieContext);

    const page = usePageBarStore((state) =>
        state.currentPage,
    );
    const targetMovies = movies.slice((page-1)*PAGE_SIZE, page*PAGE_SIZE);

    return (
        <div className="grid grid-cols-2 gap-4 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
            {targetMovies.length === 0
                ? <p>영화가 없습니다</p>
                : targetMovies.map((movie) => (
                    <MovieCard key={movie.id} movie={movie}/>
                ))
            }
        </div>
    );
}
//page-selector.tsx

import { PageBarButton } from "../page-bar-button";
import { possiblePages } from "../../contexts/page-bar-context";

export function PageSelector() {
    return (
        <nav aria-label="페이지" className="mt-8 flex justify-center gap-2">
            {possiblePages.map((pageNum) => (
                <PageBarButton pageNum={pageNum}/>
            ))}
        </nav>
    );
}
//page-bar-button.tsx

import { usePageBarStore } from "../stores/page-bar-store";
import { cn } from "../utils/cn";

interface PageBarButtonProps {
    pageNum: number;
}

export function PageBarButton({ pageNum }: PageBarButtonProps) {
    const currentPage = usePageBarStore(
        (state) => state.currentPage,
    );
    
    const setPage = usePageBarStore(
        (state) => state.setPage,
    );

    return (<button
        key={pageNum}
        onClick={() => {setPage(pageNum)}}
        aria-current={currentPage === pageNum ? "page" : undefined}
        className={cn(
            "size-9 rounded-lg border text-sm font-bold",
            currentPage === pageNum
                ? "border-brand bg-brand text-white"
                : "border-gray-200 bg-white text-gray-600",
        )}>
        {pageNum}
    </button>);
}
//page-context.tsx
import { movies } from "../data/movies";

export const PAGE_SIZE = 5;
export const initialPage: number = 1;
export const possiblePages: number[] = Array.from(
    { length: Math.ceil(movies.length / PAGE_SIZE) },
    (_, i) => i + 1, //(현재 값, index) => 값
);

얘가 context 관리자였는데 이젠 필요가 없어져서 고민

기본 변수로 쓰이는데 따로 폴더 빼서 처리하는 걸로 해야할 듯

//page-bar-store.ts

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { initialPage } from "../contexts/page-bar-context";

interface PageBarStore {
  currentPage: number;
  setPage: (page: number) => void;
}

export const usePageBarStore = create<PageBarStore>()(
  persist(
    (set) => ({
      currentPage: initialPage, //기본값
      setPage: (page) =>
        set((_) => ({
          currentPage: page,
        })),
    }),
    {
      name: "umcine-page-bar-store", //저장 key
      storage: createJSONStorage(() => localStorage), //저장 위치
      partialize: (state) => ({
        currentPage: state.currentPage, //저장할꺼
      }),
    },
  ),
);
//pagination.tsx

import MovieGrid from "./movie-grid";
// import { PageProvider } from "../../contexts/page-bar-context";
import { PageSelector } from "../page-bar/page-bar-selector";

export default function Pagination() {
    return (
        <>
            <h1 className="mb-4 text-4xl font-extrabold">영화 목록</h1>
            <MovieGrid />
            <PageSelector />
        </>
    );
}

0개의 댓글