diff --git a/apps/1-6-vite-tanstack-router/.gitignore b/apps/1-6-vite-tanstack-router/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/1-6-vite-tanstack-router/index.html b/apps/1-6-vite-tanstack-router/index.html new file mode 100644 index 0000000..96791df --- /dev/null +++ b/apps/1-6-vite-tanstack-router/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React Query Hooks Refactor + + +
+ + + diff --git a/apps/1-6-vite-tanstack-router/package.json b/apps/1-6-vite-tanstack-router/package.json new file mode 100644 index 0000000..9340cd7 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/package.json @@ -0,0 +1,41 @@ +{ + "name": "1-6-vite-tanstack-router", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 3316", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview --port 3316" + }, + "dependencies": { + "@mantine/core": "8.1.3", + "@mantine/hooks": "8.1.3", + "@tabler/icons-react": "3.34.0", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", + "@tanstack/react-router": "^1.106.2", + "@tanstack/router-devtools": "^1.106.2", + "mantine-react-table": "^2.0.0-beta.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-error-boundary": "^6.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@typescript-eslint/eslint-plugin": "^8.37.0", + "@typescript-eslint/parser": "^8.37.0", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.31.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "postcss": "^8.5.6", + "postcss-preset-mantine": "1.18.0", + "postcss-simple-vars": "^7.0.1", + "typescript": "^5.8.3", + "vite": "^7.0.5" + } +} diff --git a/apps/1-6-vite-tanstack-router/postcss.config.cjs b/apps/1-6-vite-tanstack-router/postcss.config.cjs new file mode 100644 index 0000000..be8ba93 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; \ No newline at end of file diff --git a/apps/1-6-vite-tanstack-router/public/vite.svg b/apps/1-6-vite-tanstack-router/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/1-6-vite-tanstack-router/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/1-6-vite-tanstack-router/src/App.tsx b/apps/1-6-vite-tanstack-router/src/App.tsx new file mode 100644 index 0000000..3a728fa --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/App.tsx @@ -0,0 +1,7 @@ +import { AppProviders } from "./AppProviders"; +import "@mantine/core/styles.css"; +import "mantine-react-table/styles.css"; + +export const App = () => { + return ; +}; diff --git a/apps/1-6-vite-tanstack-router/src/AppLayout.tsx b/apps/1-6-vite-tanstack-router/src/AppLayout.tsx new file mode 100644 index 0000000..76801d8 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/AppLayout.tsx @@ -0,0 +1,110 @@ +import { + Anchor, + AppShell, + Breadcrumbs, + Burger, + Group, + Stack, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconHome, IconUsersGroup } from "@tabler/icons-react"; +import { useMemo } from "react"; +import { Link, useRouter } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { usersQueryOptions } from "./queries/users"; +import { postsQueryOptions } from "./queries/posts"; + +interface AppLayoutProps { + children: React.ReactNode; +} + +export function AppLayout({ children }: AppLayoutProps) { + const router = useRouter(); + const pathname = router.state.location.pathname; + const queryClient = useQueryClient(); + + const links = [ + { + icon: , + color: "blue", + label: "Home Feed", + href: "/", + prefetch: () => queryClient.prefetchQuery(postsQueryOptions), + }, + { + icon: , + color: "teal", + label: "Users", + href: "/users", + prefetch: () => queryClient.prefetchQuery(usersQueryOptions), + }, + ]; + + const breadCrumbLinks = useMemo(() => { + const routes = pathname.split("/"); + routes.shift(); + const links: string[] = []; + for (let i = 0; i < routes.length + 1; i++) { + if (routes[i] && routes[i] !== "/") + if (routes[i] === "posts") { + links.push(`/`); + } else links.push(`/${routes.slice(0, i + 1).join("/")}`); + } + return links; + }, [pathname]); + + if (breadCrumbLinks.length === 1) { + breadCrumbLinks.unshift("/"); + } + + const [opened, { toggle }] = useDisclosure(); + + return ( + + + + + Vite with React Query Hooks Refactor + + + + {links.map((link) => ( + + {link.label} + + ))} + + + + + {breadCrumbLinks.map((link, index) => ( + + {link === "/" ? "Home Feed" : link.split("/").pop()} + + ))} + + {children} + + + + ); +} diff --git a/apps/1-6-vite-tanstack-router/src/AppProviders.tsx b/apps/1-6-vite-tanstack-router/src/AppProviders.tsx new file mode 100644 index 0000000..ef11e06 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/AppProviders.tsx @@ -0,0 +1,111 @@ +import { lazy, Suspense } from "react"; +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createRouter, + RouterProvider, + createRootRoute, + createRoute, + Outlet +} from "@tanstack/react-router"; +import { theme } from "./theme"; +import { AppLayout } from "./AppLayout"; +import { HomePage } from "./pages/HomePage"; +import { UsersPage } from "./pages/UsersPage"; +import { UserPage } from "./pages/UserPage"; +import { PostPage } from "./pages/PostPage"; + +const ReactQueryDevtoolsProduction = lazy(() => + import("@tanstack/react-query-devtools/production").then((d) => ({ + default: d.ReactQueryDevtools, + })), +); + +const TanStackRouterDevtools = + typeof window !== "undefined" + ? lazy(() => + // Lazy load in development + import("@tanstack/router-devtools").then((res) => ({ + default: res.TanStackRouterDevtools, + })), + ) + : () => null; // Render nothing on server side + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 10, // 10 seconds + }, + }, +}); + +// Create routes +const rootRoute = createRootRoute({ + component: () => ( + + + + ), +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomePage, +}); + +const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: UsersPage, +}); + +const userRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users/$id', + component: UserPage, +}); + +const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$id', + component: PostPage, +}); + +// Create the route tree +const routeTree = rootRoute.addChildren([ + indexRoute, + usersRoute, + userRoute, + postRoute, +]); + +// Create a new router instance +const router = createRouter({ routeTree }); + +// Register the router instance for type safety +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +interface Props { + // children prop is not needed since router handles all rendering +} + +export const AppProviders = ({}: Props) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/apps/1-6-vite-tanstack-router/src/api-types.ts b/apps/1-6-vite-tanstack-router/src/api-types.ts new file mode 100644 index 0000000..49e5816 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/api-types.ts @@ -0,0 +1,38 @@ +export interface IUser { + id: number; + name: string; + username: string; + email: string; + address: { + street: string; + suite: string; + city: string; + zipcode: string; + geo: { + lat: string; + lng: string; + }; + }; + phone: string; + website: string; + company: { + name: string; + catchPhrase: string; + bs: string; + }; +} + +export interface IPost { + userId: number; + id: number; + title: string; + body: string; +} + +export interface IComment { + postId: number; + id: number; + name: string; + email: string; + body: string; +} diff --git a/apps/1-6-vite-tanstack-router/src/main.tsx b/apps/1-6-vite-tanstack-router/src/main.tsx new file mode 100644 index 0000000..65c0deb --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/1-6-vite-tanstack-router/src/pages/HomePage.tsx b/apps/1-6-vite-tanstack-router/src/pages/HomePage.tsx new file mode 100644 index 0000000..7e16048 --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/pages/HomePage.tsx @@ -0,0 +1,76 @@ +import { Suspense } from "react"; +import { Alert, Card, Flex, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { Link } from "@tanstack/react-router"; +import { IconAlertCircle } from "@tabler/icons-react"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { postsQueryOptions } from "../queries/posts"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { ErrorBoundary } from "react-error-boundary"; + +export function HomePage() { + return ( + + Your Home Feed + + + {({ reset }) => ( + } + onReset={reset} + > + }> + + + + )} + + + + ); +} + +function HomePageSkeleton() { + return [...Array(5)].map((_, index) => ( + + + + + )); +} + +function HomePageError() { + return ( + } title="Bummer!" color="red"> + There was an error fetching posts + + ); +} + +function HomePagePosts() { + const { data: posts } = useSuspenseQuery(postsQueryOptions); + + return posts.map((post) => ( + + + {post.title} + {post.body} + + Go to post + + + + )); +} diff --git a/apps/1-6-vite-tanstack-router/src/pages/PostPage.tsx b/apps/1-6-vite-tanstack-router/src/pages/PostPage.tsx new file mode 100644 index 0000000..fa7233d --- /dev/null +++ b/apps/1-6-vite-tanstack-router/src/pages/PostPage.tsx @@ -0,0 +1,276 @@ +import { useCallback, useState } from "react"; +import { Link, useParams } from "@tanstack/react-router"; +import { + ActionIcon, + Alert, + Box, + Button, + Card, + Collapse, + Flex, + Loader, + Skeleton, + Stack, + Text, + Textarea, + Title, + Tooltip, +} from "@mantine/core"; +import { IconAlertCircle, IconRefresh, IconTrash } from "@tabler/icons-react"; +import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; +import { IComment } from "../api-types"; +import { postQueryOptions } from "../queries/posts"; +import { postCommentsQueryOptions } from "../queries/comments"; +import { userQueryOptions } from "../queries/users"; + +export const PostPage = () => { + const queryClient = useQueryClient(); + const { id: postId } = useParams({ from: "/posts/$id" }); + + //load post + const { + data: post, + isLoading: isLoadingPost, + isError: isErrorLoadingPosts, + } = useQuery(postQueryOptions(postId)); + + //load user - depends on user id from post + const { + data: user, + isLoading: isLoadingUser, + isError: isErrorLoadingUser, + } = useQuery({ + ...userQueryOptions(post?.userId!), + enabled: !!post?.userId, + }); + + //load comments + const { + data: comments, + isLoading: isLoadingComments, + isFetching: isFetchingComments, + isError: isErrorLoadingComments, + refetch: refetchComments, + } = useQuery(postCommentsQueryOptions(postId)); + + //delete comment, refresh comments after delete + const { + mutate: deleteComment, + isPending: isDeletingComment, + context: deletingCommentContext, + } = useMutation({ + mutationFn: async (commentId: number) => { + const response = await fetch( + `http://localhost:3300/comments/${commentId}`, + { + method: "DELETE", + }, + ); + return response.json() as Promise; + }, + //record which comment is being deleted so we can give it lower opacity + onMutate: async (commentId) => ({ commentId }), + onError: (err, commentId) => { + console.error( + `Error deleting comment ${commentId}. Rolling UI back`, + err, + ); + alert("Error deleting comment"); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: postCommentsQueryOptions(postId).queryKey, + }); //refresh comments + }, + }); + + // Post new comment - with optimistic updates! + const [commentText, setCommentText] = useState(""); + + const { mutate: postComment, isPending: isPostingComment } = useMutation({ + mutationFn: async (comment: Omit) => { + const response = await fetch(`http://localhost:3300/comments`, { + method: "POST", + body: JSON.stringify(comment), + headers: { + "Content-type": "application/json; charset=UTF-8", + }, + }); + return response.json() as Promise; + }, + //optimistic client-side update + onMutate: async (newComment) => { + await queryClient.cancelQueries({ + queryKey: postCommentsQueryOptions(postId).queryKey, + }); + + // Snapshot the previous value + const previousComments = queryClient.getQueryData([ + "comments", + newComment.postId.toString(), + ]); + + // Optimistically update to the new value + queryClient.setQueryData( + postCommentsQueryOptions(postId).queryKey, + (oldComments: any) => [...oldComments, newComment], + ); + + // Return a context object with the snapshot value + return { previousComments }; + }, + // If the mutation fails, + // use the context returned from onMutate to roll back + onError: (err, _newComment, context) => { + queryClient.setQueryData( + postCommentsQueryOptions(postId).queryKey, + context?.previousComments as IComment[], + ); + console.error("Error posting comment. Rolling UI back", err); + }, + onSuccess: () => { + setCommentText(""); //clear comment text + }, + // Always refetch after error or success: + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: postCommentsQueryOptions(postId).queryKey, + }); + }, + }); + + const handleSubmitComment = useCallback(async () => { + const newComment: Omit = { + body: commentText, + email: "user@mailinator.com", + name: "User", + postId: Number(postId), + }; + postComment(newComment); + }, [commentText, postId, postComment]); + + return ( + + + {isErrorLoadingPosts || isErrorLoadingUser ? ( + } + title="Bummer!" + color="red" + > + There was an error loading this post + + ) : !post || isLoadingPost || isLoadingUser ? ( + <> + + + + ) : ( + <> + Post: {post?.id} + {post?.title} + + By:{" "} + <Link + to="/users/$id" + params={{ id: user?.id?.toString() || "" }} + style={{ textDecoration: "none" }} + > + {user?.name} + </Link> + + + {post.body}. {post.body}. {post.body}. {post.body}. {post.body}. + + + )} + + + + Comments on this Post + + + refetchComments()}> + + + + + + + + + + + {isErrorLoadingComments ? ( + } + title="Bummer!" + color="red" + > + There was an error loading comments for this post + + ) : isLoadingComments ? ( + [...Array(5)].map((_, index) => ( + + + + + + )) + ) : ( + comments?.map((comment) => ( + + {comment.email === "user@mailinator.com" && ( + deleteComment(comment.id)} + > + + + )} + + {comment.name} + {comment.email} + {comment.body} + + )) + )} +