init
This commit is contained in:
24
src/features/react-query-demo/api/queries.ts
Normal file
24
src/features/react-query-demo/api/queries.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
export type Pokemon = {
|
||||
id: number;
|
||||
name: string;
|
||||
sprites: {
|
||||
front_shiny: string;
|
||||
front_default: string;
|
||||
};
|
||||
types: { type: { name: string } }[];
|
||||
stats: { base_stat: number; stat: { name: string } }[];
|
||||
height: number;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export const pokemonOptions = (id: number = 25) =>
|
||||
queryOptions({
|
||||
queryKey: ['pokemon', id],
|
||||
queryFn: async (): Promise<Pokemon> => {
|
||||
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch pokemon');
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
99
src/features/react-query-demo/components/pokemon-info.tsx
Normal file
99
src/features/react-query-demo/components/pokemon-info.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { pokemonOptions } from '../api/queries';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
const POKEMON_IDS = [25, 1, 4, 7, 6, 150, 133, 39, 143, 94];
|
||||
|
||||
export function PokemonInfo() {
|
||||
const [pokemonId, setPokemonId] = useState(25);
|
||||
const { data } = useSuspenseQuery(pokemonOptions(pokemonId));
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Pokemon selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pick a Pokemon</CardTitle>
|
||||
<CardDescription>
|
||||
Each selection triggers <code>useSuspenseQuery</code> — cached results are instant, new
|
||||
fetches show the Suspense fallback.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{POKEMON_IDS.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={pokemonId === id ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={() => setPokemonId(id)}
|
||||
>
|
||||
#{id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pokemon card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-3'>
|
||||
<CardTitle className='capitalize'>{data.name}</CardTitle>
|
||||
<div className='flex gap-1'>
|
||||
{data.types.map(({ type }) => (
|
||||
<Badge key={type.name} variant='secondary'>
|
||||
{type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Height: {data.height / 10}m · Weight: {data.weight / 10}kg
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col items-center gap-6 sm:flex-row'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={data.sprites.front_shiny}
|
||||
alt={data.name}
|
||||
width={160}
|
||||
height={160}
|
||||
className='bg-muted/50 rounded-lg'
|
||||
/>
|
||||
<div className='flex-1 space-y-3'>
|
||||
{data.stats.map((s) => (
|
||||
<div key={s.stat.name} className='space-y-1'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-muted-foreground capitalize'>{s.stat.name}</span>
|
||||
<span className='font-medium'>{s.base_stat}</span>
|
||||
</div>
|
||||
<Progress value={Math.min(s.base_stat, 150) / 1.5} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Data from PokeAPI · Prefetched on server, hydrated on client
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/card';
|
||||
|
||||
export function PokemonSkeleton() {
|
||||
return (
|
||||
<div className='animate-pulse space-y-6'>
|
||||
{/* Selector card skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='bg-muted h-6 w-40 rounded' />
|
||||
<div className='bg-muted mt-2 h-4 w-72 rounded' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className='bg-muted h-9 w-14 rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pokemon card skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-muted h-7 w-32 rounded' />
|
||||
<div className='bg-muted h-5 w-16 rounded-full' />
|
||||
</div>
|
||||
<div className='bg-muted mt-2 h-4 w-48 rounded' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col items-center gap-6 sm:flex-row'>
|
||||
<div className='bg-muted size-40 rounded-lg' />
|
||||
<div className='w-full flex-1 space-y-3'>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className='space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<div className='bg-muted h-4 w-24 rounded' />
|
||||
<div className='bg-muted h-4 w-8 rounded' />
|
||||
</div>
|
||||
<div className='bg-muted h-2 w-full rounded-full' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className='bg-muted h-3 w-64 rounded' />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/features/react-query-demo/info-content.ts
Normal file
46
src/features/react-query-demo/info-content.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { InfobarContent } from '@/components/ui/infobar';
|
||||
|
||||
export const reactQueryInfoContent: InfobarContent = {
|
||||
title: 'React Query Pattern',
|
||||
sections: [
|
||||
{
|
||||
title: 'Server Prefetch',
|
||||
description:
|
||||
'Data is prefetched on the server using getQueryClient().prefetchQuery(). The dehydrated state is passed to HydrationBoundary so the client starts with cached data — no loading spinners on first load.',
|
||||
links: [
|
||||
{
|
||||
title: 'TanStack Query SSR Docs',
|
||||
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Query Options',
|
||||
description:
|
||||
'Query keys and fetch functions are defined in a shared queryOptions() object. This is reused across server prefetch and client hooks, keeping them in sync.',
|
||||
links: [
|
||||
{
|
||||
title: 'queryOptions API',
|
||||
url: 'https://tanstack.com/query/latest/docs/framework/react/reference/queryOptions'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Suspense Query',
|
||||
description:
|
||||
'The client uses useSuspenseQuery() which integrates with React Suspense. Combined with server prefetch, data is available immediately — Suspense only shows the fallback on subsequent navigations if the cache is stale.',
|
||||
links: []
|
||||
},
|
||||
{
|
||||
title: 'Optimistic Mutations',
|
||||
description:
|
||||
'Mutations use onMutate to optimistically update the cache before the request completes. On error, the previous state is rolled back. On settle, the query is invalidated to refetch fresh data.',
|
||||
links: [
|
||||
{
|
||||
title: 'Optimistic Updates Guide',
|
||||
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user