티스토리 뷰

이번에는 지금까지 배운 내용을 활용하여 카테고리 별로 최신 뉴스 목록을 보여 주는 News Viewer를 만들어보려고 한다. 

https://newsapi.org/ 에서 제공하는 API를 사용하여 데이터를 받아 오고, CSS의 경우 styled-components를 활용하여 스타일링해보려고 한다.

 

바로 한번 들어가보자.


api는 HTTP 요청을 Promise 기반으로 처리하는, 현재 자바스크립트에서 가장 많이 사용된다는 axios를 사용한다.

npm install axios

axios의 사용법부터 알아보자.

 

우선, App.js 에서 버튼을 클릭하여 get 요청을 보내고, 응답을 그대로 페이지에 나타도록 구현한다.

 

◎App.js

import { useState } from 'react';
import axios from 'axios';

const App = () => {
  const [data, setData] = useState(null);

  const onClick = () => {
    axios.get('https://jsonplaceholder.typicode.com/todos/1').then(response => {
      setData(response.data);
    });
  };

  return (
      <div>
        <div>
          <button onClick={onClick}>불러오기</button>
        </div>
        {data && <textarea rows={7} value={JSON.stringify(data, null, 2)} readOnly={true} />}
      </div>
  );
};

export default App;

onClick 함수에서 axios.get 함수를 사용한다. 이 함수는 파라미터로 전달된 주소에 GET 요청을 처리한다. 

그리고 이에 대한 결과는 .then을 통해 비동기로 확인한다.

 

위 코드에 async, await 를 적용할 수도 있다.

import { useState } from 'react';
import axios from 'axios';

const App = () => {
  const [data, setData] = useState(null);

  const onClick =  async () => {
      try{
          const response = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
          setData(response.data);
      } catch(e) {
          console.log(e);
      }
  };

  return (
      <div>
        <div>
          <button onClick={onClick}>불러오기</button>
        </div>
        {data && <textarea rows={7} value={JSON.stringify(data, null, 2)} readOnly={true} />}
      </div>
  );
};

export default App;

바로 한번 만들어보자.

 

우선, 필요한 컴포넌트는 다음과 같다.

 

1. Categories: 뉴스의 카테고리를 분류할 컴포넌트

2. NewsList: 뉴스 리스트 컴포넌트

3. NewsItem: 뉴스 리스트가 가질 각 뉴스 객체를 저장할 컴포넌트

4. NewsPage: 리액트 라우터를 사용하기 위한 전체 페이지

 


◎NewsItem.js

import styled from 'styled-components';

const NewsItemBlock = styled.div`
    display: flex;
    .thumbnail {
        margin-right: 1rem;
        img {
            width: 160px;
            height: 100px;
            object-fit: cover;
        }
    }
    
    .contents {
        h2 {
            margin: 0;
            a { 
                color: black;
            }
        }
        p {
            margin: 0;
            line-height: 1.5;
            margin-top: 0.5rem;
            white-space: normal;
        }
    }
    & + & {
        margin-top: 3rem;
    }
`;

const NewsItem = ({article}) => {
    const {title, description, url, urlToImage} = article;
    return (
        <NewsItemBlock>
            {urlToImage && (
                <div className="thumbnail">
                    <a href={url} target="_blank" rel="nooperner noreforrer">
                        <img src={urlToImage} alt="thumbnail" />
                    </a>
                </div>
            )}
            <div className="contents">
                <h2>
                    <a href={url} target="_blank" rel="nooperner noreforrer">
                        {title}
                    </a>
                </h2>
                <p>{description}</p>
            </div>
        </NewsItemBlock>
    );
};

export default NewsItem;

 

◎NewsList.js

import styled from "styled-components";
import NewsItem from "./NewsItem";
import {useState, useEffect} from 'react';
import axios from "axios";

const NewsListBlock = styled.div`
    box-sizing: border-box;
    padding-bottom: 3rem;
    width: 768px;
    margin: 0 auto;
    margin-top: 2rem;
    @media screen and (max-width: 768px) {
        width: 100%;
        padding-left: 1rem;
        padding-right: 1rem;
    }
`;

const NewsList = ({category}) => {
    const [articles, setArticles] = useState(null);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        // async를 사용하는 함수 따로 선언
        const fetchData = async () => {
            setLoading(true);
            try{
                const query = category === 'all' ? '' : `&category=${category}`;
                const response = await axios.get(`https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=bb70a3f005294996b7fecbd2923d7046`);
                setArticles(response.data.articles);
            } catch(e) {
                console.log(e);
            }
            setLoading(false);
        };
        fetchData();
    }, [category]);

    // 대기 중일 때
    if(loading) {
        return <NewsListBlock>대기 중...</NewsListBlock>;
    }

    // 아직 articles 값이 설정되지 않았을 때
    if(!articles) {
        return null;
    }

    return (
        <NewsListBlock>
            {articles.map(article => (
                <NewsItem key={article.url} article={article}/>
            ))}
        </NewsListBlock>
    );
};

export default NewsList;

카테고리의 경우, 영어로 된 쿼리스트링을 그대로 가져와 한글로 바꿔줄 json Array 를 가지고 있어야 해서.. 좀 길다.

 

◎Categories.js

import styled, {css} from'styled-components';
import {NavLink} from "react-router-dom";

const categories = [
    {
        name: 'all',
        text: '전체보기'
    },
    {
        name: 'business',
        text: '비즈니스'
    },
    {
        name: 'entertainment',
        text: '엔터테인먼트'
    },
    {
        name: 'health',
        text: '건강'
    },
    {
        name: 'science',
        text: '과학'
    },
    {
        name: 'sports',
        text: '스포츠'
    },
    {
        name: 'technology',
        text: '기술'
    }
];

const CategoriesBlock = styled.div`
    display:flex;
    padding: 1rem;
    width: 768px;
    margin: 0 auto;
    @media screen and (max-width: 768px) {
        width: 100%;
        overflow-x: auto;
    }
`;

const Category = styled(NavLink)`
    font-size: 1.125rem;
    cursor: pointer;
    white-space: pre;
    text-decoration: none;
    color: inherit;
    padding-bottom: 0.25rem;
    
    &:hover {
        color: #495057;
    }
    
    &.active {
        font-weight: 600;
        border-bottom: 2px solid #22b8cf;
        color: #22b8cf;
        &:hover {
            color: #3bc9db;
        }
    }
    
    & + & {
        margin-left: 1rem;
    }
`;

const Categories = () => {
    return (
        <CategoriesBlock>
            { /* Category가  NavLink로 만들어졌기 때문에 to 값을 사용*/ }
            {categories.map(c => (
                <Category
                    key={c.name}
                    className={({isActive}) => (isActive ? 'active' : undefined)}
                    to={c.name === 'all' ? '/' : `/${c.name}`}
                >
                    {c.text}
                </Category>
            ))}
        </CategoriesBlock>
    );
};

export default Categories;

또한 NavLink를 사용했기 때문에 to 라는 props 속성에 카테고리 이름을 작성해줬다.


◎NewsPage.js

import {useParams} from 'react-router-dom';
import Categories from "../components/Categories";
import NewsList from "../components/NewsList";

const NewsPage = () => {
    const params = useParams();

    const category = params.category || 'all';

    return (
        <>
            <Categories/>
            <NewsList category={category}/>
        </>
    );
};

export default NewsPage;

마지막으로 url 정보로 컴포넌트를 호출할 App 컴포넌트를 아래와 같이 작성해주면 된다.

 

◎App.js

import {Route, Routes} from "react-router-dom";
import NewsPage from "./pages/NewsPage";

const App = () => {
  return (
     <Routes>
         {/*카테고리가 있던 없던 보여줘야 함 */}
         <Route path="/" element={<NewsPage/>}/>
         <Route path="/:category" element={<NewsPage/>}/>
     </Routes>
  );
};

export default App;

그럼 위와 같이 뉴스 뷰어가 가져야 할 기능을 모두 구현된다!

 

axios를 사용하는 것과 async, wait 문법을 사용한 것을 제외하면 모두 이전에 다뤘던 내용을 기반으로 제작한 프로젝트이기 때문에 따로 많은 설명을 붙이지는 않겠다. (개인적으로 나중에 봤을 때, 설명이 없어서 좀 헤매야 기억에 잘 남아서...)

 

마지막으로 async, await을 사용하여 처리했던, 대기 중 / 완료 / 실패에 대한 상태 관리를 해줄 커스텀 Hook을 한번 만들어서 적용해보고 마무리하려고 한다.


◎usePromise.js

import {useState, useEffect} from 'react';

export default function usePromise(promiseCreator, deps) {
    const [loading, setLoading] = useState(false);
    const [resolved, setResolved] = useState(null);
    const [error, setError] = useState(null);
    
    useEffect( () => {
        const process = async () => {
            setLoading(true);
            try{
                const resolved = await promiseCreator();
                setResolved(resolved);
            } catch(e) {    
                setError(e);
            }
            setLoading(false);
        };
        process();
    }, deps);
    
    return [loading, resolved, error];
}

 

이를 NewsList 컴포넌트에 적용하자.

 

◎NewsList.js

import styled from "styled-components";
import NewsItem from "./NewsItem";
import axios from "axios";
import usePromise from "../lib/usePromise";

const NewsListBlock = styled.div`
    box-sizing: border-box;
    padding-bottom: 3rem;
    width: 768px;
    margin: 0 auto;
    margin-top: 2rem;
    @media screen and (max-width: 768px) {
        width: 100%;
        padding-left: 1rem;
        padding-right: 1rem;
    }
`;

const NewsList = ({category}) => {
    const [loading, response, error] = usePromise(() => {
        const query = category === 'all' ? '' : `&category=${category}`;
        return axios.get(`https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=bb70a3f005294996b7fecbd2923d7046`);
    }, [category]);

    // 대기 중일 때
    if(loading) {
        return <NewsListBlock>대기 중...</NewsListBlock>;
    }

    // 아직 articles 값이 설정되지 않았을 때
    if(!response) {
        return null;
    }
    
    if(error) {
        return <NewsListBlock>에러!</NewsListBlock>;
    }
    
    const {articles} = response.data;

    return (
        <NewsListBlock>
            {articles.map(article => (
                <NewsItem key={article.url} article={article}/>
            ))}
        </NewsListBlock>
    );
};

export default NewsList;

 

위와 같이 usePromise라는 커스텀 Hook을 만들어 사용함으로써 코드가 조금 간결해지기는 했지만, 나중에 사용해야 할 API의 종류가 많아지면 요청을 위한 상태 관리를 하는 것이 조금 번거로워 질 수 있기 때문에 다음에 다룰 리덕스와 리덕스 미들웨어로 좀 더 쉽게 요청을 처리할 수 있다.


 

 

끝!!

반응형

'WEB > React' 카테고리의 다른 글

[React] 리덕스: 사전 지식  (0) 2023.03.31
[React] Context API  (0) 2023.03.30
[React] Routing: React Router  (0) 2023.03.30
[React] 불변성 유지: immer  (0) 2023.03.29
[React] Todo List: 컴포넌트 성능 최적화  (0) 2023.03.29
Comments