Projects

[Modu-house] React-redux CRUD

soobin Choi 2022. 12. 19. 01:10

댓글 업데이트

commentList.jsx

수정 전

  const handlePatch = () => {
    dispatch(patchComment({ comment, id: params.id }));
  };

수정 후

  const handlePatch = (comment) => {
    dispatch(
      patchComment({
        id: comment.id,
        detailId: comment.detailId,
        comment: content,
      })
    );
  };

content는 새롭게 수정한 comment 내용임.

객체 자체를 갈아끼우는 것이므로 그 형식에 맞게 id, detailId(게시글 id), comment 모두 적어야 함!

 

수정 전

            <Button
              color="#61bfad"
              value="완료"
              onClick={() => {
                handlePatch();
              }}
            >

수정 후

            {comments &&
              comments.map((item) => (

			(생략)

                        <Button
                          color="#61bfad"
                          value="완료"
                          onClick={() => {
                            handlePatch(item);
                          }}
                        >

map 으로 comments 배열의 요소 하나하나를 보기 때문에 handlePatch() 안에 parameter 인자로 item을 넣어야 함!

 

commentSlice.js - extraReducers

수정 전

    [patchComment.fulfilled]: (state, action) => {
      state.isLoading = false;

      state.comments = state.comments.map((item) => {
        if (item.id === action.payload.id) {
          return action.payload.comment;
        }
        return item;
      });

수정 후

    [patchComment.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.comments = state.comments.map((comment) => comment.id === action.payload.id ? action.payload : comment)
    },

comment의 id 가 action payload의 id와 일치하면 action의 payload를 뿌려주고 아니면 comment 를 그대로 돌려줘라.

즉 아이디가 같으면 수정된 객체 전체를 리턴하고 아니라면 그대로임

 

매니저님의 조언

📌 thunk 함수 쪽은 비동기적으로 처리되고 reducer 부분은 동기적으로 처리된다. json server에서 업데이트가 완료되었는데 extraReducer 부분에서 업데이트를 제대로 처리하지 않아서 생겼던 오류였음!!!

📌 폴더 구조 및 코드 작성 관련

- src 안에 페이지 이름으로 폴더를 만들고 그 안에 component와 module, style 파일을 다같이 넣어놓으면 페이지마다 수정하기 쉬움

- initialState는 그 데이터의 구조를 파악하기 쉽도록 보통 reducer 앞에 적어줌

- module js파일도 thunk 함수 부분과 reducer 부분을 나눠서 다른 파일에 적으면 나중에 프로젝트가 커졌을 때 관리하기가 쉬움.

- isLoading, error 부분은 return 문 안에 삼항연산자로 넣어서 주로 처리함

- 주석쓸 때 /**/ 이렇게 쓰면 어떤 부분인지 보여줌

📌 custom hook 은 크게 어려운 게 아니라 함수를 나눠서 빼놓은 것

+ cumstom hook 예시

useGetDiaryList.js

/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getDiary, deleteDiary } from "../../../redux/modules/diarySlice";

export default function useGetDiaryList() {
  const dispatch = useDispatch();
  const { isLoading, error, diary } = useSelector((state) => state.diary);

  const onDelete = (itemId) => {
    // console.log(itemId);
    dispatch(deleteDiary(itemId)).then(() => {
      window.location.replace("/");
    });
  };

  useEffect(() => {
    dispatch(getDiary());
  }, []);

  return {
    isLoading,
    error,
    diary,
    onDelete,
  };
}

이렇게 기존 작성한 함수를 가져오고 return문에서 isLoading, error, diary, onDelete 모두 리턴할 수 있음.

+ 댓글 삭제

  const deleteHandler = (id) => {
    dispatch(deleteComment(id));
    // console.log(id);
  };

댓글 삭제도 마찬가지로 댓글의 id값이 있어야 하므로 버튼의 onClick 함수 인자로 id를 넣어줘야 하고 reducer에도 id를 넣어줘야함

리액트 심화주차 전체 코드

*각 댓글 리스트마다 readonly 상태를 true/false로 주어야 하나씩 수정을 할 수 있는데 return 문 안에서 readonly 로 나눠버려서 하나를 선택하면 전체가 수정 활성화가 됐다. 이전에 todolist 를 만들 때 toggle로 isDone 상태를 true/false를 줬던 것처럼 지금도 데이터 하나하나마다 수정할 수 있는 상태값을 주었어야 했다! 아예 처음에 설계를 잘못 했고 컴포넌트를 쪼개지 않아서 더 헷갈렸던 것 같다.

 

commentList.jsx

import React, { useEffect, useState, memo, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, useParams } from "react-router-dom";
import styled from "styled-components";
import Button from "../../common/Button";
import Input from "../../common/Input";
import {
  getComment,
  postComment,
  deleteComment,
  patchComment,
} from "../../../redux/modules/commentSlice";
import { getDiaryId } from "../../../redux/modules/diarySlice";

function CommentList() {
  const params = useParams();
  
  const detail = useSelector((state) => state.diary.detail); // 일기 상세 아이디
  const textList = useSelector((state) => state.comments.comments); // diary, comments 모두 있음
  const { isLoading, error, comments } = useSelector((state) => state.comments);
  
  const [input, setInput] = useState("");

  const dispatch = useDispatch();

  /** 댓글 수정 관련 */
  const text = textList.find((ele) => ele.id === params.id);
  const beforeContent = useRef(text?.content);
  
  const [content, setContent] = useState(text?.content); // comment 내용

  const [readonly, setReadOnly] = useState(true); // true 일 때 읽기 상태 false 일 때 textarea

  // 수정 취소 버튼
  const handleEdit = () => {
    setContent(beforeContent.current);
    setReadOnly(true);
  };

 // 수정 완료 버튼
  const handlePatch = (comment) => {
    dispatch(
      patchComment({
        id: comment.id,
        detailId: comment.detailId,
        comment: content,
      })
    );
  };

  // 댓글 input
  const onChangeHandler = (event) => {
    event.preventDefault();
    setInput(event.target.value);
  };

  // 댓글 추가
  const addHandler = () => {
    if (input === "") {
      alert("댓글을 입력해주세요");
    } else {
      dispatch(postComment({ comment: input, detailId: params.id })); // key: value
      setInput(""); // 값입력된 후 초기화
    }
  };

  // 댓글 삭제
  const deleteHandler = (id) => {
    dispatch(deleteComment(id));
    // console.log(id);
  };

  // 처음 mount 될 때와 getComment() dispatch 가 실행될 때 렌더링됨
  // 댓글 가져오기
  useEffect(() => {
    dispatch(getComment({ detailId: params.id }));
  }, [dispatch, params]);

  // 일기 상세정보 가져오기
  useEffect(() => {
    dispatch(getDiaryId(params.id));
  }, [dispatch, params.id]);

  if (isLoading) {
    return <div>로딩 중....</div>;
  }

  if (error) {
    return <div>{error.message}</div>;
  }

  return (
    <DiaryDetail>
      <LinkDiv>
        <Link to="/">
          <Button color="#ff8b8b">돌아가기</Button>
        </Link>
        <Link to="/write">
          <Button color="#61bfad">다시 쓰기</Button>
        </Link>
      </LinkDiv>
      <DetailDiv>
        <Diary>
          <div className="title">{detail.title}</div>
          <div className="content">{detail.content}</div>
        </Diary>

        <InputBox>
          <Input
            type="text"
            name="comment"
            label="댓글을 작성해주세요"
            value={input}
            onChange={onChangeHandler}
          />
          <Button type="button" onClick={addHandler} color="#61bfad">
            댓글 쓰기
          </Button>
        </InputBox>
        <ContentBox>
          {readonly ? ( // {상태값? (ture) : (false)}
            <ContentBox>{!content ? text?.content : content}</ContentBox>
          ) : (
            // <ContentBox>읽기상태</ContentBox>
            <textarea
              className="textBox"
              rows="10"
              maxLength="200"
              value={!content ? text?.content : content}
              onChange={(e) => setContent(e.target.value)}
            />
          )}
          {}
        </ContentBox>
        <UpdateBox>
          <div>
            {comments &&
              comments.map((item) => (
                <div key={item.id}>
                  {/* <div style={{ borderBottom: '1px dotted #c4c4c4' }}> */}

                  <div>{item.comment}</div>

                  <div className="btnBox">
                    <Button
                      color="#ff8b8b"
                      type="button"
                      onClick={() => deleteHandler(item.id)}
                    >
                      삭제
                    </Button>
                    {readonly ? (
                      // 읽기 상태
                      <Button
                        color="#61bfad"
                        value="수정"
                        onClick={() => {
                          setReadOnly(!readonly);
                        }}
                      >
                        수정
                      </Button>
                    ) : (
                      // 수정 상태
                      <>
                        <Button
                          color="#61bfad"
                          value="완료"
                          onClick={() => {
                            setReadOnly(!readonly);
                            // beforeContent.current = content;
                            handlePatch(item);
                          }}
                        >
                          완료
                        </Button>
                        <Button
                          color="#ff8b8b"
                          value="취소"
                          onClick={handleEdit}
                        >
                          {/* <Button color="#ff8b8b" value="취소"> */}
                          취소
                        </Button>
                      </>
                    )}
                    {/* <Button color="#61bfad" value="수정" onClick={()}>
                      수정
                    </Button> */}
                  </div>
                </div>
              ))}
          </div>
        </UpdateBox>
      </DetailDiv>
    </DiaryDetail>
  );
}

export default memo(CommentList);
// 컴포넌트의 state가 변경된 경우 - 불필요한 리렌더링 방지

const DiaryDetail = styled.div`
  width: 100%;
  padding: 20px;
`;

const LinkDiv = styled.div`
  display: flex;
  justify-content: space-between;
`;

const DetailDiv = styled.div`
  max-width: 800px;
  min-width: 600px;
  margin: 30px auto;
  padding: 20px;
`;

const Diary = styled.div`
  margin-bottom: 20px;
  border-bottom: 2px solid #333;

  .title {
    font-size: 26px;
    margin: 10px 0 10px 0;
  }
  .content {
    font-size: 24px;
    margin-bottom: 20px;
  }
`;

const InputBox = styled.div`
  display: flex;
  justify-content: space-between;
  flex-direction: column;
  padding-top: 10px;
  padding-bottom: 20px;
  margin-bottom: 20px;
  border-bottom: 2px solid #333;
  input {
    height: 50px;
    text-indent: 10px;
    font-size: 18px;
    /* white-space: pre-line; */
  }
  button {
    width: 80px;
    height: auto;
    font-size: 14px;
    padding: 6px 6px;
    margin-top: 10px;
    margin-left: auto;
  }
`;

const UpdateBox = styled.div`
  .textArea li {
    list-style: none;
  }

  .btnBox {
    width: 100%;
    display: flex;
    justify-content: flex-end;
  }
  button {
    width: 80px;
    height: auto;
    font-size: 14px;
    padding: 6px 6px;
    margin-top: 10px;
    margin-right: 10px;
  }
`;

const ContentBox = styled.div`
  display: flex;
  -webkit-box-align: center;
  align-items: center;
  -webkit-box-pack: justify;
  justify-content: space-between;
  flex-direction: column;
  margin-top: 50px;
  /* min-height: 550px; */
  line-height: 1.5;
  font-size: 18px;
  .textBox {
    width: 100%;
    border: 1px solid rgb(238, 238, 238);
    padding: 12px;
    font-size: 14px;
    line-height: 1.5;
    font-size: 18px;
  }
`;

commentSlice.js

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

// thunk 함수
// 댓글 가져오기
export const getComment = createAsyncThunk('comments/getcomment', async (payload, thunkAPI) => {
  try {
    const response = await axios.get(`http://localhost:3001/comments?detailId=${payload.detailId}`); // data 상수는 get 요청을 하겠다! 라는 뜻 자체
    return thunkAPI.fulfillWithValue(response.data);
  } catch (error) {
    return thunkAPI.rejectWithValue(error);
  }
});
// ? 뒤에 붙는 게 querystring임
// https://www.google.com/search?q=검색어 이런 방식 get요청을 q 이렇게 하는 것
// http 통신방식 & axios 공부(비동기 통신 함수)

// 댓글 추가하기
export const postComment = createAsyncThunk('comments/postcomment', async (payload, thunkAPI) => {
  try {
    const response = await axios.post(`http://localhost:3001/comments`, payload); // url뒤에 querystring 으로도 보낼 수 있고 request 로도 보낼 수 있음
    return thunkAPI.fulfillWithValue(response.data);
  } catch (error) {
    return thunkAPI.rejectWithValue(error);
  }
});

// 댓글 삭제하기
export const deleteComment = createAsyncThunk('comments/deletecomment', async (id, thunkAPI) => {
  try {
    await axios.delete(`http://localhost:3001/comments/${id}`);
    return id;
  } catch (error) {
    return thunkAPI.rejectWithValue(error);
  }
});

// 댓글 수정하기
export const patchComment = createAsyncThunk(
  'comment/patchcomment',

  async (payload, thunkAPI) => {
    try {
      console.log('payload', payload)
      // // payload를 데이터를 넣어줄때까지 실행하지 하지않겠다. //비동기
      const data = await axios.put(`http://localhost:3001/comments/${payload.id}`, payload);
      console.log('DB.JSON', data);

      return thunkAPI.fulfillWithValue(data.data);
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  },
);

// 초기 상태 값
const initialState = {
  comments: [],
  isLoading: false,
  error: null,
};

// createSlice API
export const commentSlice = createSlice({
  name: 'comments', // 저장소의 이름
  initialState,
  reducers: {},
  extraReducers: {
    // 댓글 가져오기
    [getComment.pending]: (state) => {
      state.isLoading = true; // 네트워크 요청이 시작되면 로딩상태를 true로 변경합니다.
    },
    [getComment.fulfilled]: (state, action) => {
      state.isLoading = false; // 네트워크 요청이 끝났으니, false로 변경합니다.
      state.comments = action.payload; // Store에 있는 comments에 서버에서 가져온 comments를 넣습니다.
    },
    [getComment.rejected]: (state, action) => {
      state.isLoading = false; // 에러가 발생했지만, 네트워크 요청이 끝났으니, false로 변경합니다.
      state.error = action.payload; // catch 된 error 객체를 state.error에 넣습니다.
    },
    // 댓글 추가하기
    [postComment.pending]: (state) => {
      state.isLoading = true;
    },
    [postComment.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.comments.push(action.payload);
    },
    [postComment.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.payload;
    },
    // 댓글 삭제하기
    [deleteComment.pending]: (state) => {
      state.isLoading = true;
    },
    [deleteComment.fulfilled]: (state, action) => {
      state.isLoading = false;

      state.comments = state.comments.filter((item) => item.id !== action.payload);
    },
    [deleteComment.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.payload;
    },
    // 댓글 수정하기
    [patchComment.pending]: (state) => {
      state.isLoading = true;
    },
    [patchComment.fulfilled]: (state, action) => {
      state.isLoading = false;
      state.comments = state.comments.map((comment) => comment.id === action.payload.id ? action.payload : comment)
    },
    [patchComment.rejected]: (state, action) => {
      state.isLoading = false;
      state.error = action.payload;
    },
  },
});

// Action Creator 는 컴포넌트에서 사용하기 위해 export
// export const {} = commentSlice.actions;
// reducer 는 configStore 에 등록하기 위해 export
export default commentSlice.reducer;

댓글 가져올 때 response를 어떻게 써야할 지 몰라서 댓글이 여러 게시글에 동시에 달렸었다. 이렇게 써줘야 한다고 함

// 댓글 가져오기
export const getComment = createAsyncThunk('comments/getcomment', async (payload, thunkAPI) => {
  try {
    const response = await axios.get(`http://localhost:3001/comments?detailId=${payload.detailId}`); // data 상수는 get 요청을 하겠다! 라는 뜻 자체
    return thunkAPI.fulfillWithValue(response.data);
  } catch (error) {
    return thunkAPI.rejectWithValue(error);
  }
});

? 뒤에 붙는 게 querystring임
https://www.google.com/search?q=검색어 이런 방식 get요청을 q 이렇게 하는 것

-> json server 만의 규칙임!!!

(json server에서는 비밀번호가 맞는지 확인할 때 get으로 확인할 수 밖에 없기 때문에 이렇게 쓸 수 밖에 없는 것)

 

🌟 추가 공부

http 통신방식 & axios 공부(비동기 통신 함수)

useCallback() 써서 최적화하는 코드