Poke Docs 만들기 - NextJS, react-query

서론에서 썻던것과 같이 페이지는 총 3개로 구성하였습니다.

  • index.tsx
  • docs.tsx
  • item/[id].tsx

일단 Next js 에 나온것 과 같이 시작을 하였습니다.

Next app 생성

npx create-next-app pocket-docs

기본적으로 typescript, eslint, prettier 를 추가하였습니다.

  1. nextJS 에서 typescript 도입은 생각외로 굉장히 간단한데요.
    Next js Basic Features TypeScript <- 문서에 나와있는데로 하면 쉽게 관련 파일과 typescript 를 설치할 수 있습니다.
  1. eslint 는 기본적으로 airbnb 기반으로 작성하였고 prettier 는 다음과 같이 추가하였습니다.
    .prettierrc
{
	"singleQuote": true,  // ' 따옴표 사용
	"semi": true,         // 세미콜론 여부
	"useTabs": false,     // tab 사용 여부
	"tabWidth": 2,        // tab 너비 
	"trailingComma": "all", // 요소에 , 추가
	"printWidth": 80,     // max 가로 length
	"arrowParens": "avoid"  // 화살표 함수 괄호 사용 방식
}
  1. emotion을 사용하게 되면 emotion이 자동으로 className을 난독화 해버리는데 이는 디버깅에서 참 쉽지 않습니다.
    예를 들면 이런식.... (코드상에서 어떤 클래스인지 찾을 수가 없음)

따라서 .babel을 이용하여 특정 className을 정의하여 디버깅을 쉽게 할 수 있게 해줄 수 있습니다.

yarn add babel-plugin-emotion --dev

.babelrc

{
  "presets": ["next/babel"],
  "plugins": [
    [
      "@emotion",
      {
        "autoLabel": "dev-only",
        "labelFormat": "[dirname]-[filename]-[local]"
      }
    ]
  ]
}

그럼 이제 css-암호화-dirname-filename-className 으로 되어서 디버깅하기가 아주 쉬워집니다.

  1. 마지막으로 API fetch 를 위해 redux 를 사용하지 않고 react-query 를 사용하였는데, redux 에 수많은 보일러플레이트 코드들도 보기 싫고 간단한 프로젝트인 만큼 가볍게 가고 싶어서 선택하게 되었습니다.
    또한 graph QL 에 대한 관심도도 한 몫 한것 같습니다.
    공식문서가 아주 잘 되어있고 서론에서 언급했던 SSR 도 아주 잘 지원됩니다. 특히 Next JS 부분이 있다는것도 상당히 매력적이죠.

이렇게 대략적인 프로젝트 구조 설명은 끝난것 같습니다. 뭐 그외에 여러가지 모듈들이 설치가 되어있는데요.

  • axios <- 누구나 아는 모듈
  • object-key-converter <- snake_case <-> camelCase 로 변환해주는 모듈 (제가 만들었어요. PR 환영!)
  • eslint-config-prettier, eslint-plugin-prettier <- eslint/prettier 충돌 방지를 위한 모듈
  • eslint- <- 그 외에 eslint-aribnb 사용시 자동으로 추가되는 모듈
  • emotion 관련 모듈
{
  "name": "pocket-docs",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint ./ --ext .js,.jsx,.ts,.tsx --fix"
  },
  "dependencies": {
    "@emotion/core": "^11.0.0",
    "@emotion/react": "^11.1.4",
    "@emotion/styled": "^11.0.0",
    "axios": "^0.21.1",
    "next": "10.0.6",
    "object-key-converter": "^1.0.4",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "react-query": "^3.7.1"
  },
  "devDependencies": {
    "@types/node": "^14.14.25",
    "@types/react": "^17.0.1",
    "@typescript-eslint/eslint-plugin": "^4.14.2",
    "@typescript-eslint/parser": "^4.14.2",
    "babel-plugin-emotion": "^11.0.0",
    "eslint": "^7.19.0",
    "eslint-config-airbnb": "^18.2.1",
    "eslint-config-prettier": "^7.2.0",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-jsx-a11y": "^6.4.1",
    "eslint-plugin-prettier": "^3.3.1",
    "eslint-plugin-react": "^7.22.0",
    "eslint-plugin-react-hooks": "^4.2.0",
    "prettier": "^2.2.1",
    "typescript": "^4.1.3"
  }
}

뭐 딱히 특히한건 없어보이네요. 기본적인 프로젝트 세팅이고 추가로 제가 진행하려고 하는 simple create react app 프로젝트인데 거기에는 정말 필요한 모듈만 설치하고 react app 을 만들기 위한 프로젝트인데 진전이.... ㅋㅋㅋ(PR 환영)

@eomttt/react-format

Next 구조

index.tsx

먼저 pages/index.tsx 를 구성하였습니다. 메인화면으로 크게 하는일은 없고 snowflake 효과를 포켓몬으로 줘보기로 하였습니다.
그러면 일단 포켓몬 리스트를 가져와야하는데

  • GET https://pokeapi.co/api/v2/pokemon?limit={number}&offset={number}

위에 api 를 호출하면 포켓몬 리스트가 가져와지는것을 볼 수 있습니다.
Next에 유용한점을 사용하기 위해 prefetch 를 이용하여 api 를 불러보도록 하였습니다.
기존에 제가 Next 를 사용했을 때에는 getInitialProps 뿐이였는데 버전업이 되면서 여러가지가 바뀌었더군요.

Next data fetching

  • getStaticProps: static data를 위한 data fetching
    - 만약 dynamic route item/[number].tsx 를 사용하는 경우 getStaticPaths 을 사용하여 명시적으로 정의된 path를 설정해줘야함
  • getServerSideProps: SSR를 위한 data fetching

따라서 일반적인 pages/index.tsx 에서는 getStaticProps 을 사용하였고 동적인 pages/item/[number].tsx 에서는 getStaticProps, getStaticPaths 를 같이 사용하였습니다.

pages/index.tsx

import { getPocketmonList } from 'apis/getPokemonList';
import { Layout } from 'components/Layout';
import { Thumbnailes } from 'components/Thumbnailes';
import { GetStaticProps } from 'next';
import React from 'react';
import { QueryClient } from 'react-query';
import { dehydrate } from 'react-query/hydration';

const Home = () => (
  <Layout>
    <Thumbnailes />
  </Layout>
);

export const getStaticProps: GetStaticProps = async () => {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery('pokemonlist', getPocketmonList, {
    staleTime: 10000,
  });
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

export default Home;

Thumbnailes.tsx

components/Thumbnailes.tsx

import { PokemonThumbnail, Thumbnail } from 'components/Thumbnail';
import { IMAGE_URL, TITLE_IMAGE } from 'constants/common';
import { useGetPokemonList } from 'hooks/useGetPokemonList';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import * as Styles from './styles';

export const Thumbnailes = () => {
  const router = useRouter();
  const { data, isLoading } = useGetPokemonList();
  const pokemonList = useMemo(() => {
    if (data) {
      return data.results.map((item, index) => ({
        ...item,
        number: index + 1,
      }));
    }
    return [];
  }, [data]);
  const [thumbnailes, setThumbnailes] = useState<PokemonThumbnail[]>([]);

  useEffect(() => {
    setThumbnailes(
      pokemonList.map(pokemon => ({
        ...pokemon,
        image: `${IMAGE_URL}${pokemon.number}.png`,
      })),
    );
  }, [pokemonList]);

  const handleClickFindPokemon = useCallback(() => {
    router.push('docs');
  }, [router]);

  const pokemonImages = useMemo(
    () => (
      <>
        {thumbnailes.map((thumbnail, index) => (
          <Styles.Thumbnail
            delay={Math.random() * 5}
            left={Math.random() * 100}
            // eslint-disable-next-line react/no-array-index-key
            key={index}
          >
            <Thumbnail pokemon={thumbnail} />
          </Styles.Thumbnail>
        ))}
      </>
    ),
    [thumbnailes],
  );

  if (isLoading || !data) {
    return <div>Loading...</div>;
  }

  return (
    <Styles.Container>
      <Styles.Image src={TITLE_IMAGE} alt="titleImage" />
      <Styles.Text onClick={handleClickFindPokemon}>
        {'포켓몬 찾기 >'}
      </Styles.Text>
      {pokemonImages}
    </Styles.Container>
  );
};

useGetPokemonList.ts

hooks/useGetPokemonList.ts

import { getPocketmonList } from 'apis/getPokemonList';
import { useQuery } from 'react-query';

export const useGetPokemonList = () =>
  useQuery('pokemonlist', getPocketmonList);

이런식으로 getStaticProps 내부에서 QueryClient 를 호출하고 prefetchQuery를 해주면 react-query 가 prefetchQuery key(pokemonlist) 에 따라 query 를 prefetch 하고, 컴포넌트 단에서 useQuery 를 사용하면 그 key 에 따라 prefetch 된 값을 빠르게 가져올 수 있게 됩니다.

react-query에서 제공해주는 Using Hydration 방식으로 구현하였습니다.
이렇게 되면 getStaticProps 에서 pokemonlist key에 대한 prefetch 를 진행하게 되고, 클라이언트에서는 useQuery를 호출하였을때에 기존에 prefetch 하였던 data 를 리턴하게 됩니다.

_app.tsx

_app.tsx

const queryClient = new QueryClient();
queryClient.setDefaultOptions({
  queries: {
    staleTime: Infinity,
  },
});

function MyApp({ Component, pageProps }: MyAppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
        <ReactQueryDevtools initialIsOpen={false} />
      </Hydrate>
    </QueryClientProvider>
  );
}

export default MyApp;

queryClient 에 staleTime 을 Infinity 로 해주어야 서버 사이드(getStaticProps)에서 prefetch 한 query가 클라이어트 사이드(useQuery) 에서 호출 하였을때 값이 유지되는것을 볼 수 있습니다.
만약 다음과 같이 클라이어트 사이드(useQuery) 에서 staleTime 을 정해주면 그 시간마다 다시 query 를 요청하는것을 볼 수 있습니다.

useQuery('key', api, { staleTime: 1000})  

위와 같이 react-query 를 Next 에 적용시켜 사용하는 법을 알아보았는데요. 아직 많은 예제들이 없고 특히 한국어로 되어있는 예제들은 거의 없어서 잘 적용되었는지는 모르겠습니다. 하하

결론

  1. _app.tsx 에서 queryClient 설정, Hydrate components 로 감싸기
  2. pages/[name].tsx 에서 prefetch 시 stale time 적용. 만약 stale time 적용하지 않을 시에 설정이 Infinity가 되어서 새로운 쿼리로 업뎃이 안됌
  3. 클라이언트 단에서는 useQuery를 통해서 값을 가져오는데 만약 prefetch 가 된 값이 있다면 그 값을 가져오게됨. 역시 useQuery 에 stale time 을 적용하게 되면 그 시간이 지난 후 새롭게 query 를 요청하게 됨.

다음에는 index page 에 snowflake 효과에 대하여 포스팅해보도록 하겠습니다.

좋은 웹페이지 즐겨찾기