[React] CRYPTO TRACKER를 만들며 배운 것들

sjoleee·2022년 7월 18일
0

https://sjoleee.github.io/crypto-tracker
https://github.com/sjoleee/crypto-tracker

Router.tsx 파일 생성 및 기본 세팅 : useParams

Router.tsx파일을 따로 만들었다. 기존에는 App.tsx에다 곧바로 적용했는데, 따로 작성하니까 좀 더 깔끔한 듯 하다.

import { BrowserRouter, Route, Routes } from "react-router-dom";
import Coins from "./routes/Coins";
import Coin from "./routes/Coin";

function Router() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Coins />} />
        <Route path="/:coinId" element={<Coin />} />
      </Routes>
    </BrowserRouter>
  );
}

export default Router;

BrowserRouter as Router로 많이 사용한다.
BrowserRouter > Routes(구 Switch) > Route(path + element)

App.tsx에서 Router을 받아온다.

function App() {
  return (
    <>
      <GlobalStyle />
      <Router />
    </>
  );
}

Coin에서 useParams Hook을 통해 coinId를 받아온다.(아직 coinId는 아무데서도 사용되지 않고 있음 ㅋㅋ)

import { useParams } from "react-router-dom";

function Coin() {
  const { coinId } = useParams();
  return <div>coin</div>;
}

export default Coin;

이로써, '~/어쩌구'라는 url로 진입하면 Coin 화면이 보이고, coinId 파라미터는 '어쩌구'가 된다.

App.tsx의 GlobalStyle은 resetCSS 및 전체 스타일 관리를 위한 것.

const GlobalStyle = createGlobalStyle`
//여기에 CSS 작성하면 됨.
`

Coins에서 map을 이용해 Coin을 여러개 만들 준비

function Coins() {
  
  return (
    <Container>
      <Header>
        <Title>코인</Title>
      </Header>
      <CoinList>
        {coins.map((coin) => (
          <Coin key={coin.id}>
            <Link to={`/${coin.id}`}>{coin.id} &rarr;</Link>
          </Coin>
        ))}
      </CoinList>
    </Container>
  );
}

export default Coins;

Container 안에 CoinList가 있고, 그 안에 map을 활용하여 Coin 컴포넌트를 여러개 만드는 방식.

그런데, 사실 저기서는 coins.map이 의미가 없다. 아직 coins라는 데이터가 없기때문.

코인정보 api를 호출, Coin 컴포넌트를 대량생산

로딩 구현한 부분이 섞여있다.

interface CoinInterface {
  id: string;
  name: string;
  symbol: string;
  rank: number;
  is_new: boolean;
  is_active: boolean;
  type: string;
}

function Coins() {
  const [coins, setCoins] = useState<CoinInterface[]>([]);
  //코인정보를 담아둘 state. 코인정보가 갖고있는 값들의 타입을 지정하기 위해서 interface를 사용.
  
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    //async await를 사용
    (async () => {
      const response = await fetch("https://api.coinpaprika.com/v1/coins");
      const json = await response.json();
      setCoins(json.slice(0, 100));
      //100개만 불러오기
      setLoading(false);
      //이건 로딩 상태변경
    })();
  }, []);
  
  
   return (
    <Container>
      <Header>
        <Title>코인</Title>
      </Header>
           {loading ? (
        <Loader>Loading...</Loader>
      ) : (
        <CoinList>
     //가져온 데이터로 Coin 컴포넌트 100개 생성
          {coins.map((coin) => (
            <Coin key={coin.id}>
              <Link to={`/${coin.id}`}>{coin.name} &rarr;</Link>
//Link로 보내는 곳은.. /btc나 /doge 등의 coinId를 파라미터로 함
            </Coin>
          ))}
        </CoinList>
      )}
    </Container>
  );
}
export default Coins;

useLocation Hook을 이용한 백스테이지 데이터전송

여기, 비트코인을 보여주는 Coin 페이지의 Title은 어떻게 만들어질까?

Coins 페이지에서 '비트코인'을 클릭하면 비트코인의 정보를 담은 Coin으로 이동한다.
Coin에서 '비트코인'에 대한 정보를 얻기 위해 api를 호출한다.
그 api에는 비트코인의 name, symbol, price 등 많은 정보가 담겨있다.
그 중에서 name을 가져와서 Title에 담는다. 이렇게 Title이 만들어진다.

그런데, 비트코인을 클릭하고나서 api가 호출되는데는 시간이 걸린다.
그 시간동안 우리는 로딩중... 요런 문구가 보이도록 로딩 state를 활용한다.
즉, Title에 들어갈 비트코인의 name이 불러와지는 동안, name대신 "Loading..."이 출력되는 것이다.

그런데, 이게 유저 경험에 썩 좋지 않다고 생각한다.
우리는 이미, 우리가 어떤 코인을 클릭했는지 코인 이름을 알고 있지 않나?
여기에 있다 분명히!

이 Coins페이지의 'Bitcoin'을 가져올 수는 없을까?
그래서 로딩이 완료될때까지는 Coins 페이지의 name을 사용하고, 로딩이 완료되면 새로 호출한 api의 name으로 대체하는 것이다.
이런 방식이면 적어도 타이틀 만큼은 어떤 코인인지 알 수 있도록 명시될 것이다.

react-router-dom 공식문서의 Link의 사용법에 따르면, to의 state를 통해 데이터를 전송할 수 있다.

<Link
  to={{
    pathname: "/courses",
    search: "?sort=name",
    hash: "#the-hash",
    state: { fronDashboard: true }
  }}
/>

우리도 코인의 이름을 state를 통해 Coins에서 Coin으로 보낼 수 있다.

 {coins.map((coin) => (
            <Coin key={coin.id}>
              <Link to={{
                pathname: `/${coin.id}`,
                state: { name: coin.name }
              }}>{coin.name} &rarr;</Link>
            </Coin>
          ))}

이렇게 state에 실어보낸 name을 어떻게 활용하냐.
바로 useLocation을 통해서.

const location = useLocation();
console.log(location);

를 실행하면 이런 결과값이 나온다.

{
hash: 어쩌구,
key: 어쩌구,
pathname: 저쩌구,
search: 저쩌구,
state: {name: 'Bitcoin'}
  //이렇게 state에 담겨서 전달된다.
}

이제 state가 갖고있는 name을 사용하자.

const location = useLocation();
//이렇게 location.state으로 사용하지 않고, 구조분해할당을 통해
const { state } = useLocation();
//으로 사용하자.

근데, 우리는 타입스크립트를 사용하고 있으므로, state가 갖고있는 프로퍼티들의 타입을 지정해줘야한다.

interface RouteState {
  name: string;
}

const { state } = useLocation<RouteState>();

이제 state에서 name을 꺼내 쓰면 된다.
그런데, 이 state가 undefined일 수 있다.
Coins페이지를 거치지 않고 직접 url을 적어서 방문하는 경우를 생각해보자.
우리는 Coins에서 클릭된 코인의 이름을 state에 담아서 전송하는 것인데, Coins에 방문조차 하지 않았으니... 이 경우에는 에러가 발생한다.
따라서 아래와 같이 state를 사용하면 된다.

<Title>{state?.name || "Loading..."}</Title>

state?.name은 interface에서 지정한대로 string이거나, 혹은? undefined에요~ 라는 의미이며(에러 방지),
||는 'A || B'로 사용되는데, 'A 있으면 해주시고, 없으면 B 해주세요~' 라는 의미이다(화면에 undefined가 아닌 Loading... 표시).

API호출

아까 Coins에서 api호출하는 코드가 있었다.

  useEffect(() => {
    //async await를 사용
    (async () => {
      const response = await fetch("https://api.coinpaprika.com/v1/coins");
      const json = await response.json();
      setCoins(json.slice(0, 100));
      //100개만 불러오기
      setLoading(false);
      //이건 로딩 상태변경
    })();
  }, []);

Coin 컴포넌트에서 필요한 데이터를 받아오기 위해 api를 호출하는데, 위와 동일한 방식으로 호출했다.

useEffect(() => {
    (async () => {
      const infoData = await (
        await fetch(`https://api.coinpaprika.com/v1/coins/${coinId}`)
      ).json();
      const priceData = await (
        await fetch(`https://api.coinpaprika.com/v1/tickers/${coinId}`)
      ).json();
      setInfo(infoData);
      setPriceInfo(priceData);
      console.log(info, priceInfo);
    })();
  }, []);

조금 풀어서 적어보면

useEffect( ()=>{}, [] )

useEffect는 컴포넌트([]에 해당) 가 렌더링 될 때마다 작업(함수에 해당) 을 실행하는 Hook
즉, 우리는 특정 컴포넌트가 아닌 페이지가 처음 렌더링 될 때, 한 번만 api를 호출하기를 원하므로 []를 사용하며, 첫번째 인자로 api를 호출하는 함수를 작성한다.

useEffect( ()=>{
  async () => { //async는 함수 앞에 붙여서 사용하며, 순차적인 작업을 위해 사용한다.
  const response = await fetch(url);
    //response는 fetch(url)에서 반환된 객체의 값을 가져온다.
  const json = await response.json();
    //response를 json데이터로 변환하기 위해 json() 매서드를 사용.
  setData(json);
  }
}, [] )

조금 줄일 수 있는데,

useEffect( ()=>{
  async () => {
  //const response = await fetch(url);
  //const json = await response.json();
  const data = await( await fetch(url) ).json();
  setData(data);
  }
}, [] )

이렇게 두 줄을 합쳐서 한 줄로 표현이 가능하다.
이제 예시가 아닌 본래 코드로 돌아가자.

useEffect(() => {
    (async () => {
      const infoData = await (
        await fetch(`https://api.coinpaprika.com/v1/coins/${coinId}`)
      ).json();
      const priceData = await (
        await fetch(`https://api.coinpaprika.com/v1/tickers/${coinId}`)
      ).json();
      setInfo(infoData);
      setPriceInfo(priceData);
      console.log(info, priceInfo);
    })();
  }, []);

infoData, priceData 두 데이터를 가져와서 info, priceInfo라는 state에 담아서 사용하고 있음을 알 수 있다.
다만, 우리는 타입스크립트를 사용하고 있기 때문에 해당 state의 타입을 지정해주어야한다.

interface InfoData {
  id: string;
  name: string;
  symbol: string;
  ...
}

interface PriceData {
  id: string;
  name: string;
  symbol: string;
  ...
  quotes: {
    USD: {
      ...
      price: number;
      ...
    };
  };
}

const [info, setInfo] = useState<InfoData>();
const [priceInfo, setPriceInfo] = useState<PriceData>();
  //useState 뒤에 <>열고 interface와 연결해주면 된다.

nested routes 구현하는 방법

Coin에는 price와 chart탭이 있는데,
각 탭을 클릭하면 state가 true/false로 변경되면서 어떤 컴포넌트를 보여줄까~ 정하는게 아니다.

nested routes를 이용했다.
이것때문에 나중에 만들 뒤로가기 버튼이 조금 애매해졌다...
어쨌든, Coin에 Route를 만들어주자.

<Routes>
  <Route path={"price"} element={<Price />}></Route>
  <Route path={"chart"} element={<Chart />}></Route>
</Routes>

조금 이상하지 않은가? path가 왜 저렇게...
{/:coinId/price} 뭐 이런식으로 적혀야하는게 아닌가?
이유는 v6에서 path가 상대경로 지정으로 변경되었기 때문이다.
오히려 절대경로 path를 가지면 안된다고 한다.(실제로 절대경로 지정시 에러남)
/코인아이디 페이지에서 누르면 /코인아이디/price로 만들어짐.
그리고 중첩라우팅을 위해 부모 컴포넌트 라우트에 *를 붙여주어 하위 라우팅을 탐색할 수 있도록 해줘야한다.

 <BrowserRouter>
      <Routes>
        <Route path="/" element={<Coins />} />
        //<Route path="/:coinId" element={<Coin />} />
        <Route path="/:coinId/*" element={<Coin />} />
      </Routes>
    </BrowserRouter>

useMatch Hook을 이용하여 스타일 지정

Price가 선택되면 색이 변하도록 만들고 싶다면 useMatch를 활용하자.

  const priceMatch = useMatch("/:coinId/price");
  const chartMatch = useMatch("/:coinId/chart");

useMatch의 인자로 url을 넘기면, 해당 url과 지금의 url이 일치하면 url의 정보를 반환한다.
(params, pathname, pattern 등이 들어있다.)
일치하지 않으면 null을 반환한다.
이를 활용하여 null이 아니면 선택된거네? 그럼 null이 아닐때 선택된 효과 적용해줘~ 라고 코드를 짤 수 있다.

//여기서도 타입지정은 잊지말자. isActive는 boolean이다.
const Tab = styled.div<{ isActive: boolean }>`
  background-color: ${(props) => (props.isActive ? "#ffffffe8" : "#00000050")};
`
//styled-components에서 props를 사용하는 방법을 기억하자

<Tab isActive={priceMatch !== null}> // null이 아니면 = url이 일치하면 = isActive가 true가 된다.
  <Link to={`/${coinId}/price`}>Price</Link>
</Tab>
<Tab isActive={chartMatch !== null}>
  <Link to={`/${coinId}/chart`}>Chart</Link>
</Tab>

react query로 fetch하는법

react query를 사용하면 기존에 작성했던 useEffect나 불러온 데이터를 담아두던 state들을 싹 다 지워도 된다!

react query 설치 후, 가장 먼저 해주어야 할 일은 queryClient를 만드는 일이다.

import { QueryClient, QueryClientProvider } from "react-query";

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <ThemeProvider theme={theme}>
        <App />
      </ThemeProvider>
    </QueryClientProvider>
  </React.StrictMode>
);

QueryClientProvider를 설명하기 위해, ThemeProvider를 사용했을 때를 생각해보자.
ThemeProvider 안에 있는 것들은 prop인 theme에 접근할 수 있게 된다.
그렇다면 QueryClientProvider는?
마찬가지로 QueryClientProvider 안에 있는 것들은 prop인 client에 접근할 수 있게 된다.

이제 기존 api호출 코드를 수정해보자.
세 단계로 진행하면 된다.
1. fetcher 함수 만들기
2. useQuery hook
3. interface로 타입 지정

  useEffect(() => {
    (async () => {
      const response = await fetch("https://api.coinpaprika.com/v1/coins");
      const json = await response.json();
      setCoins(json.slice(0, 100));
      setLoading(false);
    })();
  }, []);

기존 코드는 길다...
react query는 fetcher함수를 꼭 필요로 한다.
fetcher 함수는 반드시 fetch promise를 반환하는... fetch 하는 함수다.

const response = await fetch("https://api.coinpaprika.com/v1/coins");
const json = await response.json();

바로 이 부분이 해당된다.

일단 api.ts를 만들어서 fetcher 함수를 작성해보자.
둘 다 상관없다.
위는 promise, 아래는 async/await를 활용했을 뿐이다.

export function fetchCoins() {
  return fetch("https://api.coinpaprika.com/v1/coins").then((response) =>
    response.json()
  );
}
export async function fetchCoins() {
  const response = await fetch("https://api.coinpaprika.com/v1/coins");
  const json = await response.json();
  return json;
}

이렇게 api를 fetch하고 json을 반환하는 함수를 만들었다.
이 함수를 가져와서 사용하면 된다.

  useEffect(() => {
    (async () => {
      const response = await fetch("https://api.coinpaprika.com/v1/coins");
      const json = await response.json();
      setCoins(json.slice(0, 100));
      setLoading(false);
    })();
  }, []);

이 기존 코드를 싹 다 지우고...

const { isLoading, data } = useQuery("allCoins", fetchCoins);

이 한줄만 적으면 된다.

useQuery hook은 두개의 인자를 필요로 하는데,
첫번째 인자는 고유식별자,
두번째 인자는 fetcher 함수다.
fetcher 함수는 아까 api.ts에서 만든 함수.

useQuery hook은 isLoading이라는 boolean을 반환하는데, 우리가 state로 로딩중이니 아니니 했던걸 얘가 알아서 알려준다.
그리고 데이터는 data에 담긴다.

근데 알다시피... 타입스크립트를 사용하고 있으므로, data의 타입을 지정해주어야한다.

interface ICoin {
  id: string;
  name: string;
  symbol: string;
}
const { isLoading, data } = useQuery<ICoin[]>("allCoins", fetchCoins);

이렇게 useQuery hook을 이용해서 기존 코드를 대체하였다.

추가로, react query는 캐시에 데이터를 저장해두기 때문에 한 번 방문했던 페이지(한번 데이터를 불러온 경우)를 다시 방문하면 로딩이 나타나지 않는다.

자, Coin에서도 코드를 수정해보자.
react query를 사용하는 방법...
첫번째, fetcher 함수를 만든다.

export async function fetchCoinInfo(coinId?: string) {
  return fetch(`https://api.coinpaprika.com/v1/coins/${coinId}`).then(
    (response) => response.json()
  );
}

export async function fetchCoinPrice(coinId?: string) {
  return fetch(`https://api.coinpaprika.com/v1/tickers/${coinId}`).then(
    (response) => response.json()
  );
}

두번째, useQuery hook을 사용한다

  const { isLoading: infoLoading, data: infoData } = useQuery<InfoData>(
    ["info", coinId],
    () => fetchCoinInfo(coinId)
  );

  const { isLoading: priceLoading, data: priceData } = useQuery<PriceData>(
    ["info", coinId],
    () => fetchCoinPrice(coinId)
  );
  const loading = infoLoading || priceLoading;

세번째, interface로 data의 타입 지정
코드가 길어서 생략.

apexcharts 라이브러리 사용법은 스킵합니다.

hasOwnProperty를 활용한 에러 메시지 출력

chart 탭을 누르면 Chart컴포넌트가 렌더링되는 구조인데, Chart 컴포넌트에서 apexcharts를 이용한 차트를 만들기 위해 코인가격 변동 데이터가 들어있는 api를 호출한다.

그런데, 이 api가 이유는 모르겠는데 자주 호출에 실패해서 에러를 뱉는 문제가 있었다.

이 경우, 앱 자체가 에러로 인해 렌더링이 되지 않았고, 이를 해결하기 위해 고민한 결과...
data라는 객체가 error이라는 key를 갖고있으면 에러메시지를 보여주게 했다.

{isLoading 
  ? "Loading..."
  : data?.hasOwnProperty("error") 
    ? <Oops/>
    : <ApexChart
        ...  

대충 알아보기 쉽게 수정한 것인데,
isLoading이 true이면 'Loading...'을 출력하고, false이면 한번 더 조건문 돌린다.
로딩중이 아닐때,
혹시 받아온 데이터의 key값이 error입니까? : 에러메시지 보여주세요,
error이 아닙니까? : 정상적으로 차트 보여주세요
이런 로직이다.

Recoil로 전역상태관리

드디어 recoil을 사용해보았다!
일단 기존 state들을 싹싹 지워주고...
recoil을 설치했다.

사용법은 이렇다.
1. index.tsx를 RecoilRoot로 감싼다.
2. atoms.ts를 만들고, atom을 만든다.
(atom은 key와 default(기본값)을 가진다.)
3-1. 값을 읽고 사용할 경우는 useRecoilValue hook 사용.
3-2. 값을 수정할 경우는 useSetRecoilState hook 사용.

useRecoilValue가 반환하는 것은 state,
useSetRecoilState가 반환하는 것은 setState
라고 생각하면 편하다.

코드를 보면 이렇다.

  1. index.tsx를 RecoilRoot로 감싼다.
  <React.StrictMode>
    <RecoilRoot>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </RecoilRoot>
  </React.StrictMode>
  1. atoms.ts를 만들고, atom을 만든다.
    (atom은 key와 default(기본값)을 가진다.)
import { atom } from "recoil";

export const isDarkAtom = atom({
  key: "isDark",
  default: false,
});

3-1. 값을 읽고 사용할 경우는 useRecoilValue hook 사용.

const isDark = useRecoilValue(isDarkAtom);
//isDark를 기존의 state처럼 활용하면 된다.

3-2. 값을 수정할 경우는 useSetRecoilState hook 사용.

const setDarkAtom = useSetRecoilState(isDarkAtom);
const toggleDarkAtom = () => setDarkAtom((prev) => !prev);
//setDarkAtom을 기존의 setState처럼 활용하면 된다.
//setDarkAtom으로 state를 수정할 수 있다!

dark mode/light mode 변경하는 Toggle

Toggle을 직접 구현해봤는데, 이건 따로 정리하지 않겠다.
이렇게 만드는게 맞는지도 모르겠고... ㅋㅋㅋ 일단 기능은 정상적으로 작동하긴 한다!
코드만 붙여넣어놔야지.

import { useRecoilValue, useSetRecoilState } from "recoil";
import styled from "styled-components";
import { isDarkAtom } from "../atoms";

const ToggleWrapper = styled.div``;

const StyledToggleBtn = styled.button`
  position: relative;
  right: 0;
  border: none;
  width: 70px;
  height: 35px;
  border-radius: 17.5px;
  font-size: 20px;
  padding: 1px;
  display: flex;
  background-color: ${(props) => props.theme.accentColor};
  align-items: center;
`;

const Circle = styled.div<{ isDark: boolean }>`
  background-color: ${(props) => props.theme.bgColor};
  width: 30px;
  height: 30px;
  border-radius: 15px;
  position: absolute;
  left: 4%;
  transform: ${({ isDark }) => (isDark ? "translateX(35px)" : null)};
  transition: ${({ isDark }) =>
    isDark ? "all 0.2s ease-in-out" : "all 0.2s ease-in-out"};
`;

function ToggleBtn() {
  const isDark = useRecoilValue(isDarkAtom);
  const setDarkAtom = useSetRecoilState(isDarkAtom);
  const toggleDarkAtom = () => setDarkAtom((prev) => !prev);
  return (
    <ToggleWrapper>
      <StyledToggleBtn onClick={toggleDarkAtom}>
        <Circle isDark={isDark}></Circle>
      </StyledToggleBtn>
    </ToggleWrapper>
  );
}

export default ToggleBtn;
profile
상조의 개발일지

0개의 댓글