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,
+ };
+}