• [ReactJS] 5. 𝒘𝒊𝒕𝒉 타입스크립트

    2022. 2. 13.

    by. 나나 (nykim)

    320x100

    Abstract vector created by fullvector - www.freepik.com

     

    이 글은 NomadCoders의 <React JS 마스터클래스>의 내용 일부를 정리한 글입니다. 멋진 강의 감사합니다!

    개인 스터디 글로, 맞지 않는 내용이나 더 나은 방법을 공유해 주신다면 복받으실 거예요 👼✨
    1) 시작하기
    2) 기능 연습 & 3) 앱 만들기
    4) styled-components
    5) 𝒘𝒊𝒕𝒉 타입스크립트 ☀︎

     

     

     

    5-1. 타입스크립트

    5-1-1. "타입" 스크립트?

     

    자바스크립트는 너무나 관대(?)해서 오냐오냐 하고 넘어갈 때가 많습니다.

    const sum = (a,b) => a+b; 에서 a와 b는 숫자로 받을 생각이었지만, 여기에 문자열을 넘기더라도 '그런갑다~' 하고 넘어가 버립니다.

    또, 존재하지 않는 프로퍼티를 읽더라도 ‘값이 없는데? 그럼 undefined 줄게’ 하고 대충 넘어가버리기 때문에 나중에 엌?? 하는 상황이 생기기도 합니다.

     

    이럴 땐 좀더 깐깐한(이른바 strongly typed)한 언어인 ‘타입스크립트’를 써볼 수 있습니다.

    타입스크립트는 자바스크립트를 바탕으로 하기 때문에 거의 유사하지만, 좀 더 강력한 기능들을 제공합니다.

    검수 선생님을 둬서 DX(개발 경험ㅎㅎ)를 향상시키는 툴이라고 볼 수 있습니다.

     

     

    https://www.typescriptlang.org/play 에서 타입스크립트를 시험삼아 두들겨 봅시다!

     

    const sum = (a:number, b:number) => a+b;
    sum(1,2);
    

     

    이렇게 : 를 이용해 코드에 타입을 정의하는 방식을 Type Annotation이라고 합니다.

    그럼 이 코드를 타입스크립트가 깐깐하게 검수한 다음, ‘음! 문제 없군!’이라 판단하면 평범한 자바스크립트문으로 컴파일해줍니다. 이걸 브라우저가 읽게 되는 거고요.

     

    또, 코드 작성에도 도움이 됩니다. 예를 들면 VS CODE에서는 sum 의 타입이 Number라는 걸 알고 있다면 sum. 까지 쳤을 때 Number에서 제공하는 API들을 밑에 미리 띄워주므로 쉽게 자동 작성할 수 있습니다.

     

     

     


     

     

    5-1-2. 설치하기

     

    Adding TypeScript | Create React App

    # 신규 프로젝트 생성 시 타입스크립트 사용
    npx create-react-app {app-name} --template typescript
    
    # 기존 프로젝트에 타입스크립트 사용
    npm install --save typescript @types/node @types/react @types/react-dom @types/jest
    
    # 자바스크립트 기반 라이브러리인 경우 `@types/styled-components`와 같이 추가로 DefinitelyTyped 설치
    # .js 파일은 .tsx 파일로 확장자 변경 후 개발서버 재시작
    

     

    앗 낯설다!

     

     

    propTypes를 활용하면 어떤 타입의 props를 받을지 설정해줄 수 있었지만, 코드가 실행된 “후”에 콘솔에 에러를 찍는 흐름이었습니다.

    하지만 타입스크립트를 쓰면 코드 실행 “전”에 체크하도록 할 수 있어요.

     

     

     

     


     

     

    5-1-3. props 설정하기

     

    bgColor라는 props를 필요로 하는 <Circle/> 이란 컴포넌트가 있다고 해봅시다.

     

    /* App.tsx */
    
    import Circle from './Circle';
    
    function App() {
      return (
        <>
          <Circle bgColor="orange" />
          <Circle bgColor="green" />
        </>
      );
    }
    
    export default App;
    
    /* Circle.tsx */
    
    import styled from 'styled-components';
    
    const Container = styled.div``;
    
    function Circle({bgColor}) {
      return <Container />;
    }
    
    export default Circle;
    

     

     

    일반적인 JS 환경이면 별일없이 넘어갈 텐데, 타입스크립트는 "{bgColor}의 타입이 뭔데!!"라며 밑줄을 쫙쫙 긋습니다.

    그래서 “얜 이런 모양이야"라고 타입스크립트하고 정해놓은 약속(=인터페이스)를 지정해 줍니다.

    interface 키워드를 이용해 인터페이스를 만들어 줄 수 있어요.

    이때 인터페이스 이름은 I를 붙이기도 합니다 (ex. 가격정보 → IPriceData)

     

     

    그럼 인터페이스를 만들어 보죠. <Circle/>에게 넘어오는 각 props의 타입은 CircleProps라고 알려줍시다.

     

    import styled from 'styled-components';
    
    const Container = styled.div``;
    
    interface CircleProps {
      bgColor: string;
    }
    
    function Circle({ bgColor }: CircleProps) {
      return <Container />;
    }
    
    export default Circle;
    

     

    <Circle/>이 넘겨받은 bgColor 란 props를 다시 자식 컴포넌트인 <Container/>에게 넘겨줄 차례입니다.

    스타일드 컴포넌트인 <Container/> 가 받을 props들도 설정해준 뒤, styled.div<{인터페이스명}>``; 형태로 사용합니다.

     

     

    import styled from 'styled-components';
    
    interface CircleProps {
      bgColor: string;
    }
    
    interface ContainerProps {
      bgColor: string;
    }
    
    const Container = styled.div<ContainerProps>`
      width: 300px;
      height: 300px;
      background-color: ${(props) => props.bgColor};
      border-radius: 50%;
    `;
    
    function Circle({ bgColor }: CircleProps) {
      return <Container bgColor={bgColor} />;
    }
    
    export default Circle;
    

     

    그럼 이제 bgColor라는 props는 타입스크립트의 검수 대상에 올라왔기에(?) 무사히 내려보낼 수 있게 됩니다.

    이렇게 <> 형태로 사용하는 것을 제네릭(Generic)이라 하는데, 타입정의를 매개변수로 넘겨주는 것처럼 사용하고 있습니다.

     

    참고

     

     

     


     

     

    5-1-4. Optional Props

    bgColor는 꼭 필요한 Props인데, 그렇다면 반드시 필요하지 않은 Props는 어떻게 설정할까요?

    그냥 물음표를 붙여주면 됩니다?

     

    interface CircleProps {
      bgColor: string;
      borderColor?: string;
    }
    

     

    borderColor는 타입이 string | undefined 가 됩니다.

    이렇게 | 연산자를 이용해 타입을 여러 개 연결하는 방식을 유니온 타입이라고 부릅니다.

    해당 값은 string 또는 undefined니까 값이 undefined더라도 당황하지 말고 넘어가세요! 하고 타입스크립트에게 말해주는 거죠.

     

    우리는 지금 CircleProps와 ContainerProps 두 개가 있는데, CircleProps는 선택값으로 받되 ContainerProps에서는 필수값으로 받아봅시다.

    반드시 border값이 있어야 하긴 하는데, 그 값이 부모에서 넘어올 수도 있고 아닐 수도 있는 상황이란 거죠!

     

    interface CircleProps {
      bgColor: string;
      borderColor?: string;
    }
    
    interface ContainerProps {
      bgColor: string;
      borderColor: string;
    }
    

     

    그래서 만약 부모(Circle)로부터 넘겨받은 값이 없다면 그냥 bgColor 값이 borderColor 값이 되도록 합니다.

    borderColor={borderColor ?? bgColor}

     

    Tip. nullish 병합 연산자
    a ?? b에서 a 가 null또는 undefined라면 b, 아니라면 a

     

    // ...
    const Container = styled.div<ContainerProps>`
      width: 300px;
      height: 300px;
      background-color: ${(props) => props.bgColor};
      border: 3px solid ${(props) => props.borderColor};
      border-radius: 50%;
    `;
    
    function Circle({ bgColor, borderColor }: CircleProps) {
      return <Container bgColor={bgColor} borderColor={borderColor ?? bgColor} />;
    }
    

     

    또는 { text = “Lorem Ipsum”} 과 같이 기본값을 설정해 줄 수도 있겠죠!

     

    /* Circle.tsx */
    
    import styled from 'styled-components';
    interface CircleProps {
      bgColor: string;
      borderColor?: string;
      text?: string;
    }
    interface ContainerProps {
      bgColor: string;
      borderColor: string;
    }
    
    const Container = styled.div<ContainerProps>`
      width: 300px;
      height: 300px;
      background-color: ${(props) => props.bgColor};
      border: 3px solid ${(props) => props.borderColor};
      border-radius: 50%;
    `;
    
    function Circle({ bgColor, borderColor, text = 'Lorem Ipsum' }: CircleProps) {
      return (
        <Container bgColor={bgColor} borderColor={borderColor ?? bgColor}>
          {text}
        </Container>
      );
    }
    
    export default Circle;
    

     

    여기서 더 깐깐하게 가고 싶다면 컴포넌트의 타입을 지정해 줄 수도 있습니다.

     

    function Button({children}: ButtonProps):JSX.Element {
      return <button>{children}</button>
    }
    

     

     

     


     

     

    5-1-5. State

     

    타입스크립트를 사용하고 있다면 리액트의 state 초기값을 보고 알아서 타입을 유추해 줍니다.

    const [value, setValue] = useState(true); 라면 Boolean 값이 여기 들어오겠거니 알아챕니다 (이것이 알잘딱깔센)

     

    만약 state값이 undefined나 null이 될 수 있는 등, 여러 타입이 올 수 있다면 따로 타입을 지정해 줄 수 있습니다.

    const [value, setValue] = useState<number | null>(0);

     

     

     

     

    임의의 폼을 만들어 보죠! 사용자가 인풋에 이름을 입력하고 버튼을 클릭하면, 입력한 내용을 표시해 줍니다.

     

    import { useState } from 'react';
    
    function App() {
      const [name, setName] = useState('');
      const onChange = () => {};
    
      return (
        <form>
          <input type="text" value={name} onChange={onChange} />
          <button>확인</button>
        </form>
      );
    }
    
    export default App;
    

     

    이제 입력한 내용을 받아오기 위해 event 객체를 인자로 받아올 건데...

     

     

     

    잠깐, 이 넘겨받을 e는 타입이 뭐지!? 하고 타입스크립트가 의문을 제기합니다. 

    결국 깐깐한 검수 조건에 맞춰 얘도 타입을 지정해줘야겠네요.

     

     

     

    우선 onChange는 React에서 제공하는 SyntheticEvent 입니다.

    이는 브라우저가 제공하는 기본 이벤트가 아니고, 리액트가 다양한 환경에서 일관되게 동작할 수 있도록 만든 이벤트 입니다.

    그래서 event 객체도 우리가 아는 걔(?)가 아니고 SyntheticBaseEvent 객체입니다.

     

    문서를 살펴보면 onChange 는 Form Events 임을 알 수 있습니다.

    그리고 이벤트를 발생시킬 요소는 <HTMLInputElement>입니다.

    따라서 e 객체의 타입을 React.FormEvent<HTMLInputElement> 로 지정합니다.

     

     

    그러면 e.currentTarget.value 으로 값을 가져올 수 있게 됩니다.

     

    만약 e 객체에서 꺼내 써야 할 것들이 많다면 이렇게 조각조각 낼 수도 있죠.

     

    const {
      currentTarget: { value }
    } = e;
    
    /*
      ① const { } = e;
      ② const { currentTarget } = e;
      ③ const { currentTarget: {value} } = e;
      ④ const { currentTarget: {value, id, innerHTML } } = e;
        console.log(id);
    */

     

    onSubmit도 비슷하게 작성합니다.

    const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      alert(`Hello, ${name}!`);
    };
    

     

     

     

    Tip. e.target vs. e.currentTarget

    • e.target = 이벤트를 트리거한 요소
    • e.currentTarget = 이벤트 리스너가 할당된 요소

    따라서 버블링 등이 발생한 경우, e.target !== e.currentTarget 이 될 수 있습니다.

    만약 e 객체에서 꺼내 써야 할 것들이 많다면 이렇게 조각조각 낼 수도 있죠.

     

    const {
      currentTarget: { value }
    } = e;
    
    /*
      ① const { } = e;
      ② const { currentTarget } = e;
      ③ const { currentTarget: {value} } = e;
      ④ const { currentTarget: {value, id, innerHTML } } = e;
        console.log(id);
    */

     

     


     

     

    5-1-6. Theme

     

    스타일드 컴포넌트를 위한 타입스크립트 정의는 declarations 파일을 통해 확장할 수 있습니다. (참고 문서)

    styled.d.ts 란 이름으로 declarations 파일을 생성 후 아래 내용을 붙여넣습니다.

    (d.ts 파일은 선언(delcaration)을 통해 타입스크립트 코드의 타입 추론을 돕는 파일입니다.)

     

    스타일드 컴포넌트의 DefaultTheme 에게 인터페이스를 지정해주는 거죠.

     

    /* styled.d.ts */
    
    // import original module declarations
    import 'styled-components';
    
    // and extend them!
    declare module 'styled-components' {
      export interface DefaultTheme {
        textColor: string;
    	bgColor: string
      }
    }

     

    import { DefaultTheme } 으로 가져오고, 작성한 테마의 타입으로 지정해준 다음 export 합니다.

     

    /* theme.ts */
    
    import { DefaultTheme } from 'styled-components';
    
    export const lightTheme: DefaultTheme = {
      textColor: '#000',
      bgColor: '#fff'
    };
    
    export const darkTheme: DefaultTheme = {
      textColor: '#fff',
      bgColor: '#000'
    };

     

    테마를 적용할 컴포넌트를 <ThemeProvider/> 로 감싸고 theme={}로 지정해주면 되겠죠!

     

    /* index.tsx */
    
    //...
    import { ThemeProvider } from 'styled-components';
    import { darkTheme } from './theme';
    
    ReactDOM.render(
      <React.StrictMode>
        <ThemeProvider theme={darkTheme}>
          <App />
        </ThemeProvider>
      </React.StrictMode>,
      document.getElementById('root')
    );

     

    그러면 props.theme로 가져올 수 있게 되며, 어떤 속성을 어떤 타입으로 써야하는지 명확히 알 수 있습니다.

     

     

     

     


     

     

    5-1-7. 타입스크립트 문법

     

    타입스크립트에서 정의할 수 있는 기본 타입은 아래와 같습니다.

    • Boolean
    • Number
    • String
    • Object
    • Array
      ex) let arr: [number[] = [1,2,3];
      ex) let arr: Array<number> = [1,2,3];
    • Tuple
    • Enum
    • Any
    • Void
    • Null
    • Undefined
    • Never (함수의 끝에 절대 도달하지 않는다는 의미를 지닌 타입)

     

     

     

    타입스크립트에서는 함수의 인자도 필수값으로 보며, 정의된 매개변수 값만 받을 수 있습니다.

    아니면 ? 를 써서 옵셔널 값으로 정의합니다.

     

    function sum(a: number, b: number): number {
      return a + b;
    }
    sum(10); // 에러
    

     

    REST 문법을 써서 매개변수를 넘기고 싶다면 이렇게 정의할 수 있습니다.

     

    function sum(a: number, ...nums: number[]):number {
    	let total = 0;
    	for (let key in nums) {
    		total += nums[key]
    	}
    	return a + total;
    }
    

     

     

     


     

     

    5-1-8. 타입스크립트 & CSS Module

     

    .module.css 형태로 CSS를 작성하면, 리액트 컴포넌트에서 해당 CSS 파일을 불러올 때 클래스 이름을 ‘고유하게‘ 바꿔줍니다.

    그래서 머리 아프게 클래스 네이밍을 고민할 필요가 없습니다. (딱 여기서만 쓰일 거니까요!)

     

    주관적인 생각으론 styled-components랑 다르게 CSS 스타일링만 별도 관리한다는 느낌이 강했습니다. 조금 더 친숙한 느낌!?

     

     

     

    연습을 위해 버튼 컴포넌트를 만들어 봅니다.

    Button.tsx 생성 후 기본적인 버튼 마크업을 작성합니다.

     

    function Button({children}) {
      return (
        <button type="button">
          {children}
        </button>
      );
    }
    
    export default Button;
    

     

    children을 포함해 버튼이 갖춰야 할 props와 그들의 타입을 인터페이스로 정의합니다.

     

    import { ReactNode } from 'react';
    
    interface ButtonProps {
      shape: 'fill' | 'line';
      color?: string;
      disabled?: boolean;
      children: ReactNode;
    }
    
    function Button({
      shape = 'fill',
      color,
      disabled = false,
      children
    }: ButtonProps): JSX.Element {
      return (
        <button type="button" disabled={disabled}>
          {children}
        </button>
      );
    }
    
    export default Button;
    

     

    그럼 이 버튼 컴포넌트를 처음 보는 사람도 ‘이 버튼을 쓰려면 이렇게 써야겠군’이라고 파악할 수 있죠.

    • shape와 children은 필수값이고, color와 disabled는 옵셔널입니다.
    • shape의 기본값은 ‘fill’이며, disabled는 기본적으로 false입니다.
    • <Button/> 컴포넌트의 타입은 JSX.Element 입니다.

     

     

    CSS-in-JS가 아니므로 props를 CSS에게 넘겨줄 수는 없지만, 클래스 형태로 알려줄 수는 있겠네요!

    클래스네임을 편하게 작성하기 위해 classnames 라이브러리를 설치해 가져옵니다.

     

    import style from './Button.module.scss';
    import classNames from 'classnames/bind';
    const cx = classNames.bind(style);
    

     

    이때, scss 파일은 tsx나 ts 같이 익숙한 모듈 파일이 아니여서 타입스크립트한테 검문 당하므로
    d.ts 파일에서 declare module '*.scss'; 로 타입 추론을 도와줍니다.

     

    이제 cx() 형태로 조건부 클래스 네이밍이 가능합니다.

     

    cx('btn', 'primary')
    cx('btn', { primary: true })
    cx('btn', ['primary', 'special'])
    

     

    먼저 shape를 넘겨줘서 클래스로 fill 또는 line 을 줍니다.

    className={cx(${shape})}

     

    그리고 color도 넘겨줍시다. 이때 color는 옵셔널이므로 && 연산자를 사용합니다.

    className={cx(${shape}, color && ${color})}

     

     

    마지막으로 scss 파일에서 분기 처리해주면 끝!

     

    /* Button.module.scss */
    
    button {
      $default: #333;
      $palette: (
        primary: #f25050,
        secondary: #1a58de
      );
    
      display: block;
      padding: 1rem 2rem;
      font-size: 1.4rem;
      margin: 0.5rem;
      color: #fff;
      background-color: $default;
      border: 0.2rem solid;
      border-radius: 0.2rem;
      border-color: $default;
    
      &.line {
        color: $default;
        background-color: #fff;
        border-color: $default;
      }
    
      &:hover {
        opacity: 0.8;
      }
    
      &:disabled {
        opacity: 0.3;
        cursor: not-allowed;
      }
    
      @each $name, $value in $palette {
        &.#{$name} {
          &.fill {
            color: #fff;
            background-color: $value;
            border-color: $value;
          }
    
          &.line {
            color: $value;
            background-color: #fff;
            border-color: $value;
          }
        }
      }
    }
    

     

     

     

     


     

     

     

     

    5-2. 코인 트래커 만들기

    5-2-1. 시작하기

     

    필요한 패키지 설치!

    $ npm i react-rotuer-dom

     

     

    Router.tsx 생성 후 경로를 지정해줍니다.

    • / ⇒ 코인 목록(Coins)로
    • /코인ID ⇒ 해당 코인 상세(Coin)으로

     

    /* Router.tsx */
    
    import { BrowserRouter, Routes, Route } from 'react-router-dom';
    import Coin from './routes/Coin';
    import Coins from './routes/Coins';
    
    function Router() {
      return (
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Coins />} />
            <Route path="/:coinId" element={<Coin />} />
          </Routes>
        </BrowserRouter>
      );
    }
    
    export default Router;
    

     

    그리고 App에 Router를 가져온 뒤 실행시켜 보면...

     

    /* App.tsx */
    
    import Router from './Router';
    
    function App() {
      return <Router />;
    }
    
    export default App;
    

     

     

     

    잘 나오네요!

    이제 url의 /:coinId 를 통해 어떤 코인을 보려고 하는지 알아봅시다.

    에 썼던 것처럼 useParams() 훅으로 받아오면 됩니다.

     

    /* Coin.tsx */
    
    import { useParams } from 'react-router-dom';
    
    function Coin() {
      const { coinId } = useParams();
      return <h1>Coin</h1>;
    }
    
    export default Coin;
    

     

    react-router-dom v6 이상인 경우, useParams() 만 쓰더라도 타입이 string | undefined 일 거라고 알아서 예상해 줍니다.

     

     

     

     


     

     

    5-2-2. API 받아오기

     

    받아올 코인 정보를 state로 관리해 봅시다!

     

    const [coins, setCoins] = useState([]);
    

     

     

     

    🥲  거참 엄청 깐깐하시네요

    검수 담당자(=타입스크립트)의 통과를 받기 위해서는, API로 받아온 데이터들의 타입이 어떻게 될지도 미리 알려줘야 합니다.
    그리고 배열 형태로 넘겨받을 거란 것도 알려줍니다.

     

    interface ICoin {
      id: string;
      name: string;
      symbol: string;
      rank: number;
      is_new: boolean;
      is_active: boolean;
      type: string;
    }
    
    const [coins, setCoins] = useState<ICoin[]>([]);
    

     

    이제 useEffect() 를 사용해 최초 한 번 정보를 받아와 setCoins로 coins 안에 넣어줍니다.

     

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

     

    불러오는 동안 화면을 가려줄 로딩 커튼도 있으면 좋고요.

     

    function Coins() {
      const [coins, setCoins] = useState<ICoin[]>([]);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        (async () => {
          const response = await fetch('https://api.coinpaprika.com/v1/coins');
          const json = await response.json();
          setCoins(json.slice(0, 30));
          setLoading(false);
        })();
      }, []);
      return (
        <Container>
          <Header>
            <Title>MyCoin</Title>
          </Header>
          {loading ? (
            <Loader>Loading...</Loader>
          ) : (
            <CoinList>
              {coins.map((coin, i) => (
                <CoinItem key={coin.id}>
                  <Link to={`/${coin.id}`}>{coin.name}</Link>
                </CoinItem>
              ))}
            </CoinList>
          )}
        </Container>
      );
    }

     

    글만 있으면 심심하니 이미지도 넣어줍니다.

     

    <CoinItem key={coin.id}>
    	<Link to={`/${coin.id}`}>
    		<Img src={`https://cryptoicon-api.vercel.app/api/icon/${coin.symbol.toLowerCase()}`}/>
        {coin.name}
      </Link>
    </CoinItem>

     

     

     

    이제 상세화면인 <Coin/>를 작업할 차례입니다.

    상세화면에서 코인 정보를 요청할 때, 이미 <Coins/> 에서 받아온 정보인 coin.name이 있으므로 이걸 넘겨줘 봅시다.

    <Link>의 state에 object 형태로 넘겨줄 수 있어요.

     

    <Link to={`/${coin.id}`} state={{ name: coin.name }}></Link>
    

     

    이제 <Coin/>에서는 useLocation()으로 Location Object에 접근하면 됩니다.

    const { state } = useLocation() as RouteState;

    <Title>{state.name}</Title>

     

    console.log(useLocation());

     

     

    하지만 이대로 state를 가져다 쓰려면 타입을 알려달란 잔소리를 듣습니다.

     

     

    interface RouteState {
      state: {
        name: string;
      };
    }
    
    function Coin() {
      const { state } = useLocation() as RouteState;
      return (
    	  <Title>{state.name}</Title>
      );
    }
    

     

    그럼 API 요청 전, state를 통한 coin.name을 전달할 수 있으므로 화면에 재빨리 표시할 수 있어요.

    다만 문제는 <Coin> ⇒ <Coins> 가 아닌 바로 <Coins> 로 접근했을 때는 받아올 coin.name이 없다는 것입니다.
    그래서 안전장치가 하나 필요해요.

     

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

     

    Tip. 옵셔널 체이닝

    a?.b 에서 a가 undefined 또는 null이면 평가를 멈추고 undefined를 반환합니다. 이때 a가 선언되어 있지 않으면 에러가 발생합니다.

     

     

    이제 세부 코인 정보를 불러옵니다.

     

    const [info, setInfo] = useState({});
    const [priceInfo, setPriceInfo] = useState({});
    
    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);
    			setLoading(false);
        })();
      }, [coinId]); // coinId는 URL을 통해 넘어오므로 변경될 일이 없어 어차피 1번만 실행됨
    

     

     

    그럼 다음에 할 일은... 깐깐한 검수 통과를 위한 타입 지정이죠!

    useState({}) 로 지정했기 때문에 타입스크립트는 info나 priceInfo가 빈 객체일 거라고 생각하지만,
    사실 거기엔 뭐뭐가 들어갈 거야 라고 세부사항을 말해줘야 합니다.

     

     

    우선 이렇게 가져올 수 있어요!

    콘솔로 찍고 ⇒ 우클릭 - ‘Copy object’ ⇒ JSON을 타입으로 변환해주는 사이트 활용

     

     

     

    interface InfoData {
      id: string;
      name: string;
      //...
    }
    
    interface PriceData {
      id: string;
      name: string;
      //...
    }
    
    const [info, setInfo] = useState<InfoData>();
    const [priceInfo, setPriceInfo] = useState<PriceData>();
    

     

    Tip. VS CODE 단축키

    • Ctrl + D : 동일한 단어 한번에 선택
    • Opt + Up/Down :해당 줄의 코드를 위아래로 위치 변경 (Shift 포함 시 해당 위치로 복제)
    • Opt + Shift + I : 각줄의 가장 오른쪽에 커서 포커스
    • Opt + Shift + Drag : 각줄의 동일한 위치에 커서 포커스

     

     

     


     

     

     

     

    5-2-3. Nested Routes

     

    <Coin/> 내에는 2개의 탭이 있어 하나는 가격 정보를, 하나는 차트를 보여준다고 해봅시다.

    이때 /chart 등을 통해 넘어온다면 바로 차트 탭이 보여지도록 하려고 합니다.

     

    그러려면 라우트 설정이 필요하겠네요! react-router-dom v6 기준으로,

    Route 안에 하위 Route 를 넣고 Outlet으로 표시하거나 (참고)

     

    import { Routes, Route, Outlet } from "react-router-dom";
    
    function App() {
      return (
        <Routes>
          <Route path="invoices" element={<Invoices />}>
            <Route path=":invoiceId" element={<Invoice />} />
            <Route path="sent" element={<SentInvoices />} />
          </Route>
        </Routes>
      );
    }
    
    function Invoices() {
      return (
        <div>
          <h1>Invoices</h1>
          <Outlet />
        </div>
      );
    }
    
    function Invoice() {
      let { invoiceId } = useParams();
      return <h1>Invoice {invoiceId}</h1>;
    }
    
    function SentInvoices() {
      return <h1>Sent Invoices</h1>;
    }
    

     

    요소 내부에서 후손 Route를 렌더링할 수 있습니다. (참고)

     

    function App() {
      return (
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="dashboard/*" element={<Dashboard />} />
        </Routes>
      );
    }
    
    function Dashboard() {
      return (
        <div>
          <p>Look, more routes!</p>
          <Routes>
            <Route path="/" element={<DashboardGraphs />} />
            <Route path="invoices" element={<InvoiceList />} />
          </Routes>
        </div>
      );
    }
    

     

    routes/ 폴더 내 Price.tsx와 Chart.tsx를 만들고 중첩시켜 표현해 봅시다.

     

    /* Router.tsx */
    
    import { BrowserRouter, Routes, Route } from 'react-router-dom';
    import Coin from './routes/Coin';
    import Coins from './routes/Coins';
    import Price from './routes/Price';
    import Chart from './routes/Chart';
    
    function Router() {
      return (
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Coins />} />
            <Route path="/:coinId" element={<Coin />}>
              <Route path="price" element={<Price />} />
              <Route path="chart" element={<Chart />} />
            </Route>
          </Routes>
        </BrowserRouter>
      );
    }
    
    export default Router;
    
    /* Coin.tsx */
    
    import { useEffect, useState } from 'react';
    import { useParams, useLocation, Outlet } from 'react-router-dom';
    //...
    
    function Coin() {
      //...
    
      return (
        <Container>
    	    {/* ... */}
    	    <div className="tab">
    	      <Outlet />
    	    </div>
        </Container>
      );
    }
    
    export default Coin;
    

     

    이제 각 탭을 클릭하면 /price 또는 /chart URL로 연결되도록 합니다.

     

    <Tabs>
      <Tab>
        <Link to={`/${coinId}/price`}>Price</Link>
      </Tab>
      <Tab>
        <Link to={`/${coinId}/chart`}>Chart</Link>
      </Tab>
    </Tabs>
    <Outlet />
    

     

    현재 탭이 활성화되어 있는지 (=URL이 price 또는 chart인지) 확인해 .isActive 클래스를 붙여줍니다.

    useMatch() 훅은 경로와 URL이 일치하면 관련 객체를 반환하고, 아니라면 null을 반환합니다. (참고)

     

    const priceMatch = useMatch('/:coinId/price');
    const chartMatch = useMatch('/:coinId/chart');
    
    console.log(priceMatch);
    console.log(chartMatch);
    
    <Tabs>
    	<Tab isActive={priceMatch !== null}>
    	  <Link to={`/${coinId}/price`}>Price</Link>
    	</Tab>
    	<Tab isActive={chartMatch !== null}>
    	  <Link to={`/${coinId}/chart`}>Chart</Link>
    	</Tab>
    </Tabs>
    

     

    이제 할 일은 styled-component 안에 props.isActive ? 로 분기 처리하는 거겠네요.

    아, 타입스크립트가 isActive라는 custom props의 타입을 인지할 수 있도록 알려주는 것도 필요하고요. (참고)

     

    const Tab = styled.span<{ isActive: boolean }>`
      color: ${(props) =>
        props.isActive ? props.theme.accentColor : props.theme.textColor};
    `;
    

     

     

     


     

     

     

    5-2-4. React Query

     

    이번에는 React Query를 써서 더 쉽고 편하게 리팩토링 해보죠!

    이녀석은 React 애플리케이션에서 서버 상태를 가져오고, 캐싱하고, 동기화하고, 업데이트하는 작업 등을 간단하게 만들어 줍니다.

    $ npm i react-query 로 설치 후 { QueryClient, QueryClientProvider } 를 가져옵니다.

     

    /* index.tsx */
    
    //...
    import { QueryClient, QueryClientProvider } from "react-query";
    const queryClient = new QueryClient();
    
    ReactDOM.render(
      <React.StrictMode>
        <QueryClientProvider client={queryClient}>
          <ThemeProvider theme={theme}>
            <App />
          </ThemeProvider>
        </QueryClientProvider>
      </React.StrictMode>,
      document.getElementById('root')
    );
    

     

    이제 <Coins/> 에서 코인 정보를 불러와 화면에 보여줄 때까지의 일들을 React Query에게 일임해 봅시다.

     

     

    우선 api.ts를 만들어 API fetch와 관련된 기능을 몰아넣고 fetcher 함수로 만듭니다.

     

    /* api.ts */
    
    export async function fetchCoins(){
      return await (await fetch('<https://api.coinpaprika.com/v1/coins>')).json();
    }
    

     

    그런 다음 <Coins/>에서 useQuery 훅을 사용합니다. (참고)

    이때 두 가지 인자를 넘겨야 합니다:

    1. 쿼리에 대한 고유식별자(A unique key)
    2. promise를 리턴하는 함수

     

    /* Coins.tsx */
    
    //...
    import { useQuery } from "react-query";
    import { fetchCoins } from "../api";
    
    function Coins() {
    	useQuery('allCoins', fetchCoins);
    }
    

     

     

    useQuery의 리턴값은 쿼리에 대한 정보를 담고 있어요.

    • data : 쿼리로 넘어온 데이터
    • isLoading : 쿼리 데이터가 아직 없고 fetch 중인지
    • isError : 쿼리 중 에러가 발생했는지
    • isSuccess : 쿼리 완료 후 데이터에 접근할 수 있는지

     

     

    그럼 기존에 만들어뒀던 loading state를 isLoading으로, coin state를 data로 대체할 수 있겠네요.

    data는 기존대로 30개까지만 불러오고, 쿼리 실패 시 undefined가 될 수 있으므로 옵셔널 체이닝을 합니다.

     

    {isLoading ? (
      <Loader>Loading...</Loader>
    ) : (
    	//...
    )}
    
    {data?.slice(0,30).map()}
    

     

    이때 타입스크립트가 data가 뭐냐며 검수하러 들기 때문에 타입 지정을 해줍니다.

     

    const { isLoading, data } = useQuery<ICoin[]>('allCoins', fetchCoins);
    

     

     

     

    이렇게 하면 fetch API 과정을 짧은 코드로 단축할 수 있고,
    React Query가 쿼리 결과값을 캐싱하기 때문에 다시 불러올 필요가 없습니다. (쾌-적)

     

    React Query의 Devtools를 설치하면 관련된 내용들을 시각적으로 쉽게 볼 수 있습니다.

     

    import { ReactQueryDevtools } from 'react-query/devtools'
     
     function App() {
       return (
         <QueryClientProvider client={queryClient}>
           {/* The rest of your application */}
           <ReactQueryDevtools initialIsOpen={false} />
         </QueryClientProvider>
       )
     }
    

     

     

     

     

    이번에는 <Coin/> 컴포넌트를 React Query로 정리해 봅시다.

    마찬가지로 API 호출을 api.tsx에서 관리하고...

     

    const BASE_URL = `https://api.coinpaprika.com/v1`;
    
    export async function fetchCoins() {
      return await (await fetch(`${BASE_URL}/coins`)).json();
    }
    
    export async function fetchCoinInfo(coinId: string) {
      return await (await fetch(`${BASE_URL}/coins/${coinId}`)).json();
    }
    
    export async function fetchCoinTickers(coinId: string) {
      return await (await fetch(`${BASE_URL}/tickers/${coinId}`)).json();
    }
    

     

    useQuery로 불러옵니다.

    이때, unique key를 배열 형태로 지정하고, 타입스크립트에게 혼나지 않게 타입을 알려줍시다.

    아, 그리고 구조분해 할당으로 꺼내온 isLoading과 data의 이름을 각각 다르게 붙여줄 수도 있어요. (참고)

     

    const { isLoading: infoLoading, data: infoData } = useQuery<InfoData>(
      ['info', coinId],
      () => fetchCoinInfo(coinId)
    );
    
    const { isLoading: tickersLoading, data: tickersData } = useQuery<PriceData>(
      ['tickers', coinId],
      () => fetchCoinTickers(coinId)
    );
    

     

     

    앗, 그런데 음? “타입이 string | undefined인 녀석을 string 타입의 파라미터로 지정하려 들다니!”하고 시비... 아니, 지적을 하네요.

     

     

    그 이유는 우리가 useParams 훅을 썼기 때문입니다.

     

    ~과거회상~

    const { coinId } = useParams();

     react-router-dom v6 이상인 경우, useParams() 만 쓰더라도 타입이 string | undefined 일 거라고 알아서 예상해 줍니다.

    ~과거회상~

     

     

    그러면 그냥 “그래그래 이 자리엔 string | undefined 타입이 들어올 거야”라고 fetcher 함수 내 파라미터 타입을 수정하거나

     

    export async function fetchCoinInfo(coinId: string | undefined) {
      return await (await fetch(`${BASE_URL}/coins/${coinId}`)).json();
    }
    

     

    아니면 non-null assertion 연산자를 써서 피연산자가 null이나 undefined가 아닐 거라고(=항상 값이 할당될 거라고) 주장할 수 있습니다.

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

     

    추가로 특정 시간마다 리프레쉬 되도록 하려면, useQuery의 세번째 인자로 Object를 넘겨줍니다.

     

    const { isLoading: tickersLoading, data: tickersData } = useQuery<PriceData>(
    	['tickers', coinId],
    	() => fetchCoinTickers(coinId),
    	{
    	  refetchInterval: 5000
    	}
    );
    

     

     

     

    여기에 React Helmet도 써보죠! 얘는 문서의 head를 쉽게 관리할 수 있게 합니다.

    그렇다는 건 <head>의 <title>을 현재 보고 있는 코인 이름으로 바꾸게 하는 것도 가능하단 거죠.

     

    $ npm i react-helmet

    $ npm i --save-dev @types/react-helmet

     

    import { Helmet } from "react-helmet";
    
    //...
    return(
    	<Helmet>
    	  <title>
    	    {state?.name ? state.name : loading ? 'MyCoins' : infoData?.name}
    	  </title>
    	</Helmet>
    );
    //...
    

     

    문서의 제목을 바꿔봤습니다 :9

     

     

     


     

     

    5-2-5. 차트 그리기

     

    마지막으로 <Chart> 컴포넌트 작업을 해줄 차례입니다!

    우선 차트를 그릴 수 있게 정보를 받아옵니다.

     

    /* api.ts */
    
    export async function fetchCoinHistory(coinId: string | undefined) {
      const endDate = Math.floor(Date.now() / 1000);
      const startDate = endDate - 60 * 60 * 24 * 7;
      return await (
        await fetch(
          `${BASE_URL}/coins/${coinId}/ohlcv/historical?start=${startDate}&end=${endDate}`
        )
      ).json();
    }
    

     

    그럼 <Chart/>내에서 coinId 값을 받아와야 하는데,

    현재 <Outlet>으로 가져오는 중이여서 useOutletContext 훅으로 props를 전달받아야 합니다.

    아니면 그냥 <Chart/>에서 useParams로 아이디 값을 알아올 수도 있겠죠.

     

    /* Chart.tsx */
    
    import { useQuery } from 'react-query';
    import { useParams } from 'react-router-dom';
    import { fetchCoinHistory } from '../api';
    interface IHistorical {
      time_open: string;
      time_close: string;
      open: number;
      high: number;
      low: number;
      close: number;
      volume: number;
      market_cap: number;
    }
    
    function Chart() {
      const { coinId } = useParams();
      const { isLoading, data } = useQuery<IHistorical[]>(['ohlcv', coinId], () =>
        fetchCoinHistory(coinId)
      );
      return <h1>Chart: {coinId}</h1>;
    }
    
    export default Chart;
    

     

    차트 라이브러리는 apexcharts 를 써봅시다.

    $ npm install --save react-apexcharts apexcharts

     

    import ApexChart from "react-apexcharts";
    

     

    (문서보고 대충 옵션 슥슥)

     

     

     

     

    (c) LINE+

     

    ---

    2022.07 타입스크립트로 담근 칸반 클론 with 노마드코더 👇👇

    https://nana-like.github.io/react-iWork/

     

    iWork

     

    nana-like.github.io

     

    728x90

    댓글