tl;dr: A React 18 + Vite + React Query v3 project for learning async data fetching patterns. It fetches all 151 original Pokemon in two sequential query stages and renders flippable stat cards. Several real bugs are baked in. This documents what the code actually does, not the idealised version.
The question I was trying to answer: how do you fetch a list of URLs from one endpoint, then fetch all of them in parallel, cancel everything if the user wants out, and cache the results so re-renders don’t trigger the network again?
The stack
- React 18 + Vite 4
react-queryv3 (the pre-TanStack rename)- Axios 1.3.4
- MUI v5
- Framer Motion 10
react-card-flip(installed, never shipped)
The data model
PokéAPI is a two-level API. The index endpoint gives you a name and a URL:
1GET /api/v2/pokemon?limit=151
2→ { results: [{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }, ...] }
The detail endpoint gives you everything — sprites, types, stats, abilities, height, weight:
1GET /api/v2/pokemon/1/
2→ { id: 1, name: "bulbasaur", sprites: {...}, types: [...], stats: [...], ... }
Fetching the full data for 151 Pokemon requires 152 HTTP requests: one for the list, then one for each individual Pokemon. The goal was to run those 151 detail requests in parallel rather than sequentially.
The two-stage dependent query
The core architecture is two useQuery calls chained on each other:
1const {
2 data: urls,
3 isLoading: isUrlsLoading,
4 isError: isUrlsError,
5} = useQuery("urls", () => fetchURL({ signal: abortController.signal }), {
6 refetchOnWindowFocus: !isCanceled,
7 staleTime: 60000,
8 keepPreviousData: true,
9});
10
11const {
12 data: contents,
13 isLoading: isContentsLoading,
14 isError: isContentError,
15} = useQuery(
16 ["contents", urls],
17 () =>
18 Promise.all(
19 urls.map(
20 (pokemon) => fetchContents(pokemon.url, abortController.signal)
21 )
22 ),
23 {
24 enabled: !!urls,
25 }
26);
The second query has enabled: !!urls, which means it will not fire until urls resolves to a truthy value. Once it does, Promise.all() fires 151 requests simultaneously. React Query treats the result as a single cached value keyed to ["contents", urls]. This is the correct pattern and it works.
staleTime: 60000 on the first query means the list of 151 Pokemon URLs is cached for a minute and will not be re-fetched on component re-render or window refocus. This is also correct.
keepPreviousData: true means the query returns its cached data while fetching new data, so the UI does not blank out on refetch. Correct placement.
What the query key actually is
["contents", urls] uses the full array of 151 { name, url } objects as part of the query key. React Query v3 serialises this for equality comparison. It works but it’s 151 objects deep. A better key would be something like ["contents", "gen1"] if the URL list is stable — but for a learning project hitting a stable public API, this does not cause real problems.
The AbortController bug
Both CharactersFront and CharactersBack contain this:
1const [isCanceled, setIsCanceled] = useState(false);
2const abortController = new AbortController();
The AbortController is constructed in the component body, not inside a useRef or useEffect. This means a new controller is created on every render. The queries capture the signal at the time they are registered — which is the first render. On subsequent renders, the component holds a different abortController that was never passed to any query. When handleCancel fires:
1const handleCancel = () => {
2 abortController.abort();
3 setIsCanceled(true);
4};
It aborts the current render’s controller, not the one the query is using. The in-flight requests carry on. The cancel button updates state and the UI shows it was pressed, but the network requests are unaffected.
The correct approach is to hold the controller in a ref:
1const abortControllerRef = useRef(new AbortController());
Then call abortControllerRef.current.abort() in the cancel handler.
The options-in-wrong-place bug
In the second query, there is this code:
1Promise.all(
2 urls.map(
3 (pokemon) => fetchContents(pokemon.url, abortController.signal),
4 {
5 staleTime: 60000,
6 keepPreviousData: true,
7 }
8 )
9)
The object { staleTime: 60000, keepPreviousData: true } is the second argument to Array.prototype.map(). That argument is thisArg — the value used as this inside the callback. It is not an options object. These React Query options are silently ignored.
They should be in the third argument to useQuery, not inside .map(). In CharactersFront, the outer useQuery for contents has no staleTime, so the detail data for all 151 Pokemon will be refetched on every cache invalidation.
The string-as-boolean flip bug
FrontCharacter initialises its flip state like this:
1const [isFlipped, setIsFlipped] = useState("isFlipped");
The initial state is the string "isFlipped", not a boolean. The conditional rendering uses isFlipped ? front : back. A non-empty string is truthy, so the card starts showing the front face. On click:
1setIsFlipped(!isFlipped);
!isFlipped when isFlipped is the string "isFlipped" evaluates to false. The state becomes false. Next click: !false is true. From that point forward the state cycles correctly between true and false.
The card works. It starts on the correct face. But it does so by accident — the initial boolean check coerces a string to truthy, and the first toggle coerces it to a boolean from that point on. The correct initialiser is useState(false).
The FlippableCard architecture mistake
The most structurally broken component in the project is FlippableCard.jsx:
1const FlippableCard = () => {
2 const [isFlipped, setIsFlipped] = useState("isFlipped");
3
4 return (
5 <Box>
6 <Card onClick={handleCardClick}>
7 <CardContent>
8 {isFlipped ? <CharactersFront /> : <CharactersBack />}
9 </CardContent>
10 </Card>
11 </Box>
12 );
13};
CharactersFront and CharactersBack are not presentational components. Each one runs the entire data-fetching pipeline: 1 list request plus 151 detail requests. Rendering one as the front face and the other as the back face means flipping this card triggers a whole new set of 152 network requests.
Additionally, FlippableCard was not wired up to receive a character prop. It has no data to show. It renders the entire grid of 151 Pokemon behind a card flip toggle.
This was the original approach and was correctly abandoned. App.jsx comments it out and renders CharactersFront directly. The flip logic was moved into FrontCharacter, where each card owns its own isFlipped state and toggling it just swaps the view of already-fetched data.
The FlippableCard copy.jsx file preserves the earlier iterations — including an attempt using react-card-flip with ReactCardFlip, and a version using a Framer Motion whileHover scale. None of these shipped.
The flip animation that shipped
The final flip animation in FrontCharacter is not a 3D CSS flip. It is a Framer Motion opacity crossfade:
1<motion.div
2 key={isFlipped ? "back" : "front"}
3 initial={{ opacity: 0 }}
4 animate={{ opacity: 1 }}
5 exit={{ opacity: 0 }}
6>
The key prop changes when isFlipped changes, which causes Framer Motion to unmount the old element and mount a new one. The AnimatePresence import is there but the component is not wrapped in it, so the exit animation never fires. The card fades in on state change but does not fade out.
The search components that don’t work
There are two search components in the project and neither compiles or runs correctly.
DSearch.jsx uses a different SearchBar name but hits api.example.com — a placeholder URL that returns nothing. More critically, it computes filteredItems unconditionally before the isLoading check:
1const filteredItems = data.filter((item) =>
2 tags.every((tag) => item.tags.includes(tag))
3);
data is undefined while the query is loading. This throws TypeError: Cannot read properties of undefined on mount. Neither component is imported by App.jsx.
SearchBar.jsx is the other attempt. It has a syntax error that prevents it from parsing:
1if search ==! "" {
==! is not a JavaScript operator. This would be == !"" === == true. The file also references second as a standalone expression in the cleanup return of a useEffect — undefined variable. The component is not wired up anywhere.
Missing keys on list renders
Both CharactersFront and CharactersBack render their card lists without key props:
1{contents.map((character) => (
2 <FrontCharacter frontCharacter={character} />
3))}
React needs stable keys for reconciliation. Without them, React falls back to index-based comparison and will warn in the console. The character.id from the PokéAPI response is the correct key here.
The page state that goes nowhere
Both CharactersFront and CharactersBack declare:
1const [page, setPage] = useState(40);
This is never read or set after initialisation. It is a remnant of the earlier Rick & Morty API approach, which used page-based pagination. The commented-out code at the bottom of both files shows the pagination buttons and keepPreviousData usage that was originally built for that API before switching to PokéAPI.
The unit conversions
PokéAPI returns height in decimetres and weight in hectograms. The display code handles this correctly:
1{frontCharacter.height / 100} M
2{frontCharacter.weight / 10} KG
Bulbasaur is 7 decimetres tall (0.7m) and 69 hectograms (6.9kg). These numbers are right.
h7 is not a valid MUI Typography variant
FrontCharacter uses variant="h7" twice:
1<Typography align="center" variant="h7" color="text.secondary">
MUI’s Typography component accepts h1 through h6, subtitle1, subtitle2, body1, body2, and a few others. h7 is not one of them. MUI silently falls back to the component’s default styling rather than throwing.
What the installed but unused dependency tells you
react-card-flip is in package.json dependencies. It appears in one commented-out block inside FlippableCard copy.jsx alongside ReactCardFlip, Grow, and a height={400} constraint on the card content. The library was pulled in, tried briefly, then replaced. It is not tree-shaken in Vite unless it is never imported, which it isn’t in the final code — but it ships in node_modules regardless.
The component tree as it actually runs
1App
2 QueryClientProvider
3 CharactersFront
4 [query: "urls"] → fetch /pokemon?limit=151
5 [query: ["contents", urls]] → Promise.all of 151 fetches
6 Grid
7 FrontCharacter (×151)
8 motion.div
9 Card [onClick toggles isFlipped]
10 isFlipped=true → image + name only
11 isFlipped=false → image + name + types + height + weight + stats + abilities
The CharactersBack, FlippableCard, Character, SearchBar, and DSearch components exist in the file system and are imported in various places but nothing currently renders them.
What the code got right
The dependent query pattern is the correct way to chain async data in React Query. enabled: !!urls ensures the second query waits for the first. Using Promise.all() inside a single useQuery correctly treats 151 parallel requests as one logical operation — React Query caches the combined result, not the individual responses.
staleTime: 60000 on the index query is a sensible default for a public API that changes rarely. keepPreviousData: true means the grid does not flash blank on refetch.
The MUI LinearProgress stat bars with value={stat.base_stat} are well-formed. The flex: "1 1 auto" layout with a fixed-width stat name on the left and the number on the right is clean and readable.
The Framer Motion opacity fade — even without AnimatePresence completing the exit — is smoother than a raw state toggle with no animation.
The two-stage architecture scales to any paginated list API that follows this pattern: fetch index, then fetch detail for each item in parallel. The shape is reusable even if this implementation has bugs in it.