From f8e4be9b36a16969ac53bd9ce12ce8064be10196 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 4 Jan 2026 17:43:59 +0900
Subject: refactor(client): migrate state management from React Context to
 Jotai
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Replace AuthProvider and SyncProvider with Jotai atoms for more granular
state management and better performance. This migration:

- Creates atoms for auth, sync, decks, cards, noteTypes, and study state
- Uses atomFamily for parameterized state (e.g., cards by deckId)
- Introduces StoreInitializer component for subscription initialization
- Updates all components and pages to use useAtomValue/useSetAtom
- Updates all tests to use Jotai Provider with createStore pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 src/client/pages/HomePage.tsx | 265 ++++++++++++++++++------------------------
 1 file changed, 110 insertions(+), 155 deletions(-)

(limited to 'src/client/pages/HomePage.tsx')

diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index ddf97e2..e0e9e9e 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -3,72 +3,121 @@ import {
 	faLayerGroup,
 	faPen,
 	faPlus,
-	faSpinner,
 	faTrash,
 } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useCallback, useEffect, useState } from "react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Suspense, useState, useTransition } from "react";
 import { Link } from "wouter";
-import { ApiClientError, apiClient } from "../api";
+import { type Deck, decksAtom, logoutAtom } from "../atoms";
 import { CreateDeckModal } from "../components/CreateDeckModal";
 import { DeleteDeckModal } from "../components/DeleteDeckModal";
 import { EditDeckModal } from "../components/EditDeckModal";
+import { ErrorBoundary } from "../components/ErrorBoundary";
+import { LoadingSpinner } from "../components/LoadingSpinner";
 import { SyncButton } from "../components/SyncButton";
 import { SyncStatusIndicator } from "../components/SyncStatusIndicator";
-import { useAuth } from "../stores";
 
-interface Deck {
-	id: string;
-	name: string;
-	description: string | null;
-	newCardsPerDay: number;
-	createdAt: string;
-	updatedAt: string;
+function DeckList({
+	onEditDeck,
+	onDeleteDeck,
+}: {
+	onEditDeck: (deck: Deck) => void;
+	onDeleteDeck: (deck: Deck) => void;
+}) {
+	const decks = useAtomValue(decksAtom);
+
+	if (decks.length === 0) {
+		return (
+			<div className="text-center py-16 animate-fade-in">
+				<div className="w-16 h-16 mx-auto mb-4 bg-ivory rounded-2xl flex items-center justify-center">
+					<FontAwesomeIcon
+						icon={faBoxOpen}
+						className="w-8 h-8 text-muted"
+						aria-hidden="true"
+					/>
+				</div>
+				<h3 className="font-display text-lg font-medium text-slate mb-2">
+					No decks yet
+				</h3>
+				<p className="text-muted text-sm mb-6">
+					Create your first deck to start learning
+				</p>
+			</div>
+		);
+	}
+
+	return (
+		<div className="space-y-3 animate-fade-in">
+			{decks.map((deck, index) => (
+				<div
+					key={deck.id}
+					className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200 group"
+					style={{ animationDelay: `${index * 50}ms` }}
+				>
+					<div className="flex items-start justify-between gap-4">
+						<div className="flex-1 min-w-0">
+							<Link
+								href={`/decks/${deck.id}`}
+								className="block group-hover:text-primary transition-colors"
+							>
+								<h3 className="font-display text-lg font-medium text-slate truncate">
+									{deck.name}
+								</h3>
+							</Link>
+							{deck.description && (
+								<p className="text-muted text-sm mt-1 line-clamp-2">
+									{deck.description}
+								</p>
+							)}
+						</div>
+						<div className="flex items-center gap-2 shrink-0">
+							<button
+								type="button"
+								onClick={() => onEditDeck(deck)}
+								className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+								title="Edit deck"
+							>
+								<FontAwesomeIcon
+									icon={faPen}
+									className="w-4 h-4"
+									aria-hidden="true"
+								/>
+							</button>
+							<button
+								type="button"
+								onClick={() => onDeleteDeck(deck)}
+								className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+								title="Delete deck"
+							>
+								<FontAwesomeIcon
+									icon={faTrash}
+									className="w-4 h-4"
+									aria-hidden="true"
+								/>
+							</button>
+						</div>
+					</div>
+				</div>
+			))}
+		</div>
+	);
 }
 
 export function HomePage() {
-	const { logout } = useAuth();
-	const [decks, setDecks] = useState<Deck[]>([]);
-	const [isLoading, setIsLoading] = useState(true);
-	const [error, setError] = useState<string | null>(null);
+	const logout = useSetAtom(logoutAtom);
+	const reloadDecks = useSetAtom(decksAtom);
+	const [, startTransition] = useTransition();
+
 	const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
 	const [editingDeck, setEditingDeck] = useState<Deck | null>(null);
 	const [deletingDeck, setDeletingDeck] = useState<Deck | null>(null);
 
-	const fetchDecks = useCallback(async () => {
-		setIsLoading(true);
-		setError(null);
-
-		try {
-			const res = await apiClient.rpc.api.decks.$get(undefined, {
-				headers: apiClient.getAuthHeader(),
-			});
-
-			if (!res.ok) {
-				const errorBody = await res.json().catch(() => ({}));
-				throw new ApiClientError(
-					(errorBody as { error?: string }).error ||
-						`Request failed with status ${res.status}`,
-					res.status,
-				);
-			}
-
-			const data = await res.json();
-			setDecks(data.decks);
-		} catch (err) {
-			if (err instanceof ApiClientError) {
-				setError(err.message);
-			} else {
-				setError("Failed to load decks. Please try again.");
-			}
-		} finally {
-			setIsLoading(false);
-		}
-	}, []);
-
-	useEffect(() => {
-		fetchDecks();
-	}, [fetchDecks]);
+	const handleDeckMutation = () => {
+		startTransition(() => {
+			reloadDecks();
+		});
+	};
 
 	return (
 		<div className="min-h-screen bg-cream">
@@ -95,7 +144,7 @@ export function HomePage() {
 						</Link>
 						<button
 							type="button"
-							onClick={logout}
+							onClick={() => logout()}
 							className="text-sm text-muted hover:text-slate transition-colors px-3 py-1.5 rounded-lg hover:bg-ivory"
 						>
 							Logout
@@ -125,130 +174,36 @@ export function HomePage() {
 					</button>
 				</div>
 
-				{/* Loading State */}
-				{isLoading && (
-					<div className="flex items-center justify-center py-12">
-						<FontAwesomeIcon
-							icon={faSpinner}
-							className="h-8 w-8 text-primary animate-spin"
-							aria-hidden="true"
+				{/* Deck List with Suspense */}
+				<ErrorBoundary>
+					<Suspense fallback={<LoadingSpinner />}>
+						<DeckList
+							onEditDeck={setEditingDeck}
+							onDeleteDeck={setDeletingDeck}
 						/>
-					</div>
-				)}
-
-				{/* Error State */}
-				{error && (
-					<div
-						role="alert"
-						className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
-					>
-						<span className="text-error">{error}</span>
-						<button
-							type="button"
-							onClick={fetchDecks}
-							className="text-error hover:text-error/80 font-medium text-sm"
-						>
-							Retry
-						</button>
-					</div>
-				)}
-
-				{/* Empty State */}
-				{!isLoading && !error && decks.length === 0 && (
-					<div className="text-center py-16 animate-fade-in">
-						<div className="w-16 h-16 mx-auto mb-4 bg-ivory rounded-2xl flex items-center justify-center">
-							<FontAwesomeIcon
-								icon={faBoxOpen}
-								className="w-8 h-8 text-muted"
-								aria-hidden="true"
-							/>
-						</div>
-						<h3 className="font-display text-lg font-medium text-slate mb-2">
-							No decks yet
-						</h3>
-						<p className="text-muted text-sm mb-6">
-							Create your first deck to start learning
-						</p>
-					</div>
-				)}
-
-				{/* Deck List */}
-				{!isLoading && !error && decks.length > 0 && (
-					<div className="space-y-3 animate-fade-in">
-						{decks.map((deck, index) => (
-							<div
-								key={deck.id}
-								className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200 group"
-								style={{ animationDelay: `${index * 50}ms` }}
-							>
-								<div className="flex items-start justify-between gap-4">
-									<div className="flex-1 min-w-0">
-										<Link
-											href={`/decks/${deck.id}`}
-											className="block group-hover:text-primary transition-colors"
-										>
-											<h3 className="font-display text-lg font-medium text-slate truncate">
-												{deck.name}
-											</h3>
-										</Link>
-										{deck.description && (
-											<p className="text-muted text-sm mt-1 line-clamp-2">
-												{deck.description}
-											</p>
-										)}
-									</div>
-									<div className="flex items-center gap-2 shrink-0">
-										<button
-											type="button"
-											onClick={() => setEditingDeck(deck)}
-											className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
-											title="Edit deck"
-										>
-											<FontAwesomeIcon
-												icon={faPen}
-												className="w-4 h-4"
-												aria-hidden="true"
-											/>
-										</button>
-										<button
-											type="button"
-											onClick={() => setDeletingDeck(deck)}
-											className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
-											title="Delete deck"
-										>
-											<FontAwesomeIcon
-												icon={faTrash}
-												className="w-4 h-4"
-												aria-hidden="true"
-											/>
-										</button>
-									</div>
-								</div>
-							</div>
-						))}
-					</div>
-				)}
+					</Suspense>
+				</ErrorBoundary>
 			</main>
 
 			{/* Modals */}
 			<CreateDeckModal
 				isOpen={isCreateModalOpen}
 				onClose={() => setIsCreateModalOpen(false)}
-				onDeckCreated={fetchDecks}
+				onDeckCreated={handleDeckMutation}
 			/>
 
 			<EditDeckModal
 				isOpen={editingDeck !== null}
 				deck={editingDeck}
 				onClose={() => setEditingDeck(null)}
-				onDeckUpdated={fetchDecks}
+				onDeckUpdated={handleDeckMutation}
 			/>
 
 			<DeleteDeckModal
 				isOpen={deletingDeck !== null}
 				deck={deletingDeck}
 				onClose={() => setDeletingDeck(null)}
-				onDeckDeleted={fetchDecks}
+				onDeckDeleted={handleDeckMutation}
 			/>
 		</div>
 	);
-- 
cgit v1.2.3-70-g09d2

