From fffd36268a216044523c3f5227c3d375608c36dc Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 14 Feb 2026 12:17:23 +0900 Subject: refactor(frontend): migrate state management to jotai and jotai-tanstack-query Replace React Context + manual useEffect data fetching with jotai atoms for state management and jotai-tanstack-query for server state caching. - Add jotai, jotai-tanstack-query, @tanstack/query-core dependencies - Create atoms for auth (primitive + action), feeds (suspense query), and articles (infinite query with cursor-based pagination) - Wire up Provider, HydrateQueryClient, and StoreInitializer in main.tsx - Migrate all components from useAuth() context to jotai atoms - Replace manual fetch logic in FeedSidebar/FeedList with feedsAtom - Replace usePaginatedArticles hook with articlesInfiniteAtom - Add queryClient.invalidateQueries() after mutations for automatic cache refresh - Add ErrorBoundary and LoadingSpinner components for Suspense support - Remove callback prop chains (onFeedAdded, onFeedChanged, etc.) in favor of query invalidation Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/Login.tsx | 16 ++--- frontend/src/pages/ReadArticles.tsx | 109 ++++++++++++++++++++++++---------- frontend/src/pages/Settings.tsx | 21 +++---- frontend/src/pages/UnreadArticles.tsx | 109 ++++++++++++++++++++++++---------- 4 files changed, 172 insertions(+), 83 deletions(-) (limited to 'frontend/src/pages') diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 76a775a..7dc71e7 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,13 +1,14 @@ +import { useSetAtom } from "jotai"; import { useState } from "react"; import { useLocation } from "wouter"; -import { useAuth } from "../contexts/AuthContext"; +import { loginAtom } from "../atoms"; export function Login() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); - const { login } = useAuth(); + const login = useSetAtom(loginAtom); const [, setLocation] = useLocation(); const handleSubmit = async (e: React.FormEvent) => { @@ -15,13 +16,14 @@ export function Login() { setError(""); setIsLoading(true); - const result = await login(username, password); - if (result.success) { + try { + await login({ username, password }); setLocation("/"); - } else { - setError(result.error); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setIsLoading(false); } - setIsLoading(false); }; return ( diff --git a/frontend/src/pages/ReadArticles.tsx b/frontend/src/pages/ReadArticles.tsx index 2538446..e231906 100644 --- a/frontend/src/pages/ReadArticles.tsx +++ b/frontend/src/pages/ReadArticles.tsx @@ -1,48 +1,91 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { Suspense, useEffect } from "react"; import { useSearch } from "wouter"; -import { ArticleList, FeedSidebar } from "../components"; -import { usePaginatedArticles } from "../hooks/usePaginatedArticles"; +import { + articleFeedFilterAtom, + articlesInfiniteAtom, + articleViewAtom, +} from "../atoms"; +import { ArticleList } from "../components/ArticleList"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { FeedSidebar } from "../components/FeedSidebar"; +import { LoadingSpinner } from "../components/LoadingSpinner"; export function ReadArticles() { const search = useSearch(); const params = new URLSearchParams(search); const feedId = params.get("feed"); - const { articles, hasNextPage, loading, loadingMore, loadMore, error } = - usePaginatedArticles({ isReadView: true, feedId }); + const setView = useSetAtom(articleViewAtom); + const setFeedFilter = useSetAtom(articleFeedFilterAtom); + + useEffect(() => { + setView("read"); + setFeedFilter(feedId); + }, [feedId, setView, setFeedFilter]); return (
- + + }> + + +
-
-

Read

- {!loading && articles.length > 0 && ( -

- {articles.length} - {hasNextPage ? "+" : ""} article - {articles.length !== 1 ? "s" : ""} -

- )} -
- {loading ? ( -
-

Loading read articles...

-
- ) : error ? ( -
- Error: {error.message} -
- ) : ( - - )} +
); } + +function ReadArticleList({ feedId }: { feedId: string | null }) { + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + error, + } = useAtomValue(articlesInfiniteAtom); + + const articles = data?.pages.flatMap((page) => page.articles) ?? []; + + if (isLoading) { + return ( +
+

Loading read articles...

+
+ ); + } + + if (error) { + return ( +
+ Error: {error.message} +
+ ); + } + + return ( + <> +
+

Read

+ {articles.length > 0 && ( +

+ {articles.length} + {hasNextPage ? "+" : ""} article + {articles.length !== 1 ? "s" : ""} +

+ )} +
+ fetchNextPage()} + /> + + ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c179fab..48a1f8e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,24 +1,25 @@ -import { useCallback, useState } from "react"; -import { AddFeedForm, FeedList } from "../components"; +import { Suspense } from "react"; +import { AddFeedForm } from "../components/AddFeedForm"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { FeedList } from "../components/FeedList"; +import { LoadingSpinner } from "../components/LoadingSpinner"; export function Settings() { - const [refreshKey, setRefreshKey] = useState(0); - - const handleChange = useCallback(() => { - setRefreshKey((k) => k + 1); - }, []); - return (
- +

Your Feeds

- + + }> + + +
); diff --git a/frontend/src/pages/UnreadArticles.tsx b/frontend/src/pages/UnreadArticles.tsx index eade6fc..291f0ee 100644 --- a/frontend/src/pages/UnreadArticles.tsx +++ b/frontend/src/pages/UnreadArticles.tsx @@ -1,48 +1,91 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { Suspense, useEffect } from "react"; import { useSearch } from "wouter"; -import { ArticleList, FeedSidebar } from "../components"; -import { usePaginatedArticles } from "../hooks/usePaginatedArticles"; +import { + articleFeedFilterAtom, + articlesInfiniteAtom, + articleViewAtom, +} from "../atoms"; +import { ArticleList } from "../components/ArticleList"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { FeedSidebar } from "../components/FeedSidebar"; +import { LoadingSpinner } from "../components/LoadingSpinner"; export function UnreadArticles() { const search = useSearch(); const params = new URLSearchParams(search); const feedId = params.get("feed"); - const { articles, hasNextPage, loading, loadingMore, loadMore, error } = - usePaginatedArticles({ isReadView: false, feedId }); + const setView = useSetAtom(articleViewAtom); + const setFeedFilter = useSetAtom(articleFeedFilterAtom); + + useEffect(() => { + setView("unread"); + setFeedFilter(feedId); + }, [feedId, setView, setFeedFilter]); return (
- + + }> + + +
-
-

Unread

- {!loading && articles.length > 0 && ( -

- {articles.length} - {hasNextPage ? "+" : ""} article - {articles.length !== 1 ? "s" : ""} to read -

- )} -
- {loading ? ( -
-

Loading unread articles...

-
- ) : error ? ( -
- Error: {error.message} -
- ) : ( - - )} +
); } + +function UnreadArticleList({ feedId }: { feedId: string | null }) { + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + error, + } = useAtomValue(articlesInfiniteAtom); + + const articles = data?.pages.flatMap((page) => page.articles) ?? []; + + if (isLoading) { + return ( +
+

Loading unread articles...

+
+ ); + } + + if (error) { + return ( +
+ Error: {error.message} +
+ ); + } + + return ( + <> +
+

Unread

+ {articles.length > 0 && ( +

+ {articles.length} + {hasNextPage ? "+" : ""} article + {articles.length !== 1 ? "s" : ""} to read +

+ )} +
+ fetchNextPage()} + /> + + ); +} -- cgit v1.3-1-g0d28