diff --git a/__tests__/detail/screens/DetailScreen.test.tsx b/__tests__/detail/screens/DetailScreen.test.tsx index b553609..a0ff14d 100644 --- a/__tests__/detail/screens/DetailScreen.test.tsx +++ b/__tests__/detail/screens/DetailScreen.test.tsx @@ -1,6 +1,5 @@ import { render, screen } from "@testing-library/react-native"; import { DetailScreen } from "@/src/detail"; -import { FavoritesProvider } from "@/src/shared"; import type { Pokemon } from "@/src/shared"; const mockPokemon: Pokemon = { @@ -43,12 +42,7 @@ jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ usePokemonSpeciesInfo: () => mockUsePokemonSpeciesInfo, })); -const renderWithProvider = (id: string) => - render( - - - , - ); +const renderWithProvider = (id: string) => render(); describe("DetailScreen", () => { beforeEach(() => { diff --git a/__tests__/favorites/screens/FavoritesScreen.test.tsx b/__tests__/favorites/screens/FavoritesScreen.test.tsx index 0ba7512..eb35155 100644 --- a/__tests__/favorites/screens/FavoritesScreen.test.tsx +++ b/__tests__/favorites/screens/FavoritesScreen.test.tsx @@ -1,6 +1,5 @@ import { render, screen } from "@testing-library/react-native"; import { FavoritesScreen } from "@/src/favorites"; -import { FavoritesProvider } from "@/src/shared"; import type { PokemonSummary } from "@/src/shared"; const mockUsePokemonByIds = { @@ -27,12 +26,7 @@ jest.mock("expo-router", () => ({ }, })); -const renderWithProvider = () => - render( - - - , - ); +const renderWithProvider = () => render(); describe("FavoritesScreen", () => { beforeEach(() => { diff --git a/__tests__/home/screens/HomeScreen.test.tsx b/__tests__/home/screens/HomeScreen.test.tsx index f242d3b..9ad6bf7 100644 --- a/__tests__/home/screens/HomeScreen.test.tsx +++ b/__tests__/home/screens/HomeScreen.test.tsx @@ -1,6 +1,5 @@ import { render, screen, fireEvent } from "@testing-library/react-native"; import { HomeScreen } from "@/src/home"; -import { FavoritesProvider } from "@/src/shared"; import type { PokemonSummary } from "@/src/shared"; const mockPokemon: PokemonSummary[] = [ @@ -42,12 +41,7 @@ jest.mock("expo-router", () => ({ }, })); -const renderWithProvider = () => - render( - - - , - ); +const renderWithProvider = () => render(); describe("HomeScreen", () => { beforeEach(() => { diff --git a/__tests__/shared/contexts/FavoritesContext.test.tsx b/__tests__/shared/stores/useFavoritesStore.test.tsx similarity index 73% rename from __tests__/shared/contexts/FavoritesContext.test.tsx rename to __tests__/shared/stores/useFavoritesStore.test.tsx index fd8ce7b..3208dc1 100644 --- a/__tests__/shared/contexts/FavoritesContext.test.tsx +++ b/__tests__/shared/stores/useFavoritesStore.test.tsx @@ -1,6 +1,7 @@ import { renderHook, act } from "@testing-library/react-native"; import { Alert } from "react-native"; -import { FavoritesProvider, useFavorites } from "@/src/shared"; +import { useFavorites } from "@/src/shared"; +import { useFavoritesStore } from "@/src/shared/stores/useFavoritesStore"; jest.mock("@/src/shared/i18n", () => ({ i18n: { @@ -10,23 +11,20 @@ jest.mock("@/src/shared/i18n", () => ({ jest.spyOn(Alert, "alert").mockImplementation(() => {}); -describe("FavoritesContext", () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - +describe("useFavoritesStore", () => { beforeEach(() => { + useFavoritesStore.setState({ favoriteIds: [] }); (Alert.alert as jest.Mock).mockClear(); }); describe("useFavorites", () => { it("初期状態ではお気に入りが空である", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); expect(result.current.favoriteIds).toEqual([]); }); it("toggleFavoriteでポケモンをお気に入りに追加できる", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); act(() => { result.current.toggleFavorite(25); }); @@ -34,7 +32,7 @@ describe("FavoritesContext", () => { }); it("toggleFavoriteで既にお気に入りのポケモンを削除できる", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); act(() => { result.current.toggleFavorite(25); }); @@ -45,7 +43,7 @@ describe("FavoritesContext", () => { }); it("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); act(() => { result.current.toggleFavorite(25); }); @@ -53,12 +51,12 @@ describe("FavoritesContext", () => { }); it("isFavoriteが未登録のポケモンに対してfalseを返す", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); expect(result.current.isFavorite(25)).toBe(false); }); it("お気に入りが6匹に達している場合、追加できずアラートが表示される", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); act(() => { result.current.toggleFavorite(1); result.current.toggleFavorite(2); @@ -81,7 +79,7 @@ describe("FavoritesContext", () => { }); it("上限に達していても既存のお気に入りは削除できる", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); act(() => { result.current.toggleFavorite(1); result.current.toggleFavorite(2); @@ -99,7 +97,7 @@ describe("FavoritesContext", () => { }); it("isFullが上限到達時にtrueを返す", () => { - const { result } = renderHook(() => useFavorites(), { wrapper }); + const { result } = renderHook(() => useFavorites()); expect(result.current.isFull).toBe(false); act(() => { @@ -112,13 +110,5 @@ describe("FavoritesContext", () => { }); expect(result.current.isFull).toBe(true); }); - - it("Provider外でuseFavoritesを呼ぶとエラーになる", () => { - jest.spyOn(console, "error").mockImplementation(() => {}); - expect(() => { - renderHook(() => useFavorites()); - }).toThrow("useFavorites must be used within a FavoritesProvider"); - jest.restoreAllMocks(); - }); }); }); diff --git a/app/_layout.tsx b/app/_layout.tsx index fc9fa98..3612819 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { Stack } from "expo-router"; import { I18nextProvider, useTranslation } from "react-i18next"; import * as SplashScreen from "expo-splash-screen"; -import { FavoritesProvider, i18n, initI18n } from "@/src/shared"; +import { i18n, initI18n } from "@/src/shared"; import { AnimatedSplash } from "@/src/splash"; SplashScreen.preventAutoHideAsync(); @@ -25,11 +25,9 @@ function RootLayoutNav() { export default function RootLayout() { return ( - - - - - + + + ); } diff --git a/package.json b/package.json index 262fdf4..e115fc0 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.23.0", "react-native-web": "~0.21.0", - "react-native-worklets": "0.7.4" + "react-native-worklets": "0.7.4", + "zustand": "^5.0.12" }, "expo": { "install": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88eb1d6..bd9cd30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: react-native-worklets: specifier: 0.7.4 version: 0.7.4(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) devDependencies: '@testing-library/react-native': specifier: ^13.3.3 @@ -4858,6 +4861,24 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@babel/code-frame@7.29.0': @@ -10869,3 +10890,9 @@ snapshots: yocto-queue@0.1.0: {} zod@3.25.76: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) diff --git a/src/shared/contexts/FavoritesContext.tsx b/src/shared/contexts/FavoritesContext.tsx deleted file mode 100644 index 593468e..0000000 --- a/src/shared/contexts/FavoritesContext.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { createContext, useCallback, useContext, useMemo, useState } from "react"; -import type { ReactNode } from "react"; -import { Alert } from "react-native"; -import { i18n } from "../i18n"; - -const MAX_FAVORITES = 6; - -interface FavoritesContextValue { - favoriteIds: number[]; - toggleFavorite: (id: number) => void; - isFavorite: (id: number) => boolean; - isFull: boolean; -} - -const FavoritesContext = createContext(null); - -export function FavoritesProvider({ children }: { children: ReactNode }) { - const [favoriteIds, setFavoriteIds] = useState([]); - - const toggleFavorite = useCallback((id: number) => { - setFavoriteIds((prev) => { - if (prev.includes(id)) { - return prev.filter((fid) => fid !== id); - } - if (prev.length >= MAX_FAVORITES) { - Alert.alert(i18n.t("favorites.limitTitle"), i18n.t("favorites.limitMessage")); - return prev; - } - return [...prev, id]; - }); - }, []); - - const isFavorite = useCallback( - (id: number) => favoriteIds.includes(id), - [favoriteIds], - ); - - const isFull = favoriteIds.length >= MAX_FAVORITES; - - const value = useMemo( - () => ({ favoriteIds, toggleFavorite, isFavorite, isFull }), - [favoriteIds, toggleFavorite, isFavorite, isFull], - ); - - return ( - - {children} - - ); -} - -export function useFavorites(): FavoritesContextValue { - const context = useContext(FavoritesContext); - if (context === null) { - throw new Error("useFavorites must be used within a FavoritesProvider"); - } - return context; -} diff --git a/src/shared/index.ts b/src/shared/index.ts index 9f066a7..1efddfb 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,7 +1,7 @@ export { i18n, initI18n, SUPPORTED_LANGUAGES, STORAGE_KEY } from "./i18n"; export type { SupportedLanguage } from "./i18n"; export { useLanguage } from "./i18n"; -export { FavoritesProvider, useFavorites } from "./contexts/FavoritesContext"; +export { useFavorites, useFavoritesStore } from "./stores/useFavoritesStore"; export { PokemonCard } from "./components/PokemonCard"; export { FavoriteButton } from "./components/FavoriteButton"; export { typeColors } from "./domain/typeColors"; diff --git a/src/shared/stores/useFavoritesStore.ts b/src/shared/stores/useFavoritesStore.ts new file mode 100644 index 0000000..77315d2 --- /dev/null +++ b/src/shared/stores/useFavoritesStore.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; +import { Alert } from "react-native"; +import { i18n } from "../i18n"; + +const MAX_FAVORITES = 6; + +interface FavoritesStoreState { + favoriteIds: number[]; + toggleFavorite: (id: number) => void; +} + +interface FavoritesValue extends FavoritesStoreState { + isFavorite: (id: number) => boolean; + isFull: boolean; +} + +export const useFavoritesStore = create((set, get) => ({ + favoriteIds: [], + + toggleFavorite: (id: number) => { + const { favoriteIds } = get(); + if (favoriteIds.includes(id)) { + set({ favoriteIds: favoriteIds.filter((fid) => fid !== id) }); + return; + } + if (favoriteIds.length >= MAX_FAVORITES) { + Alert.alert(i18n.t("favorites.limitTitle"), i18n.t("favorites.limitMessage")); + return; + } + set({ favoriteIds: [...favoriteIds, id] }); + }, +})); + +export function useFavorites(): FavoritesValue { + const { favoriteIds, toggleFavorite } = useFavoritesStore(); + return { + favoriteIds, + toggleFavorite, + isFavorite: (id: number) => favoriteIds.includes(id), + isFull: favoriteIds.length >= MAX_FAVORITES, + }; +}