Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
# TanStack Query: Intelligentes Data Fetching für React
|
||||
|
||||
**Meta-Description:** TanStack Query (React Query) für Server State Management. Caching, Background Refetch, Optimistic Updates und Best Practices.
|
||||
|
||||
**Keywords:** TanStack Query, React Query, Data Fetching, Server State, Caching, React, API Client, SWR
|
||||
|
||||
---
|
||||
|
||||
## Einführung
|
||||
|
||||
TanStack Query (ehemals React Query) löst das schwerste Problem in React: **Server State Management**. Statt manuelles Fetching, Loading States und Caching zu bauen, bietet es eine deklarative, caching-first Lösung.
|
||||
|
||||
---
|
||||
|
||||
## Warum TanStack Query?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SERVER STATE CHALLENGES │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Ohne TanStack Query: │
|
||||
│ ├── Manuelles Loading/Error State │
|
||||
│ ├── Keine automatische Cache-Invalidierung │
|
||||
│ ├── Duplizierte Requests │
|
||||
│ ├── Kein Background Refetch │
|
||||
│ ├── Komplizierte Pagination │
|
||||
│ └── Race Conditions │
|
||||
│ │
|
||||
│ Mit TanStack Query: │
|
||||
│ ├── Automatisches Loading/Error Handling │
|
||||
│ ├── Intelligentes Caching │
|
||||
│ ├── Request Deduplication │
|
||||
│ ├── Stale-While-Revalidate │
|
||||
│ ├── Eingebaute Pagination/Infinite Scroll │
|
||||
│ └── Automatische Retry-Logik │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
// app/providers.tsx
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 Minute
|
||||
gcTime: 5 * 60 * 1000, // 5 Minuten (früher cacheTime)
|
||||
retry: 3,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Queries
|
||||
|
||||
### useQuery
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// API Function
|
||||
async function fetchUser(userId: string): Promise<User> {
|
||||
const response = await fetch(`/api/users/${userId}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch user');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Component
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
isFetching, // true auch bei Background Refetch
|
||||
isStale, // Daten sind "veraltet"
|
||||
refetch // Manueller Refetch
|
||||
} = useQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
enabled: !!userId, // Nur fetchen wenn userId existiert
|
||||
staleTime: 5 * 60 * 1000,
|
||||
placeholderData: previousUser // Zeige alte Daten während Refetch
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
if (isError) return <Error message={error.message} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}</h1>
|
||||
{isFetching && <span>Updating...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Query mit abhängigen Daten
|
||||
|
||||
```typescript
|
||||
function UserPosts({ userId }: { userId: string }) {
|
||||
// Erst User laden
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId)
|
||||
});
|
||||
|
||||
// Dann Posts (nur wenn User da)
|
||||
const { data: posts } = useQuery({
|
||||
queryKey: ['posts', user?.id],
|
||||
queryFn: () => fetchUserPosts(user!.id),
|
||||
enabled: !!user // Wartet auf User
|
||||
});
|
||||
|
||||
return (/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mutations
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
function CreatePostForm() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createPost = useMutation({
|
||||
mutationFn: async (newPost: CreatePostInput) => {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newPost)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create post');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Bei Erfolg: Cache invalidieren
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
},
|
||||
|
||||
// Oder: Optimistic Update
|
||||
onMutate: async (newPost) => {
|
||||
// Laufende Queries canceln
|
||||
await queryClient.cancelQueries({ queryKey: ['posts'] });
|
||||
|
||||
// Snapshot des aktuellen States
|
||||
const previousPosts = queryClient.getQueryData(['posts']);
|
||||
|
||||
// Optimistisch updaten
|
||||
queryClient.setQueryData(['posts'], (old: Post[]) => [
|
||||
{ id: 'temp', ...newPost },
|
||||
...old
|
||||
]);
|
||||
|
||||
return { previousPosts };
|
||||
},
|
||||
|
||||
onError: (err, newPost, context) => {
|
||||
// Bei Fehler: Rollback
|
||||
queryClient.setQueryData(['posts'], context?.previousPosts);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Immer am Ende: Refetch für Konsistenz
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: FormData) => {
|
||||
createPost.mutate({
|
||||
title: data.get('title') as string,
|
||||
content: data.get('content') as string
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="title" />
|
||||
<textarea name="content" />
|
||||
<button disabled={createPost.isPending}>
|
||||
{createPost.isPending ? 'Erstellen...' : 'Post erstellen'}
|
||||
</button>
|
||||
{createPost.isError && <Error message={createPost.error.message} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pagination & Infinite Scroll
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
function PaginatedPosts() {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isPlaceholderData } = useQuery({
|
||||
queryKey: ['posts', page],
|
||||
queryFn: () => fetchPosts(page),
|
||||
placeholderData: keepPreviousData // Zeigt alte Daten während neuer Page lädt
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data?.posts.map(post => <PostCard key={post.id} post={post} />)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(p - 1, 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<span>Seite {page}</span>
|
||||
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={isPlaceholderData || !data?.hasMore}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```typescript
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
function InfinitePosts() {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['posts', 'infinite'],
|
||||
queryFn: ({ pageParam }) => fetchPosts(pageParam),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.nextPage : undefined
|
||||
});
|
||||
|
||||
// Auto-load bei Scroll
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data?.pages.map(page =>
|
||||
page.posts.map(post => <PostCard key={post.id} post={post} />)
|
||||
)}
|
||||
|
||||
<div ref={ref}>
|
||||
{isFetchingNextPage
|
||||
? <Spinner />
|
||||
: hasNextPage
|
||||
? 'Mehr laden...'
|
||||
: 'Keine weiteren Posts'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prefetching
|
||||
|
||||
```typescript
|
||||
// Hover Prefetch
|
||||
function PostLink({ postId }: { postId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const prefetchPost = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
staleTime: 60 * 1000
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${postId}`}
|
||||
onMouseEnter={prefetchPost}
|
||||
onFocus={prefetchPost}
|
||||
>
|
||||
Post anzeigen
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Server-Side Prefetch (Next.js)
|
||||
export async function getServerSideProps() {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: fetchPosts
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
dehydratedState: dehydrate(queryClient)
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Invalidation
|
||||
|
||||
```typescript
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Einzelnen Query invalidieren
|
||||
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
||||
|
||||
// Alle User-Queries
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
|
||||
// Alle Queries
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
// Mit Predicate
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === 'post' &&
|
||||
(query.queryKey[1] as Post)?.authorId === userId
|
||||
});
|
||||
|
||||
// Nur refetchen wenn aktiv (sichtbar)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['posts'],
|
||||
refetchType: 'active' // 'all' | 'active' | 'inactive' | 'none'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Suspense Mode
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
// Wirft Promise während Loading (für Suspense)
|
||||
const { data: user } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId)
|
||||
});
|
||||
|
||||
// user ist garantiert da (kein undefined)
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
|
||||
// Parent
|
||||
function UserPage({ userId }: { userId: string }) {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<UserProfile userId={userId} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Hooks Pattern
|
||||
|
||||
```typescript
|
||||
// hooks/useUser.ts
|
||||
export function useUser(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
enabled: !!userId
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateUser,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(['user', variables.id], data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Verwendung
|
||||
function Profile({ userId }: { userId: string }) {
|
||||
const { data: user, isLoading } = useUser(userId);
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DevTools & Debugging
|
||||
|
||||
```typescript
|
||||
// Query Logging
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
onError: (error) => {
|
||||
console.error('Query error:', error);
|
||||
// Sentry, LogRocket, etc.
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
console.error('Mutation error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DevTools
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-right"
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
TanStack Query bietet:
|
||||
|
||||
1. **Automatisches Caching**: Keine manuelle Cache-Logik
|
||||
2. **Background Refetch**: Daten bleiben frisch
|
||||
3. **Optimistic Updates**: Schnelle UX
|
||||
4. **DevTools**: Debugging leicht gemacht
|
||||
|
||||
Für jede App mit Server-Daten ist TanStack Query unverzichtbar.
|
||||
|
||||
---
|
||||
|
||||
## Bildprompts
|
||||
|
||||
1. "Data flowing from server to UI with caching layer in between, architectural diagram"
|
||||
2. "Stale data refreshing in background, real-time update visualization"
|
||||
3. "Query cache tree with multiple components accessing same data, efficiency concept"
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [TanStack Query Documentation](https://tanstack.com/query)
|
||||
- [TanStack Query GitHub](https://github.com/TanStack/query)
|
||||
- [Practical React Query](https://tkdodo.eu/blog/practical-react-query)
|
||||
- [React Query DevTools](https://tanstack.com/query/latest/docs/framework/react/devtools)
|
||||
Reference in New Issue
Block a user