[react]react-queryを使用してポケモン図2を作成


今ポケモン図鑑APIを取ります
PokeAPI DOC
types/index.ts
export type Sprites = {
  back_default: string;
  back_shiny: string;
  front_default: string;
  front_shiny: string;
  other: {
    dream_world: {
      front_default: string;
    };
    "official-artwork": {
      front_default: string;
    };
  };
};

export type Type = {
  slot: number;
  type: {
    name: string;
    url: string;
  };
};

export type Stat = {
  base_stat: number;
  effort: number;
  stat: {
    name: string;
    url: string;
  };
};

export type Ability = {
  ability: {
    name: string;
    url: string;
  };
  is_hidden: boolean;
  slot: number;
};

export type Language = {
  name: string;
  url: string;
};

export type Name = {
  language: Language;
  name: string;
};

export type Color = {
  name: string;
  url: string;
};

export type Version = {
  name: string;
  url: string;
};

export type FlavorTextEntry = {
  flavor_text: string;
  language: Language;
  version: Version;
};

export type GrowthRate = {
  name: string;
  url: string;
};

export type EffectEntry = {
  effect: string;
  language: Language;
  short_effect: string;
};

export type SimplePokemonInfo = {
  name: string;
  url: string;
};

export type DamageRelation = {
  double_damage_from: Array<{ name: string; url: string }>;
  double_damage_to: Array<{ name: string; url: string }>;
  half_damage_from: Array<{ name: string; url: string }>;
  half_damage_to: Array<{ name: string; url: string }>;
};

export type EvolutionDetail = {
  min_level: number;
  trigger: {
    name: string;
    url: string;
  };
};

export type Chain = {
  is_baby: boolean;
  evolution_details: Array<EvolutionDetail>;
  evolves_to: Array<EvolutionTo>;
  species: {
    name: string;
    url: string;
  };
};

export type EvolutionTo = {
  evolution_details: Array<EvolutionDetail>;
  is_baby: boolean;
  evolves_to: Array<EvolutionTo>;
  species: {
    name: string;
    url: string;
  };
};

export type ListResponse = {
  count: number;
  results: Array<SimplePokemonInfo>;
};

export type PokemonResponse = {
  id: number;
  name: string;
  order: number;
  sprites: Sprites;
  base_experience: number;
  height: number;
  weight: number;
  stats: Array<Stat>;
  abilities: Array<Ability>;
  types: Array<Type>;
};

export type SpeciesResponse = {
  id: number;
  name: string;
  order: number;
  names: Array<Name>;
  color: Color;
  flavor_text_entries: Array<FlavorTextEntry>;
  growth_rate: GrowthRate;
  gender_rate: number;
  is_legendary: boolean;
  is_mythical: boolean;
  evolution_chain: {
    url: string;
  };
};

export type AbilityResponse = {
  id: number;
  name: string;
  names: Array<Name>;
  is_main_series: boolean;
  effect_entries: Array<EffectEntry>;
};

export type TypeResponse = {
  id: number;
  name: string;
  damage_relations: DamageRelation;
};

export type EvolutionChainResponse = {
  id: number;
  chain: Chain;
};

usePokemon Custom Hookの作成


APIのバインドを開始します.srcディレクトリにhooksディレクトリを作成した後、ポケモンリストにインポートされたusePokemon hookを作成します.
hooks/usePokemon
import axios, { AxiosResponse } from "axios";
import { useQuery } from "react-query";
import { UseQueryResult } from "react-query/types/react/types";

// import { PokemonResponse } from '../types';

const pokemonApi = (id?: string) =>
  axios.get(`https://pokeapi.co/api/v2/pokemon/${id || ""}`, {
    params: { limit: 151 }, // 1세대 포켓몬이 151마리였다.
  });

const usePokemon = <T>(id?: string): UseQueryResult<AxiosResponse<T>, Error> =>
  useQuery(id ? ["pokemon", id] : "pokemon", () => pokemonApi(id));

export default usePokemon;
usePokemonは、idを因子として、UseQueryResultを返すhookである.

バインドAPI


components/PokemonList.tsx
import React from "react";
import styled from "@emotion/styled";
import usePokemon from "../hooks/usePokemon";
import { ListResponse } from "../types/indes";

const Base = styled.div`
  margin-top: 24px;
`;

const LoadingWrapper = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: calc(100vh - 180px);
`;

const Loading = styled.img``;

const ListItem = styled.li`
  position: relative;
  list-style: none;
  display: flex;
  align-items: center;
  box-shadow: 6px 4px 14px 5px rgba(0, 0, 0, 0.21);
  border-radius: 12px;
  & + & {
    margin-top: 18px;
  }
`;

const List = styled.ul`
  margin: 0;
  padding: 0;
`;

const Image = styled.img``;

const Name = styled.p`
  margin: 0;
  padding: 0 0 0 12px;
  flex: 1 1 100%;
  color: #374151;
  text-transform: capitalize;
  font-size: 16px;
  font-weight: bold;
`;

const Index = styled.p`
  position: absolute;
  margin: 0;
  padding: 0;
  right: 16px;
  bottom: 16px;
  font-size: 24px;
  font-weight: bold;
  color: #d1d5db;
`;

const getImageUrl = (index: number): string =>
  `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${index}.png`;

const PokemonList: React.FC = () => {
  const { isLoading, isError, data } = usePokemon<ListResponse>();

  const formatNumbering = (index: number): string => {
    return `#${String(index).padStart(3, "0")}`;
  };

  return (
    <Base>
      {isLoading || isError ? (
        <LoadingWrapper>
          <Loading src="/loading.gif" alt="loading" />
        </LoadingWrapper>
      ) : (
        <List>
          {data?.data.results.map((pokemon, idx) => (
            <ListItem key={pokemon.name}>
              <Image src={getImageUrl(idx + 1)} />
              <Name>{pokemon.name}</Name>
              <Index>{formatNumbering(idx + 1)}</Index>
            </ListItem>
          ))}
        </List>
      )}
    </Base>
  );
};

export default PokemonList;
APIバインドを完了すると、以下の画面が得られます.

ポケモン詳細ページの作成


次に、ポケモンの詳細を表示できるページを作成します.その前にutils/index.TSファイルを生成し、ポケモンの色、タイプ、インデックスに基づいて16進コードに変換し、フォーマットコードを記述します.
utils/index.ts
export const mapColorToHex = (color?: string) => {
  // 포켓몬의 컬러를 받아 hexCode 로 변
  switch (color) {
    case "white":
    case "gray":
      return "#6B7280";
    case "brown":
      return "#92400E";
    case "yellow":
      return "#F59E0B";
    case "green":
      return "#10B981";
    case "red":
      return "#EF4444";
    case "blue":
      return "#3B82F6";
    case "purple":
      return "#8B5CF6";
    case "pink":
      return "#EC4899";
    case "black":
      return "#1F2937";
    default:
      return "#6B7280";
  }
};

export const mapTypeToHex = (type?: string) => {
  // 포켓몬의 타입를 받아 hexCode 로 변
  switch (type) {
    case "bug":
      return "#92BC2C";
    case "dark":
      return "#595761";
    case "dragon":
      return "#0C69C8";
    case "electric":
      return "#F2D94E";
    case "fire":
      return "#FBA54C";
    case "fairy":
      return "#EE90E6";
    case "fighting":
      return "#D3425F";
    case "flying":
      return "#A1BBEC";
    case "ghost":
      return "#5F6DBC";
    case "grass":
      return "rgba(5, 150, 105, 1)";
    case "ground":
      return "#DA7C4D";
    case "ice":
      return "#75D0C1";
    case "normal":
      return "#A0A29F";
    case "poison":
      return "#B763CF";
    case "psychic":
      return "#FA8581";
    case "rock":
      return "#C9BB8A";
    case "steel":
      return "#5695A3";
    case "water":
      return "#539DDF";
    default:
      return "#6B7280";
  }
};

export const formatNumbering = (
  pokemonIndex: number | string
): string => // 포켓몬의 인덱스를 받아서 #001 형태로 변
  `#${(typeof pokemonIndex === "number"
    ? String(pokemonIndex)
    : pokemonIndex
  ).padStart(3, "0")}`;
次はComponent/PokemonInfoです.tsxファイルを作成します.
components/PokemonInfo.tsx
import React from "react";
import styled from "@emotion/styled/macro";

import { Color, Type } from "../types";
import { formatNumbering, mapColorToHex, mapTypeToHex } from "../utils";

type Props = {
  id: string;
  name?: string;
  types?: Array<Type>;
  color?: Color;
};

const Base = styled.div<{ color?: string }>`
  display: flex;
  flex-direction: column;
  background-color: ${({ color }) => color};
  padding: 20px;
  border-bottom-left-radius: 20%;
  border-bottom-right-radius: 20%;
`;

const ThumbnailImageWrapper = styled.div`
  width: 160px;
  margin-inline: auto;
  margin-block: 24px;
`;

const ThumbnailImage = styled.img`
  width: 100%;
  height: 100%;
  object-fit: contain;
`;

const InfoWrapper = styled.div`
  display: flex;
  justify-content: space-between;
  width: 100%;
`;

const Name = styled.div`
  color: #fff;
  font-size: 30px;
  font-weight: bold;
  text-transform: capitalize;
`;

const Index = styled.div`
  color: #fff;
  font-size: 36px;
  font-weight: bold;
  opacity: 0.75;
`;

const TypeWrapper = styled.div<{ color: string }>`
  background-color: ${({ color }) => color};
  padding: 4px;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
`;

const TypeList = styled.div`
  display: flex;
  margin-top: 8px;
  ${TypeWrapper} + ${TypeWrapper} {
    margin-left: 8px;
  }
`;

const TypeInfo = styled.img`
  height: 12px;
`;

const ImageWrapper = styled.div`
  position: absolute;
  width: 288px;
  height: 288px;
  left: -96px;
  top: -96px;
  opacity: 0.75;
`;

const Image = styled.img`
  width: 100%;
  height: 100%;
  object-fit: contain;
`;

const PokemonInfo: React.FC<Props> = ({ id, name, color, types }) => (
  <Base color={mapColorToHex(color?.name)}>
    <ImageWrapper>
      <Image src="/assets/pocketball.svg" />
    </ImageWrapper>
    <InfoWrapper>
      <Name>{name}</Name>
      <Index>{formatNumbering(id)}</Index>
    </InfoWrapper>
    <TypeList>
      {types?.map(({ type }, idx) => (
        <TypeWrapper key={idx} color={mapTypeToHex(type.name)}>
          <TypeInfo src={`/assets/${type.name}.svg`} />
        </TypeWrapper>
      ))}
    </TypeList>
    <ThumbnailImageWrapper>
      <ThumbnailImage
        src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/${id}.svg`}
        alt="image"
      />
    </ThumbnailImageWrapper>
  </Base>
);

export default PokemonInfo;
次に、以下の手順に従って、自分が望むポケモン情報を選択できるタグセットを作成します.
components/Tabs
import React from "react";
import styled from "@emotion/styled/macro";

import { Color } from "../types";
import { mapColorToHex } from "../utils";

type Props = {
  tab: "about" | "stats" | "evolution";
  onClick: (tab: "about" | "stats" | "evolution") => void;
  color?: Color;
};

const List = styled.ul`
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
`;

const ListItem = styled.li`
  & + & {
    margin-left: 16px;
  }
`;

const TabButton = styled.button<{ active?: boolean; color: string }>`
  margin: 0;
  border-radius: 8px;
  box-shadow: 6px 4px 14px 5px rgba(0, 0, 0, 0.21);
  padding: 6px 12px;
  background-color: #fff;
  border: none;
  font-size: 16px;
  color: ${({ active, color }) => (active ? color : "#6B7280")};
`;

const Tabs: React.FC<Props> = ({ tab, onClick, color }) => (
  <List>
    <ListItem onClick={() => onClick("about")}>
      <TabButton active={tab === "about"} color={mapColorToHex(color?.name)}>
        About
      </TabButton>
    </ListItem>
    <ListItem onClick={() => onClick("stats")}>
      <TabButton active={tab === "stats"} color={mapColorToHex(color?.name)}>
        Stats
      </TabButton>
    </ListItem>
    <ListItem onClick={() => onClick("evolution")}>
      <TabButton
        active={tab === "evolution"}
        color={mapColorToHex(color?.name)}
      >
        Evolution
      </TabButton>
    </ListItem>
  </List>
);

export default Tabs;
それからDetailPageに入って、ラベルが正常かどうかを見ます.ラベルが正常に動作しているかどうかは、colorの値をランダムに入力します.
pages/DetailPage
import React, { useState } from "react";
import { useParams } from "react-router-dom";
import Tabs from "../components/Tabs";

type Parmas = {
  id: string;
};

type Tab = "about" | "stats" | "evolution";

const DetailPage: React.FC = () => {
  const { id } = useParams<Parmas>();
  const [selectedTab, setSelectedTab] = useState<Tab>("about");

  const handleClick = (tab: Tab) => {
    setSelectedTab(tab);
  };

  return (
    <div>
      <Tabs
        tab={selectedTab}
        onClick={handleClick}
        color={{ name: "red", url: "" }}
      />
    </div>
  );
};

export default DetailPage;