관리 메뉴

거니의 velog

(12) 프론트엔드 프로젝트 : 글쓰기 기능 구현하기 3 본문

React_프론트엔드 프로젝트

(12) 프론트엔드 프로젝트 : 글쓰기 기능 구현하기 3

Unlimited00 2024. 2. 23. 15:12

3. 리덕스로 글쓰기 상태 관리하기

* 글쓰기 관련 상태를 리덕스로 관리해 줄 차례이다. write 리덕스 모듈을 작성해 주자.

[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, // initialState를 넣으면 초기상태로 바뀜
    [CHANGE_FIELD]: (state, { payload: { key, value } }) => ({
      ...state,
      [key]: value, // 특정 key 값을 업데이트
    }),
  },
  initialState,
);

export default write;

* 리듀서를 다 만들었으면 루트 리듀서에 포함시키자.

[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, TagBox, WriteActionButtons 컴포넌트 각각에 대해 컨테이너 컴포넌트를 만들어 줄 것이다. 현재 상황을 보면, 구현해야 할 기능이 그렇게 많지 않고 로직도 간단하기 때문에 컨테이너 컴포넌트를 하나만 만들고 그 안에서 글 작성에 관련한 모든 컴포넌트의 상태 관리를 해 주어도 괜찮을 것 같다. 하지만 이러한 방식은 자칫하면 코드가 방대해져 나중에 유지 보수가 어려워질 수 있기 때문에 각 컴포넌트의 역할에 따라 컨테이너 컴포넌트를 따로 만드는 것을 권장한다.


(1) EditorContainer 만들기

[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: write.title,
    body: write.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;

* 이 컨테이너 컴포넌트에는 title 값과 body 값을 리덕스 스토어에서 불러와 Editor 컴포넌트에 전달해 주었다. 참고로 Quill 에디터는 일반 input이나 textarea가 아니기 때문에 onChange와 value 값을 사용하여 상태를 관리할 수 없다. 따라서 지금은 에디터에서 값이 바뀔 때 리덕스 스토어에 값을 넣는 작업만 하고, 리덕스 스토어의 값이 바뀔 때 에디터의 값이 바뀌게 하는 작업은 추후 포스트 수정 기능을 구현 시 처리할 것이다.

* onChangeField 함수는 useCallback 으로 감싸 주었는데, 이는 Editor 컴포넌트에서 사용할 useEffect 에서 onChangeField를 사용할 것이기 때문이다. onChangeField를 useCallback으로 감싸 주어야만 나중에 Editor에서 사용할 useEffect가 컴포넌트가 화면에 나타났을 때 딱 한 번만 실행되기 때문이다. 

* 또한, 사용자가 WritePage에서 벗어날 때는 데이터를 초기화해야 한다. 컴포넌트가 언마운트될 때 useEffect로 INITIALIZE 액션을 발생시켜서 리덕스의 write 관련 상태를 초기화해 준다. 만약 초기화를 하지 않는다면, 포스트 작성 후 다시 글쓰기 페이지에 들어왔을 때 이전에 작성한 내용이 남아 있게 된다.

* 컨테이너 컴포넌트를 다 만들었으면 WritePage에서 기존 Editor를 EditorContainer로 대체시키자.

[pages/WritePage.js]

import React from 'react';
import TagBox from '../components/write/TagBox';
import WriteActionButtons from '../components/write/WriteActionButtons';
import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';

const WritePage = () => {
  return (
    <Responsive>
      <EditorContainer />
      <TagBox />
      <WriteActionButtons />
    </Responsive>
  );
};

export default WritePage;

* 이어서 Editor 컴포넌트를 다음과 같이 수정하자.

[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 Editor = ({ title, onChangeField }) => {
  const quillElement = useRef(null); // Quill을 적용할 DivElement를 설정
  const quillInstance = useRef(null); // Quill 인스턴스를 설정

  useEffect(() => {
    quillInstance.current = new Quill(quillElement.current, {
      theme: 'bubble',
      placeholder: '내용을 작성하세요...',
      modules: {
        // 더 많은 옵션
        // https://quilljs.com/docs/modules/toolbar/ 참고
        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;

* 코드를 다 작성했는가? 브라우저에서 에디터 내용과 값을 입력해 보자. 그리고 리덕스 개발자 도구를 확인하여 에디터에 입력한 값이 스토어에도 그대로 반영되었는지 확인해 보자.

EditorContainer


(2) TagBoxContainer 만들기

* 이번에는 TagBox를 위한 컨테이너 컴포넌트인 TagBoxContainer를 구현해 보자.

[containers/write/TagBoxContainer.js]

import React 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((state) => state.write.tags);

  const onChangeTags = (nextTags) => {
    dispatch(
      changeField({
        key: 'tags',
        value: nextTags,
      }),
    );
  };

  return <TagBox onChangeTags={onChangeTags} tags={tags} />;
};

export default TagBoxContainer;

* 다음으로 WritePage 에서 TagBox 를 TagBoxContainer 로 대체하자.

[pages/WritePage.js]

import React from 'react';
import WriteActionButtons from '../components/write/WriteActionButtons';
import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';

const WritePage = () => {
  return (
    <Responsive>
      <EditorContainer />
      <TagBoxContainer />
      <WriteActionButtons />
    </Responsive>
  );
};

export default WritePage;

* 그리고 TagBox 컴포넌트에서 다음과 같이 props로 전달받은 onChangeTags와 tags를 사용하자.

[components/write/TagBox.js]

import React, { useState, useCallback, useEffect } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';

(...)

const TagBox = ({ tags, onChangeTags }) => {
  const [input, setInput] = useState('');
  const [localTags, setLocalTags] = useState([]);

  const insertTag = useCallback(
    (tag) => {
      if (!tag) return; // 공백이라면 추가하지 않음
      if (localTags.includes(tag)) return; // 이미 존재한다면 추가하지 않음
      const nextTags = [...localTags, tag];
      setLocalTags(nextTags);
      onChangeTags(nextTags);
    },
    [localTags, onChangeTags],
  );

  const onRemove = useCallback(
    (tag) => {
      const nextTags = localTags.filter((t) => t !== tag);
      setLocalTags(nextTags);
      onChangeTags(nextTags);
    },
    [localTags, onChangeTags],
  );

  const onChange = useCallback((e) => {
    setInput(e.target.value);
  }, []);

  const onSubmit = useCallback(
    (e) => {
      e.preventDefault();
      insertTag(input.trim()); // 앞뒤 공백 없앤 후 등록
      setInput(''); // input 초기화
    },
    [input, insertTag],
  );

  // tags 값이 바뀔 때
  useEffect(() => {
    setLocalTags(tags);
  }, [tags]);

  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;

* setLocalTags 를 호출해야 하는 상황에서 onChangeTags 도 함께 호출했다. 또한, props 로 받아 온 tag 가 바뀔 때 setLocalTags 를 호출해 주었다. 이로써 TagBox 컴포넌트 내부에서 상태가 바뀌면 리덕스 스토어에도 반영되고, 리덕스 스토어에 있는 값이 바뀌면 TagBox 컴포넌트 내부의 상태도 바뀌게 된다.

* 컴포넌트를 다 작성했으면 리덕스 개발자 도구를 열고 태그를 추가해 보자. 리덕스 스토어에 바뀐 내용이 잘 반영되는가?

TagBoxContainer 작동 확인