4. 포스트 리스팅 페이지 구현
이번 섹션에서는 여러 포스트를 불러오는 기능을 구현해보겠습니다. 이전에 로그인/회원가입을 구현 했을 때 처럼 UI 를 먼저 완성하고 나서 데이터를 연동해주겠습니다.
UI 준비하기
포스트 리스팅에 관련 된 컴포넌트는 components 디렉터리에서 postList 라는 이름으로 분류해주겠습니다.
PostList 만들기
우선 PostList 컴포넌트를 작성해보세요.
components/postList/PostList.js
import React from 'react';
import { Link } from 'react-router-dom';
import './PostList.scss';
const PostList = ({ posts }) => {
return (
<div className="PostList">
<div className="post-item">
<Link to="/posts/1" className="title">
title
</Link>
<div className="meta">
<span className="username">
by <b>username</b>
</span>
<span className="separator">·</span>
<span className="date">1시간 전</span>
</div>
<p>body</p>
</div>
<div className="post-item">
<Link to="/posts/1" className="title">
title
</Link>
<div className="meta">
<span className="username">
by <b>username</b>
</span>
<span className="separator">·</span>
<span className="date">1시간 전</span>
</div>
<p>body</p>
</div>
</div>
);
};
export default PostList;
components/postList/PostList.scss
.PostList {
.post-item {
padding-top: 2rem;
padding-bottom: 2rem;
.title {
color: $oc-gray-9;
font-size: 2rem;
font-weight: 800;
&:hover {
color: $oc-teal-6;
}
}
.meta {
margin-top: 0.5rem;
color: $oc-gray-6;
font-style: italic;
.separator {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
}
p {
color: $oc-gray-7;
font-size: 1.125rem;
line-height: 1.5;
}
}
.post-item + .post-item {
border-top: 1px solid $oc-gray-2;
}
}
이 컴포넌트에서는 추후 posts 배열을 props 로 받아와서 map 을 사용하여 여러 포스트들을 렌더링해 줄 것입니다. 지금 당장은 posts 가 없기 때문에 포스트 항목을 직접 하드코딩하여 보여주고 있습니다.
이 컴포넌트를 PostListPage.js 에서 헤더 하단에 렌더링하세요.
pages/PostListPage.js
import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import PostList from '../components/postList/PostList';
/**
* 여러 포스트 목록을 보여주는 페이지
*/
const PostListPage = () => {
return (
<>
<HeaderContainer />
<Responsive>
<div className="PostListPage">
<PostList />
</div>
</Responsive>
</>
);
};
export default PostListPage;
그리고, 이전에 Sass 가 작동하는지 확인하기 위해서 작성했었던 PostListPage.scss 내부의 코드를 지워주세요.
pages/PostListPage.scss
.PostListPage {
}
저장을 하고나서 이런 화면이 나타나는지 확인하세요.
WritePostButton 만들기
이 컴포넌트는 로그인 했을 때만 보여지게 될 글쓰기 버튼입니다. 여기서는 react-icons 에서 아이콘을 불러와서 사용하게 되니 react-icons 를 설치하세요.
$ yarn add react-icons
components/pageList/WritePostButton.js
import React from 'react';
import { MdNoteAdd } from 'react-icons/md';
import { Link } from 'react-router-dom';
import './WritePostButton.scss';
const WritePostButton = () => {
return (
<div className="WritePostButton">
<Link to="/write">
<MdNoteAdd />
<div className="text">새 글 작성하기</div>
</Link>
</div>
);
};
export default WritePostButton;
components/pageList/WritePostButton.scss
.WritePostButton {
display: flex;
justify-content: flex-end;
a {
background: $oc-gray-9;
color: white;
display: flex;
align-items: center;
padding: 0.5rem 1rem;
&:hover {
background: $oc-gray-6;
}
.text {
font-weight: 600;
margin-left: 0.5rem;
}
}
}
이 컴포넌트를 PostListPage 에서 PostList 상단에 렌더링해보세요.
버튼이 잘 렌더링 됐나요?
Pagination 컴포넌트 만들기
Pagination 컴포넌트는 다음 페이지 혹은 이전 페이지로 이동 할 수 있게 해주는 컴포넌트입니다.
components/postList/Pagination.js
import React from 'react';
import { MdChevronLeft, MdChevronRight } from 'react-icons/md';
import { Link } from 'react-router-dom';
import './Pagination.scss';
const Pagination = ({ page = 1, lastPage }) => {
return (
<div className="Pagination">
{page === 1 ? (
<div className="disabled-btn">
<MdChevronLeft />
</div>
) : (
<Link to={`/?page=${page - 1}`} disabled={page === 1}>
<MdChevronLeft />
</Link>
)}
<div className="page">
<b>{page}</b> 페이지
</div>
{page >= lastPage ? (
<div className="disabled-btn">
<MdChevronRight />
</div>
) : (
<Link to={`/?page=${page + 1}`}>
<MdChevronRight />
</Link>
)}
</div>
);
};
export default Pagination;
components/pageList/Pagination.scss
.Pagination {
display: flex;
margin-bottom: 3rem;
align-items: center;
justify-content: center;
a,
.disabled-btn {
width: 2rem;
height: 2rem;
background: $oc-gray-7;
border-radius: 1rem;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
a {
cursor: pointer;
&:hover {
background: $oc-gray-5;
}
}
.disabled-btn {
background: $oc-gray-4;
color: $oc-gray-2;
}
.page {
margin-left: 1rem;
margin-right: 1rem;
color: $oc-gray-7;
b {
color: $oc-gray-9;
font-weight: 600;
}
}
}
이제 이 컴포넌트를 PostListPage 에서 PostList 하단에 렌더링해줄 차례입니다. 이번에는 리액트 라우터에서 넘겨주는 location.search 값에서 쿼리 파라미터를 파싱하여 page 값을 전달해줘야 하므로, qs 라이브러리를 설치해서 사용하겠습니다.
$ yarn add qs
pages/PostListPage.js
import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import PostList from '../components/postList/PostList';
import WritePostButton from '../components/postList/WritePostButton';
import Pagination from '../components/postList/Pagination';
import qs from 'qs';
/**
* 여러 포스트 목록을 보여주는 페이지
*/
const PostListPage = ({ location }) => {
const query = qs.parse(location.search, {
ignoreQueryPrefix: true
});
return (
<>
<HeaderContainer />
<Responsive>
<div className="PostListPage">
<WritePostButton />
<PostList />
<Pagination page={parseInt(query.page || '1')} />
</div>
</Responsive>
</>
);
};
export default PostListPage;
Pagination 컴포넌트가 잘 보여지고 있나요?
API 연동
이제 API 를 연동해줄 차례입니다. 우선 사용 할 API 를 함수화 하여 저장하겠습니다.
lib/api/posts.js
import client from './client';
export const listPosts = (page = 1) => client.get(`/api/posts?page=${page}`);
export const read = id => client.get(`/api/posts/${id}`);
listPosts 는 여러 포스트를 불러오는 API 이고, read 는 단일 포스트를 불러오는 API 입니다.
API 함수를 모두 작성하셨으면 posts 리덕스 모듈을 작성하세요.
modules/posts.js
import { handleActions } from 'redux-actions';
import createPromiseThunk from '../lib/createPromiseThunk';
import * as postsAPI from '../lib/api/posts';
const LIST_POSTS = 'posts/LIST_POSTS';
const LIST_POSTS_SUCCESS = 'posts/LIST_POSTS_SUCCESS';
export const listPosts = createPromiseThunk(LIST_POSTS, postsAPI.listPosts);
const initialState = {
list: null
};
export default handleActions(
{
[LIST_POSTS_SUCCESS]: (state, { payload }) => ({
...state,
list: payload.data
})
},
initialState
);
그 다음엔 루트리듀서에 포함시켜주세요.
modules/index.js
import { combineReducers } from 'redux';
import auth from './auth';
import user from './user';
import posts from './posts';
const rootReducer = combineReducers({
auth,
user,
posts
});
export default rootReducer;
이제 PostListContainer 를 만들어서 현재 페이지 값에 따라 API 를 요청하도록 구현해주겠습니다.
containers/postList/PostListContainer.js
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import qs from 'qs';
import { listPosts } from '../../modules/posts';
import PostList from '../../components/postList/PostList';
class PostListContainer extends Component {
get page() {
const query = qs.parse(this.props.location.search, {
ignoreQueryPrefix: true
});
return parseInt(query.page || '1', 10);
}
loadPosts = async () => {
await this.props.listPosts(this.page);
window.scrollTo(0, 0);
};
componentDidUpdate(prevProps, prevState) {
if (prevProps.location.search !== this.props.location.search) {
this.loadPosts();
}
if (prevProps.posts !== this.props.posts) {
window.scrollTo(0, 0);
}
}
componentDidMount() {
this.loadPosts();
}
render() {
if (!this.props.list) return null;
return <PostList posts={this.props.list.posts} />;
}
}
export default withRouter(
connect(
state => ({
list: state.posts.list
}),
{
listPosts
}
)(PostListContainer)
);
작성 후, PostListPage 에서 기존의 PostList 를 PostListPage 로 대체시키세요.
이제 진짜 데이터를 보여줄 차례입니다. 진짜 데이터를 보여주기 전에, date-fns 라는 라이브러리를 설치하겠습니다. 이 라이브러리는 날짜를 원하는 형식으로 쉽게 변환해주는 역할을 합니다.
$ yarn add date-fns
그리고, lib 디렉터리에 common.js 라는 파일을 만들어서 그 안에 formatDate 라는 함수를 만들도록 하겠습니다. common.js 에 작성하는 이유는 추후 단일 포스트를 읽을 때에도 똑같은 작업을 할 것이기 때문입니다.
lib/common.js
import { format, distanceInWordsToNow } from 'date-fns';
import koLocale from 'date-fns/locale/ko';
export function formatDate(date) {
const now = new Date();
const d = new Date(date);
const diff = now.getTime() - d.getTime();
if (diff < 1000 * 60) {
return '방금 전';
}
if (diff < 1000 * 60 * 60 * 24 * 7) {
return distanceInWordsToNow(d, {
locale: koLocale,
addSuffix: true
});
}
return format(d, 'YYYY-MM-DD');
}
그 다음엔 PostList 컴포넌트를 다음과 같이 수정해주세요.
components/postList/PostList.js
import React from 'react';
import { Link } from 'react-router-dom';
import './PostList.scss';
import { formatDate } from '../../lib/common';
const PostList = ({ posts }) => {
return (
<div className="PostList">
{posts.map(post => (
<div className="post-item" key={post.id}>
<Link to={`/posts/${post.id}`} className="title">
{post.title}
</Link>
<div className="meta">
<span className="username">
by <b>{post.user.username}</b>
</span>
<span className="separator">·</span>
<span className="date">{formatDate(post.created_at)}</span>
</div>
<p>{post.body}</p>
</div>
))}
</div>
);
};
export default PostList;
이렇게 해주고 나면 실제 포스트를 화면에서 볼 수 있게 될 것입니다.
그 다음에는 PaginationContainer 도 만들어주겠습니다. 이 컴포넌트는 가장 마지막 페이지를 리덕스 스토어에서 받아와서 Pagination 에게 전달해줍니다.
containers/postList/PaginationContainer.js
import React, { Component } from 'react';
import Pagination from '../../components/postList/Pagination';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import qs from 'qs';
class PaginationContainer extends Component {
get page() {
const query = qs.parse(this.props.location.search, {
ignoreQueryPrefix: true
});
return parseInt(query.page || '1', 10);
}
render() {
if (!this.props.hasPosts) return null;
return <Pagination page={this.page} lastPage={this.props.lastPage} />;
}
}
export default withRouter(
connect(state => ({
hasPosts: !!state.posts.list,
lastPage: state.posts.list ? Math.ceil(state.posts.list.count / 10) : 1
}))(PaginationContainer)
);
다 작성하신 후 기존의 Pagination 을 PaginationContainer 로 대체시키세요.
pages/PostListPage.js
import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import WritePostButton from '../components/postList/WritePostButton';
import PostListContainer from '../containers/postList/PostListContainer';
import PaginationContainer from '../containers/postList/PaginationContainer';
/**
* 여러 포스트 목록을 보여주는 페이지
*/
const PostListPage = () => {
return (
<>
<HeaderContainer />
<Responsive>
<div className="PostListPage">
<WritePostButton />
<PostListContainer />
<PaginationContainer />
</div>
</Responsive>
</>
);
};
export default PostListPage;
이제 마지막 페이지까지 갔을 때 우측 버튼이 비활성화 되는지 확인해보세요.
로그인 했을 때만 글쓰기 버튼 보여주기
상단의 새 글 작성하기 버튼은 로그인 상태에서만 보여져야 합니다. 때문에, WritePostButton 컴포넌트를 위한 컨테이너를 또 따로 만들어주어야 합니다. 이번에는 connect 를 대신에, Render Props 를 사용하여 WithUser 라는 컴포넌트를 만들고, 그 컴포넌트를 통하여 WritePostButton 렌더링 여부를 정하겠습니다.
containers/common/WithUser.js
// Render Props 를 사용하여 원하는 곳에서 쉽게 유저 조회하는 예제
// PostListPage 에서 사용중
import { connect } from 'react-redux';
const WithUser = ({ children, user }) => {
return children(user);
};
export default connect(state => ({
user: state.user.user
}))(WithUser);
pages/PostListPage.js
import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import WritePostButton from '../components/postList/WritePostButton';
import PostListContainer from '../containers/postList/PostListContainer';
import PaginationContainer from '../containers/postList/PaginationContainer';
import WithUser from '../containers/common/WithUser';
/**
* 여러 포스트 목록을 보여주는 페이지
*/
const PostListPage = () => {
return (
<>
<HeaderContainer />
<Responsive>
<div className="PostListPage">
<WithUser>{user => (user ? <WritePostButton /> : null)}</WithUser>
<WritePostButton />
<PostListContainer />
<PaginationContainer />
</div>
</Responsive>
</>
);
};
export default PostListPage;
이렇게 WithUser 컴포넌트를 만들어두면, user 값을 조회해야 하는 상황에 더욱 쉽게 구현 할 수 있게 됩니다.