3까지 애플리케이션의 회원 인증 시스템이 모두 구현되었습니다. 이번에는 글쓰기 페이지 기능을 구현해 보겠습니다. 글쓰기와 관련된 컴포넌트들은 write라는 이름으로 분류하겠습니다.
에디터 UI구현하기
글을 작성하는 에디터는 Quill이라는 라이브러리를 사용하여 구현하겠습니다.
src/components/write/Editor.js
import React, { useRef, useEffect } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.bubble.css';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const EditorBlock = styled(Responsive)`
/* 페이지 위 아래 여백 지정 */
padding-top: 5rem;
padding-bottom: 5rem;
`;
const TitleInput = styled.input`
font-size: 3rem;
outline: none;
padding-bottom: 0.5rem;
border: none;
border-bottom: 1px solid ${palette.gray[4]};
margin-bottom: 2rem;
width: 100%;
`;
const QuillWrapper = styled.div`
/* 최소 크기 지정 및 padding 제거 */
.ql-editor {
padding: 0;
min-height: 320px;
font-size: 1.125rem;
line-height: 1.5;
}
.ql-editor.ql-blank::before {
left: 0px;
}
`;
const Editor = ({ title, body, onChangeField }) => {
const quillElement = useRef(null); // Quill을 적용할 DivElement를 설정
const quillInstance = useRef(null); // Quill 인스턴스를 설정
useEffect(() => {
quillInstance.current = new Quill(quillElement.current, {
theme: 'bubble',
placeholder: '내용을 작성하세요...',
modules: {
// 더 많은 옵션
toolbar: [
[{ header: '1' }, { header: '2' }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['blockquote', 'code-block', 'link', 'image'],
],
},
});
// quill에 text-change 이벤트 핸들러 등록
// 참고: https://quilljs.com/docs/api/#events
const quill = quillInstance.current;
quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
onChangeField({ key: 'body', value: quill.root.innerHTML });
}
});
}, [onChangeField]);
const onChangeTitle = (e) => {
onChangeField({ key: 'title', value: e.target.value });
};
return (
<EditorBlock>
<TitleInput
placeholder="제목을 입력하세요"
onChange={onChangeTitle}
value={title}
/>
<QuillWrapper>
<div ref={quillElement} />
</QuillWrapper>
</EditorBlock>
);
};
export default Editor;
외부 라이브러리를 연동할 때는 이처럼 useRef와 useEffect를 적절하게 사용하면 됩니다
TagBox
src/components/write/TagBox.js
import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
const TagBoxBlock = styled.div`
width: 100%;
border-top: 1px solid ${palette.gray[2]};
padding-top: 2rem;
h4 {
color: ${palette.gray[8]};
margin-top: 0;
margin-bottom: 0.5rem;
}
`;
const TagForm = styled.form`
border-radius: 4px;
display: flex;
width: 256px;
border: 1.5px solid ${palette.gray[9]};
input,
button {
outline: none;
border: none;
font-size: 1rem;
}
input {
padding: 0.5rem;
flex: 1;
min-width: 0;
}
button {
cursor: pointer;
padding-right: 1rem;
padding-left: 1rem;
border: none;
background: ${palette.gray[8]};
color: white;
font-weight: bold;
&:hover {
background: ${palette.gray[6]};
}
}
`;
const Tag = styled.div`
margin-right: 0.5rem;
color: ${palette.gray[6]};
cursor: pointer;
&:hover {
opacity: 0.5;
}
`;
const TagListBlock = styled.div`
display: flex;
margin-top: 0.5rem;
`;
// React.memo를 사용하여 tag값이 바뀔 때만 리렌더링되도록 설정
const TagItem = React.memo(({ tag, onRemove }) => (
<Tag onClick={() => onRemove(tag)}>#{tag}</Tag>
));
// React.memo를 사용하여 tags값이 바뀔 때만 리렌더링되도록 처리
const TagList = React.memo(({ tags, onRemove }) => (
<TagListBlock>
{tags.map((tag) => (
<TagItem key={tag} tag={tag} onRemove={onRemove} />
))}
</TagListBlock>
));
const TagBox = () => {
const [input, setInput] = useState('');
const [localTags, setLocalTags] = useState([]);
const insertTag = useCallback(
(tag) => {
if (!tag) return; // 공백이라면 추가하지 않음
if (localTags.includes(tag)) return; // 이미 존재한다면 추가하지 않음
setLocalTags([...localTags, tag]);
},
[localTags],
);
const onRemove = useCallback(
(tag) => {
setLocalTags(localTags.filter((t) => t !== tag));
},
[localTags],
);
const onChange = useCallback((e) => {
setInput(e.target.value);
}, []);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
insertTag(input.trim());
setInput('');
},
[input, insertTag],
);
return (
<TagBoxBlock>
<h4>태그</h4>
<TagForm onSubmit={onSubmit}>
<input
placeholder="태그를 입력하세요"
value={input}
onChange={onChange}
/>
<button type="submit">추가</button>
</TagForm>
<TagList tags={localTags} onRemove={onRemove} />
</TagBoxBlock>
);
};
export default TagBox;
TagBox 컴포넌트에서 모든 작업을 하진 않습니다. 이 컴포넌트를 만들 때 TagItem, TagList라는 두 컴포넌트를 추가로 만들었는데, 이렇게 컴포넌트를 분리시킨 이유는 렌더링을 최적화하기 위해서입니다. 현재 TagBox컴포넌트는 두 가지 상황에서 렌더링을 합니다. 첫 번째는 input이 바뀔 때이고, 두 번째는 태그 목록이 바뀔 때입니다.
만약 컴포넌트를 분리하지 않고 한 컴포넌트에서 전부 직접 렌더링한다면, input값이 바뀔 때 태그 목록도 리렌더링될 것입니다. 태그 목록이 리렌더링되면 또 태그 하나하나가 모두 리렌더링됩니다.
하지만 위에 작성한 코드처럼 TagList와 TagItem컴포넌트를 분리시켜 주면 input값이 바뀌더라도 TagList컴포넌트가 리렌더링되지 않습니다. 그리고 태그 목록에 변화가 생겨도 이미 렌더링 중인 TagItem들은 리렌더링되지 않고, 실제로 추가되거나 삭제되는 태그에만 영향을 미치게 됩니다.
추가로 React.memo를 사용하여 컴포넌트를 사용하여 컴포넌트를 감싸 주면, 해당 컴포넌트가 받아오는 props가 실제로 바뀌었을 때만 리렌더링해 줍니다.
writeActionButton
src/components/write/WriteActionButton.js
import React from 'react';
import styled from 'styled-components';
import Button from '../common/Button';
const WriteActionButtonBlock = styled.div`
margin-top: 1rem;
margin-bottom: 3rem;
`;
const StyledButton = styled(Button)`
height: 2.125rem;
& + & {
margin-left: 0.5rem;
}
`;
const WriteActionButton = ({ onCancel, onPublish }) => {
return (
<WriteActionButtonBlock>
<StyledButton cyan onClick={onPublish}>
포스트 등록
</StyledButton>
<StyledButton onClick={onCancel}>취소</StyledButton>
</WriteActionButtonBlock>
);
};
export default WriteActionButton;
src/pages/WritePage.js
import React from 'react';
import Responsive from '../components/common/Responsive';
import Editor from '../components/write/Editor';
import TagBox from '../components/write/TagBox';
import WriteActionButton from '../components/write/WriteActionButton';
const Writepage = () => {
return (
<Responsive>
<Editor />
<TagBox />
<WriteActionButton />
</Responsive>
);
};
export default Writepage;
리덕스로 글쓰기 상태 관리하기
src/modules/write.js
import { createAction, handleActions } from 'redux-actions';
const INITIALIZE = 'write/INITIALIZE'; // 모든 내용 초기화
const CHANGE_FIELD = 'write/CHANGE_FIELD'; // 특정 key 값 바꾸기
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value,
}));
const initialState = {
title: '',
body: '',
tags: [],
};
const write = handleActions(
{
[INITIALIZE]: (state) => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value } }) => ({
...state,
[key]: value,
}),
},
initialState,
);
export default write;
src/modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import write from './write';
const rootReducer = combineReducers({
auth,
loading,
user,
write,
});
export function* rootSaga() {
yield all([authSaga(), userSaga()]);
}
export default rootReducer;
이제 Editor에서 changeField를 다루기 위해 EditorContainer를 만들어서 writePage에 렌더링 해 보겠습니다.
src/containers/write/EditorContainer.js
import React, { useEffect, useCallback } from 'react';
import Editor from '../../components/write/Editor';
import { useSelector, useDispatch } from 'react-redux';
import { changeField, initialize } from '../../modules/write';
const EditorContainer = () => {
const dispatch = useDispatch();
const { title, body } = useSelector(({ write: { title, body } }) => ({
title: title,
body: body,
}));
const onChangeField = useCallback(
(payload) => dispatch(changeField(payload)),
[dispatch],
);
// 페이지 언마운트 될 때 리덕스를 초기화 해줘야 한다.
useEffect(() => {
return () => {
dispatch(initialize());
};
}, [dispatch]);
return <Editor onChangeField={onChangeField} title={title} body={body} />;
};
export default EditorContainer;
src/components/write/Editor.js
import React, { useRef, useEffect } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.bubble.css';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const EditorBlock = styled(Responsive)`
/* 페이지 위 아래 여백 지정 */
padding-top: 5rem;
padding-bottom: 5rem;
`;
const TitleInput = styled.input`
font-size: 3rem;
outline: none;
padding-bottom: 0.5rem;
border: none;
border-bottom: 1px solid ${palette.gray[4]};
margin-bottom: 2rem;
width: 100%;
`;
const QuillWrapper = styled.div`
/* 최소 크기 지정 및 padding 제거 */
.ql-editor {
padding: 0;
min-height: 320px;
font-size: 1.125rem;
line-height: 1.5;
}
.ql-editor.ql-blank::before {
left: 0px;
}
`;
const Editor = ({ title, body, onChangeField }) => {
const quillElement = useRef(null); // Quill을 적용할 DivElement를 설정
const quillInstance = useRef(null); // Quill 인스턴스를 설정
useEffect(() => {
quillInstance.current = new Quill(quillElement.current, {
theme: 'bubble',
placeholder: '내용을 작성하세요...',
modules: {
// 더 많은 옵션
toolbar: [
[{ header: '1' }, { header: '2' }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['blockquote', 'code-block', 'link', 'image'],
],
},
});
const quill = quillInstance.current;
quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
onChangeField({ key: 'body', value: quill.root.innerHTML });
}
});
}, [onChangeField]);
const onChangeTitle = (e) => {
onChangeField({ key: 'title', value: e.target.value });
};
return (
<EditorBlock>
<TitleInput
placeholder="제목을 입력하세요"
onChange={onChangeTitle}
value={title}
/>
<QuillWrapper>
<div ref={quillElement} />
</QuillWrapper>
</EditorBlock>
);
};
export default Editor;
위 컨테이너 컴포넌트에서는 title과 body값을 리덕스 스토어에서 불러와 Editor컴포너트에 전달해 주었습니다. 참고로 Quill에디터는 일반 input이나 textarea가 아니기 때문에 onChange와 value값을 사용하여 상태를 관리할 수 없습니다. 따라서 지금은 에디터에서 값이 바뀔 때 리덕스 스토어에 값을 넣는 작업만 하고, 리덕스 스토어의 값이 바뀔 떄 에디터의 값이 바뀌게 하는 작업은 추후 포스트 수정기능을 구현할 떄 처리하겠습니다.
onChangeField 함수는 useCallback으로 감싸 주었는데, 이는 Editor컴포넌트에서 사용할 useEffect에서 onChangeField를 사용하기 때문입니다. onChangeField를 useCallback으로 감싸 주어야만 나중에 Editor에서 사용할 useEffect가 컴포넌트가 화면에 나타났을 떄 딱 한번만 실행되기 때문입니다.
또한 사용자가 WritePage에서 벗어날 때는 데이터를 초기화해야 합니다. 컴포넌트가 언마운트 될 때 useEffect로 INITIALIZE액션을 발생시켜서 리덕스의 write관련 상태를 초기화해 줍니다. 만약 초기화를 하지 않는다면, 포스트 작성 후, 다시 글쓰기 페이지에 들어왔을 떄 이전에 작성한 내용이 남아있게 됩니다.
리덕스 스토어가 정상 작동하는 것을 볼 수 있습니다.
TagBoxContainer
src/containers/write/TagBoxContainer.js
import Recat from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TagBox from '../../components/write/TagBox';
import { changeField } from '../../modules/write';
const TagBoxContainer = () => {
const dispatch = useDispatch();
const tags = useSelector(({ write: { tags } }) => tags);
const onChangeTags = (nextTags) => {
dispatch(
changeField({
key: 'tags',
value: nextTags,
}),
);
};
return <TagBox onChangeTags={onChangeTags} tags={tags} />;
};
export default TagBoxContainer;
그냥 TagBox props로 onChagneTags와 tags를 넘겨줍니다.
글쓰기 API연동하기
src/api/posts.js
import client from './client';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
src/modules/write.js
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/api/createRequestSaga';
import * as postAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const INITIALIZE = 'write/INITIALIZE'; // 모든 내용 초기화
const CHANGE_FIELD = 'write/CHANGE_FIELD'; // 특정 key 값 바꾸기
const [WRITE_POST, WRITE_POST_SUCCESS, WRITE_POST_FAILURE] =
createRequestActionTypes('write/WRITE_POST'); // 포스트 작성
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value,
}));
export const writePost = createAction(WRITE_POST, ({ title, body, tags }) => ({
title,
body,
tags,
}));
// 사가 생성
const writePostSaga = createRequestSaga(WRITE_POST, postAPI.writePost);
export function* writeSaga() {
yield takeLatest(WRITE_POST, writePostSaga);
}
const initialState = {
title: '',
body: '',
tags: [],
post: null,
postError: null,
};
const write = handleActions(
{
[INITIALIZE]: (state) => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value } }) => ({
...state,
[key]: value,
}),
[WRITE_POST]: (state) => ({
...state,
// post와 postErro를 초기화
post: null,
postError: null,
}),
// 포스트 작성 성공
[WRITE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
// 포스트 작성 실패
[WRITE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
},
initialState,
);
export default write;
src/modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import write, { writeSaga } from './write';
const rootReducer = combineReducers({
auth,
loading,
user,
write,
});
export function* rootSaga() {
yield all([authSaga(), userSaga(), writeSaga()]);
}
export default rootReducer;
src/containers/write/WriteActionButtonContainer.js
import React, { useEffect } from 'react';
import WriteActionButton from '../../components/write/WriteActionButton';
import { useSelector, useDispatch } from 'react-redux';
import { writePost } from '../../modules/write';
const WriteActionButtonContainer = ({ navigate }) => {
const dispatch = useDispatch();
const { title, body, tags, post, postError } = useSelector(
({ write: { title, body, tags, post, postError } }) => ({
title,
body,
tags,
post,
postError,
}),
);
// 포스트 등록
const onPublish = () => {
dispatch(
writePost({
title,
body,
tags,
}),
);
};
// 취소
const onCancel = () => {
navigate(-1);
};
// 성공 혹은 실패 시 할 작업
useEffect(() => {
if (post) {
const { _id, user } = post;
navigate(`/@${user.username}/${_id}`);
}
if (postError) {
console.log(postError);
}
}, [navigate, post, postError]);
return <WriteActionButton onPublish={onPublish} onCancel={onCancel} />;
};
export default WriteActionButtonContainer;
src/components/write/WriteActionButton.js
import React from 'react';
import styled from 'styled-components';
import Button from '../common/Button';
const WriteActionButtonBlock = styled.div`
margin-top: 1rem;
margin-bottom: 3rem;
`;
const StyledButton = styled(Button)`
height: 2.125rem;
& + & {
margin-left: 0.5rem;
}
`;
const WriteActionButton = ({ onCancel, onPublish }) => {
return (
<WriteActionButtonBlock>
<StyledButton cyan onClick={onPublish}>
포스트 등록
</StyledButton>
<StyledButton onClick={onCancel}>취소</StyledButton>
</WriteActionButtonBlock>
);
};
export default WriteActionButton;
src/pages/WritePage.js
import React from 'react';
import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';
import WriteActionButtonContainer from '../containers/write/WriteActionButtonContainer';
import { useNavigate } from 'react-router-dom';
const Writepage = () => {
return (
<Responsive>
<EditorContainer />
<TagBoxContainer />
<WriteActionButtonContainer navigate={useNavigate()} />
</Responsive>
);
};
export default Writepage;
이 컴포넌트에서는 포스트 등록 버튼을 누르면 현재 리덕스 스토어 안에 들어 있는 값을 사용하여 새 포스트를 작성합니다. 그리고 navigate객체를 사용하여, 취소 버튼을 누르면 브라우저에서 뒤로 가기를 하도록 하였습니다.
포스트 작성이 성공했을 때는 서버에서 응답한 포스트 정보의 _id와 username값을 참조하여 포스트를 읽을 수 있는 경로를 만든 뒤, navigate를 사용하여 해당 경로로 이동합니다.
'React > ReactJs' 카테고리의 다른 글
React - FrontEnd Project - 6 (0) | 2022.03.22 |
---|---|
React - FrontEnd Project - 5 (0) | 2022.03.20 |
React - React Router v6 (0) | 2022.03.18 |
React - FrontEnd Project - 3 (0) | 2022.03.17 |
React - FrontEnd project - 2 (0) | 2022.03.15 |