[React] 5. Blog v1

문정준's avatar
Aug 22, 2025
[React] 5. Blog v1

1. 파일 만들기

notion image
 

필요한 라이브러리

npm install

  • axios
  • react-redux
  • jwt-decode
  • bootstrap
  • react-bootstrap
  • react-bootstrap-icons
  • styled-components
  • react-router-dom
 

2. 페이지 작성

  • 통신 전에는 더미 데이터를 이용한 화면 구성만 작성

JoinForm

import axios from "axios"; import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useNavigate } from "react-router-dom"; const JoinForm = (props) => { const navigate = useNavigate(); const [user, setUser] = useState({}); function changeValue(e) {} async function submitJoin(e) { e.preventDefault(); // 새로고침 막기 (action 발동 막기) } return ( <Form> <Form.Group> <Form.Label>Username</Form.Label> <Form.Control type="text" placeholder="Enter username" name="username" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Password</Form.Label> <Form.Control type="password" placeholder="Enter password" name="password" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Email</Form.Label> <Form.Control type="email" placeholder="Enter email" name="email" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={submitJoin}> 회원가입 </Button> </Form> ); }; export default JoinForm;

LoginForm

import axios from "axios"; import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; const LoginForm = (props) => { const navigate = useNavigate(); const [user, setUser] = useState({}); async function submitLogin(e) { e.preventDefault(); } const changeValue = (e) => {}; return ( <Form> <Form.Group> <Form.Label>Username</Form.Label> <Form.Control type="text" placeholder="Enter username" name="username" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Password</Form.Label> <Form.Control type="password" placeholder="Enter password" name="password" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={submitLogin}> 로그인 </Button> </Form> ); }; export default LoginForm;

Home

import React, { useState } from "react"; import { Form, FormControl, Pagination } from "react-bootstrap"; import BoardItem from "../../components/BoardItem"; const Home = () => { const [page, setPage] = useState(0); const [keyword, setKeyword] = useState(""); const [model, setModel] = useState({}); async function apiHome() {} function changeValue(e) {} return ( <div> <Form className="d-flex mb-4" onSubmit={""}> <FormControl type="search" placeholder="Search" className="me-2" aria-label="Search" value={keyword} onChange={changeValue} /> </Form> <BoardItem key={1} id={1} title={"제목1"} page={0} /> <br /> <div className="d-flex justify-content-center"> <Pagination> <Pagination.Item onClick={""} disabled={false}> Prev </Pagination.Item> <Pagination.Item onClick={""} disabled={false}> Next </Pagination.Item> </Pagination> </div> </div> ); }; export default Home;

Detail

import React, { useState } from "react"; import { Button } from "react-bootstrap"; import { Link, useNavigate } from "react-router-dom"; const Detail = (props) => { const { id } = 1; const navigate = useNavigate(); const [board, setBoard] = useState({}); async function fetchDetail(boardId) {} async function fetchDelete(boardId) {} return ( <div> <Link to={`/update-form/${board.id}`} className="btn btn-warning"> 수정 </Link> <Button className="btn btn-danger" onClick={() => fetchDelete(board.id)}> 삭제 </Button> <br /> <br /> <h1>제목1</h1> <hr /> <div>내용1</div> </div> ); }; export default Detail;

SaveForm

import axios from "axios"; import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; const SaveForm = (props) => { const navigate = useNavigate(); const [board, setBoard] = useState({}); async function submitPost(e) { e.preventDefault(); } function changeValue(e) {} return ( <div> <h1>글쓰기</h1> <hr /> <Form> <Form.Group> <Form.Label>Title</Form.Label> <Form.Control type="text" placeholder="Enter title" name="title" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Content</Form.Label> <Form.Control as="textarea" row={5} name="content" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={submitPost}> 글등록 </Button> </Form> </div> ); }; export default SaveForm;

UpdateForm

import axios from "axios"; import React, { useEffect, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; const UpdateForm = (props) => { const { id } = 1; const navigate = useNavigate(); const [board, setBoard] = useState({}); async function updateSubmit(e) { e.preventDefault(); } const changeValue = (e) => {}; async function fetchUserInfo() {} return ( <div> <h1>글수정</h1> <hr /> <Form> <Form.Group> <Form.Label>Title</Form.Label> <Form.Control value={"제목1"} type="text" placeholder="Enter title" name="title" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Content</Form.Label> <Form.Control as="textarea" row={5} value={"내용1"} name="content" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={updateSubmit}> 글수정 </Button> </Form> </div> ); }; export default UpdateForm;
 

3. 컴포넌트 작성

BoardItem

import React from "react"; import { Card } from "react-bootstrap"; import { Link } from "react-router-dom"; const BoardItem = (props) => { return ( <Card className="mb-2"> <Card.Body> <Card.Title>제목1</Card.Title> <Link to={"/board/1"} variant="primary" className="btn btn-primary"> 상세보기 </Link> </Card.Body> </Card> ); }; export default BoardItem;

Header

import React from "react"; import { Nav, Navbar } from "react-bootstrap"; import { Link } from "react-router-dom"; function Header(props) { return ( <div> <Navbar bg="dark" expand="lg" variant="dark"> <Link to="/" className="navbar-brand"> 블로그홈 </Link> <Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Collapse id="basic-navbar-nav"> <Nav className="mr-auto"> <Link to="/save-form" className="nav-link"> 글쓰기 </Link> <Link className="nav-link" onClick={""}> 로그아웃 </Link> <Link to="/login-form" className="nav-link"> 로그인 </Link> <Link to="/join-form" className="nav-link"> 회원가입 </Link> </Nav> </Navbar.Collapse> </Navbar> <br /> </div> ); } export default Header;
 

4. 파일 수정

index.js

  • App을 BrowserRouter로 감쌈 : 주소로 이동 가능
import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { BrowserRouter } from "react-router-dom"; import "bootstrap/dist/css/bootstrap.min.css"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( <BrowserRouter> <App /> </BrowserRouter> );
 

초기 화면

notion image
notion image
 
notion image
notion image
 
 
notion image
notion image
 
 

5. 로그인/로그아웃

JoinForm

  • changeValue, submitJoin 작성
    • changeValue : 값이 바뀔 때 user 객체 내부 멤버 값을 update
    • submitJoin : 기존 action 발동을 막고, axios를 통한 통신 진행
      • body를 JS Object에서 JSON으로 자동 변환
      • 에러가 나면 터짐 : try-catch로 잡아줘야 함
      • 가입 완료 시 login-form으로 이동
import axios from "axios"; import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useNavigate } from "react-router-dom"; const JoinForm = (props) => { const navigate = useNavigate(); const [user, setUser] = useState({ username: "", password: "", email: "", }); function changeValue(e) { setUser({ ...user, [e.target.name]: e.target.value, }); } async function submitJoin(e) { e.preventDefault(); // 새로고침 막기 (action 발동 막기) try { await axios({ method: "POST", url: "http://localhost:8080/join", data: user, // axios는 JS Object를 전달하면 JSON으로 변환해서 전달 headers: { "Content-Type": "application/json", }, }); navigate("/login-form"); } catch (error) { // console.log(error); alert(error.response.data.msg); } } // console.log(user); return ( <Form> <Form.Group> <Form.Label>Username</Form.Label> <Form.Control type="text" placeholder="Enter username" name="username" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Password</Form.Label> <Form.Control type="password" placeholder="Enter password" name="password" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Email</Form.Label> <Form.Control type="email" placeholder="Enter email" name="email" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={submitJoin}> 회원가입 </Button> </Form> ); }; export default JoinForm;
  • username 중복 시 400 에러 발생
notion image
notion image

LoginForm

  • useDispatch : 전역 상태 변경 → re-render
  • changeValue는 똑같이 작성
  • submitLogin
    • 응답으로 넘어오는 jwt를 local storage에 저장 : 브라우저를 꺼도 사라지지 않음 (만료 전까지)
      • 자동 로그인 사용 가능
    • dispatch로 상태 변경 → reducer 호출
import axios from "axios"; import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { login } from "../../store"; const LoginForm = (props) => { const navigate = useNavigate(); const dispatch = useDispatch(); // reducer 호출 const [user, setUser] = useState({ username: "", password: "", }); async function submitLogin(e) { e.preventDefault(); try { let response = await axios({ method: "POST", url: "http://localhost:8080/login", data: user, // axios는 JS Object를 전달하면 JSON으로 변환해서 전달 headers: { "Content-Type": "application/json", }, }); let jwt = response.headers.authorization; localStorage.setItem("jwt", jwt); dispatch(login(jwt)); navigate("/"); } catch (error) { // console.log(error); alert(error.response.data.msg); } } const changeValue = (e) => { // 유효성 검사 setUser({ ...user, [e.target.name]: e.target.value, }); }; return ( <Form> <Form.Group> <Form.Label>Username</Form.Label> <Form.Control type="text" placeholder="Enter username" name="username" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Password</Form.Label> <Form.Control type="password" placeholder="Enter password" name="password" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={submitLogin}> 로그인 </Button> </Form> ); }; export default LoginForm;
  • 브라우저의 저장소
    • Cookies : 데이터 및 인증 정보 등을 저장할 때 사용, 브라우저 종료 후에도 만료일에 따라 데이터 유지 가능
    • Local Storage : 데이터를 영구적으로 저장 가능 (자동 로그인에 사용 가능)
    • Session Storage : Local Storage와 유사하나 브라우저를 끄면 삭제
    • IndexedDB : 대용량의 데이터를 저장할 수 있는 NoSQL 데이터베이스
notion image
notion image

Header

  • useDispatch : 전역 상태 관리
  • isLogin : 로그인 여부 → useSelector를 이용해 state의 isLogin을 주시
  • 로그아웃 시
    • 로컬 스토리지의 jwt 제거
    • dispatch(logout()) : 로그아웃 시 상태 변경
  • navbar에서 isLogin 값에 따라 태그 표시 : 삼항연산자 사용
import React from "react"; import { Nav, Navbar } from "react-bootstrap"; import { useDispatch, useSelector } from "react-redux"; import { Link } from "react-router-dom"; import { logout } from "../store"; function Header(props) { const dispatch = useDispatch(); const isLogin = useSelector((state) => state.isLogin); async function logoutEvent() { await localStorage.removeItem("jwt"); dispatch(logout()); } return ( <div> <Navbar bg="dark" expand="lg" variant="dark"> <Link to="/" className="navbar-brand"> 블로그홈 </Link> <Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Collapse id="basic-navbar-nav"> <Nav className="mr-auto"> {isLogin ? ( <> <Link to="/save-form" className="nav-link"> 글쓰기 </Link> <Link className="nav-link" onClick={logoutEvent}> 로그아웃 </Link> </> ) : ( <> <Link to="/login-form" className="nav-link"> 로그인 </Link> <Link to="/join-form" className="nav-link"> 회원가입 </Link> </> )} </Nav> </Navbar.Collapse> </Navbar> <br /> </div> ); } export default Header;

index.js

  • reducer 사용 : 전역 상태 관리를 위해 필요
  • Provider : 전역 상태를 담을 창고 설정 = gvm → store
import { configureStore } from "@reduxjs/toolkit"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { BrowserRouter } from "react-router-dom"; import "bootstrap/dist/css/bootstrap.min.css"; import { Provider } from "react-redux"; import reducer from "./store"; const root = ReactDOM.createRoot(document.getElementById("root")); const store = configureStore({ reducer: reducer, }); root.render( <Provider store={store}> <BrowserRouter> <App /> </BrowserRouter> </Provider> );
 

store.js

  • 전역 상태를 관리하는 JS (riverpod의 GVM과 역할이 비슷함)
  • initialState : 초기 상태 → 로그인 안됨, 토큰 없음
  • reducer 설정
    • 로그인 및 로그아웃 시 상태 변경
    • action = reducer를 호출하는 함수
    • 로그인 시 : isLogin = true, jwt = action.jwt
    • 로그아웃 시 : isLogin = false, jwt = null (빈칸)
  • action 설정
    • login, logout
// 전역 상태 관리 (user) // 1. 초기 상태 const initialState = { isLogin: false, jwt: "", }; // 2. reducer const reducer = (state = initialState, action) => { switch (action.type) { case "LOGIN": return { isLogin: true, jwt: action.jwt, }; case "LOGOUT": return { isLogin: false, jwt: "", }; default: return state; } }; // 3. action export function login(jwt) { return { type: "LOGIN", jwt: jwt, }; } export function logout() { return { type: "LOGOUT", }; } export default reducer;
 
 

변경된 화면

  • 비 로그인 시 : 로그인, 회원가입 메뉴 표시
  • 로그인 시 : 글쓰기, 로그아웃 메뉴 표시
notion image
notion image
 
  • 회원가입 시 username이 중복 시 alert 발생
notion image
 

6. 페이징 & 검색

Home

  • model 정의 : DTO에 들어오는 데이터에 맞추어서 바인딩
  • useEffect : state가 변경되면 처리할 함수 정의
  • prev, next : 페이지 갱신
    • isFirst가 true이면 prev를 비활성화
    • isLast가 true이면 next를 비활성화
  • changeValue : 검색 창에 키워드 입력 시 키워드 변경
    • 키워드가 변경되지 않으면 글이 입력되지 않음 → DOM이 변경을 감지할 수 없음
  • 상태가 변경된 model에서 boards만 골라 출력 : model.boards.map()
import React, { useEffect, useState } from "react"; import { Form, FormControl, Pagination } from "react-bootstrap"; import BoardItem from "../../components/BoardItem"; import axios from "axios"; const Home = () => { const [page, setPage] = useState(0); const [keyword, setKeyword] = useState(""); const [model, setModel] = useState({ totalPage: undefined, number: 0, isFirst: true, isLast: false, boards: [], }); useEffect(() => { apiHome(); }, [page, keyword]); function prev() { setPage(page - 1); } function next() { setPage(page + 1); } async function apiHome() { let response = await axios({ method: "get", url: `http://localhost:8080?page=${page}&keyword=${keyword}`, }); console.log(response.data.body); setModel(response.data.body); } function changeValue(e) { setKeyword(e.target.value); } return ( <div> <Form className="d-flex mb-4" onSubmit={""}> <FormControl type="search" placeholder="Search" className="me-2" aria-label="Search" value={keyword} onChange={changeValue} /> </Form> {model.boards.map((board) => ( <BoardItem key={board.id} id={board.id} title={board.title} page={0} /> ))} <br /> <div className="d-flex justify-content-center"> <Pagination> <Pagination.Item onClick={prev} disabled={model.isFirst}> Prev </Pagination.Item> <Pagination.Item onClick={next} disabled={model.isLast}> Next </Pagination.Item> </Pagination> </div> </div> ); }; export default Home;

BoardItem

  • board의 카드 내용 변경
    • id, title을 받아서 바인딩
    • 링크는 백틱(``)을 이용하여 키워드 바인딩 → 주소는 문자열이기 때문
import React from "react"; import { Card } from "react-bootstrap"; import { Link } from "react-router-dom"; const BoardItem = (props) => { const { id, title } = props; return ( <Card className="mb-2"> <Card.Body> <Card.Title>{title}</Card.Title> <Link to={`/board/${id}`} variant="primary" className="btn btn-primary"> 상세보기 </Link> </Card.Body> </Card> ); }; export default BoardItem;
 

6-1. 검색 디바운싱 적용

💡

Debouncing ( 디바운싱 )

  • 일정 시간 동안 연속해서 발생한 이벤트 중 맨 처음 / 마지막 이벤트만 실행
  • 아이디 중복체크, 유효성 검사 등에 사용 가능
💡

Throttling ( 스로틀링 )

  • 일정 시간마다 이벤트를 발생
  • 디바운싱과 같이 요청을 제한하지만 동작 방법이 다름
    • 디바운싱 : 특정 시간 내의 eventloop에서 맨 처음 / 마지막 요청만 수행
    • 스로트링 : 이벤트 발생 시 일정 시간 내에 동일 이벤트 발생을 disable

Home

  • 키가 입력될 때마다 useEffect 발동 : 너무 잦은 호출
    • 이를 최적화하기 위한 디바운싱 (Debouncing) 적용
  • rawKeyword : 검색창에 키워드를 입력할 때 이를 확인하기 위한 키워드 상태
  • keyword : 실제 호출에 사용할 키워드
  • useRef : 상태를 변경하지만 추가 행동을 하지 않음 → 값이 변경되어도 렌더링 X
    • useRef 내부의 ref.current에 해당 상태 값 저장
    • 값을 바꿔도 렌더링 X, 리렌더링이 되어도 값 그대로 유지
  • _.debounce : 외부 라이브러리 lodash에서 지원하는 디바운스 함수
    • 호출할 함수, 딜레이 설정
  • useEffect를 2번 사용 : 취소 시, 키워드 변경 시
    • 취소 시 타이머 적용 취소 → 키 연속 입력 시 타이머 갱신 용
    • 키워드가 정상적으로 변경 시 새 호출
      • cancelToken : axios 내부에서 사용할 수 있는 요청 취소 토큰 (≠ JWT)
        • 이전 검색 요청을 버리는 용도
import React, { useEffect, useRef, useState } from "react"; import { Form, FormControl, Pagination } from "react-bootstrap"; import BoardItem from "../../components/BoardItem"; import axios from "axios"; import _ from "lodash"; const Home = () => { const [page, setPage] = useState(0); // 1) 입력 즉시 DOM 반영용 const [rawKeyword, setRawKeyword] = useState(""); // 2) 요청 트리거용(디바운스 후 반영) const [keyword, setKeyword] = useState(""); const [model, setModel] = useState({ totalPage: undefined, number: 0, isFirst: true, isLast: false, boards: [], }); // --- 디바운스된 setter: 최초 1회 생성, 언마운트 시 cancel --- const debouncedSetKeyword = useRef( _.debounce((value) => { setKeyword(value); // 디바운스 후에만 서버요청 트리거 상태 변경 setPage(0); // 새 키워드면 페이지를 0으로 리셋 (옵션) }, 600) // 3초가 너무 길다면 600~800ms 권장 ); useEffect(() => { return () => { // 컴포넌트 언마운트 시 디바운스 타이머 취소 debouncedSetKeyword.current.cancel(); }; }, []); // --- 서버 호출: page, keyword(디바운스된 값) 변경 시에만 --- useEffect(() => { const source = axios.CancelToken.source(); (async () => { try { const res = await axios.get("http://localhost:8080", { params: { page, keyword }, cancelToken: source.token, }); setModel(res.data.body); } catch (e) { if (!axios.isCancel(e)) { console.error(e); } } })(); // 의존성 변경/언마운트 시 이전 요청 취소 return () => source.cancel("request aborted due to new search"); }, [page, keyword]); function prev() { setPage((p) => p - 1); } function next() { setPage((p) => p + 1); } // --- 입력은 즉시 rawKeyword에 반영, 서버요청은 디바운스 --- function changeValue(e) { const value = e.target.value; setRawKeyword(value); // 즉시 DOM 갱신 debouncedSetKeyword.current(value); // 요청은 지연 } return ( <div> <Form className="d-flex mb-4" onSubmit={(e) => e.preventDefault()}> <FormControl type="search" placeholder="Search" className="me-2" aria-label="Search" value={rawKeyword} // ✅ 입력은 즉시 보이도록 rawKeyword 바인딩 onChange={changeValue} /> </Form> {model.boards.map((board) => ( <BoardItem key={board.id} id={board.id} title={board.title} page={0} /> ))} <br /> <div className="d-flex justify-content-center"> <Pagination> <Pagination.Item onClick={prev} disabled={model.isFirst}> Prev </Pagination.Item> <Pagination.Item onClick={next} disabled={model.isLast}> Next </Pagination.Item> </Pagination> </div> </div> ); }; export default Home;
  • useEffect로만 구현도 가능 = setTimeout, clearTimeout으로 구현 가능
  • lodash를 useEffect만 사용해서 디바운싱 구현은 어려움
    • lodash의 debounce는 내부 함수에 사용할 value를 저장하고 일정 시간 후 내부 함수 호출
    • 리렌더링 시 lodash가 새로 호출되어 버려짐 : 이전 요청의 함수들은 버려짐
    • 검색을 하게 되면 useEffect로 인해 화면이 리렌더링 → debounce 함수 복제 → 이전 명령 삭제로 인한 검색 불가
    • lodash를 쓸 때에는 리렌더링이 되어도 해당 함수의 상태를 유지할 수 있도록 useMemo 또는 useRef를 사용해야 함
import React, { useEffect, useRef, useState } from "react"; import { Form, FormControl, Pagination } from "react-bootstrap"; import BoardItem from "../../components/BoardItem"; import axios from "axios"; import _ from "lodash"; const Home = () => { const [page, setPage] = useState(0); // 1) 입력 즉시 DOM 반영용 const [rawKeyword, setRawKeyword] = useState(""); // 2) 요청 트리거용(디바운스 후 반영) const [keyword, setKeyword] = useState(""); const [model, setModel] = useState({ totalPage: undefined, number: 0, isFirst: true, isLast: false, boards: [], }); // 🔹 rawKeyword가 바뀔 때마다 600ms 뒤에 keyword 반영 (디바운스) useEffect(() => { const t = setTimeout(() => { setKeyword(rawKeyword); }, 600); return () => clearTimeout(t); // 입력이 다시 들어오면 이전 타이머 취소 }, [rawKeyword]); useEffect(() => { apiHome(); }, [page, keyword]); async function apiHome() { let response = await axios({ method: "get", url: `http://localhost:8080?page=${page}&keyword=${keyword}`, }); let responseBody = response.data; setModel(responseBody.body); } function prev() { setPage((p) => p - 1); } function next() { setPage((p) => p + 1); } // --- 입력은 즉시 rawKeyword에 반영, 서버요청은 디바운스 --- function changeValue(e) { const value = e.target.value; setRawKeyword(value); // 즉시 DOM 갱신 } return ( <div> <Form className="d-flex mb-4" onSubmit={(e) => e.preventDefault()}> <FormControl type="search" placeholder="Search" className="me-2" aria-label="Search" value={rawKeyword} // ✅ 입력은 즉시 보이도록 rawKeyword 바인딩 onChange={changeValue} /> </Form> {model.boards.map((board) => ( <BoardItem key={board.id} id={board.id} title={board.title} page={0} /> ))} <br /> <div className="d-flex justify-content-center"> <Pagination> <Pagination.Item onClick={prev} disabled={model.isFirst}> Prev </Pagination.Item> <Pagination.Item onClick={next} disabled={model.isLast}> Next </Pagination.Item> </Pagination> </div> </div> ); }; export default Home;

변경된 화면

  • 페이징 정상 적용
notion image
notion image
  • 키워드를 변경했을 때 바로 적용되지 않고 일정 시간 후 검색
notion image
notion image
 

7. 글 상세보기, 작성, 수정, 삭제, 댓글 작성, 댓글 삭제

Detail

  • 글의 정보를 받아오기 위한 useState → board 객체 저장
    • 댓글을 별도로 표시하기 위한 reply 객체도 생성
  • 댓글 작성 : changeValue 함수를 통해 댓글 작성
    • submitReply 이벤트로 댓글 작성 통신 후 상태 갱신
  • 댓글 삭제 : notifyDeleteReply 함수를 통해 삭제된 댓글 추적
    • filter를 통해 삭제 버튼이 눌린 댓글의 id를 제외한 리스트만 출력
import axios from "axios"; import React, { useEffect, useState } from "react"; import { Button, Card, Form } from "react-bootstrap"; import { useSelector } from "react-redux"; import { Link, useNavigate, useParams } from "react-router-dom"; import ReplyItem from "../../components/ReplyItem"; const Detail = (props) => { const { id } = useParams(); const jwt = useSelector((state) => state.jwt); const navigate = useNavigate(); const [board, setBoard] = useState({ id: undefined, title: "", content: "", userId: undefined, username: "", owner: false, replies: [], }); const [reply, setReply] = useState({ comment: "", boardId: id, }); const changeValue = (e) => { setReply({ ...reply, comment: e.target.value }); }; async function submitReply(e) { e.preventDefault(); let response = await axios({ method: "POST", url: `http://localhost:8080/api/replies`, data: reply, headers: { Authorization: jwt, }, }); let responseBody = response.data.body; // replyId, comment, boardId, userId // id, comment, username, userId, owner console.log("머지", responseBody); board.replies = [responseBody, ...board.replies]; setBoard({ ...board }); } function notifyDeleteReply(replyId) { let newReplies = board.replies.filter((reply) => reply.id !== replyId); board.replies = newReplies; setBoard({ ...board }); } useEffect(() => { fetchDetail(id); }, []); async function fetchDetail(boardId) { let response = await axios({ method: "GET", url: `http://localhost:8080/api/boards/${boardId}`, headers: { Authorization: jwt, }, }); let responseBody = response.data; setBoard(responseBody.body); } async function fetchDelete(boardId) { await axios({ method: "DELETE", url: `http://localhost:8080/api/boards/${boardId}`, headers: { Authorization: jwt, }, }); navigate("/"); } console.log(board); // update-form 갈때 상세보기의 상태 Board를 가져가는 것 연습해보기 return ( <div> {board.owner ? ( <> <Link to={`/update-form/${board.id}`} className="btn btn-warning"> 수정 </Link> <Button className="btn btn-danger" onClick={() => fetchDelete(board.id)} > 삭제 </Button> </> ) : ( <></> )} <br /> <br /> <h1>{board.title}</h1> <hr /> <div>{board.content}</div> <br /> <br /> <hr /> {/* 댓글 입력 폼 */} <Card className="mb-4 shadow-sm border-0"> <Card.Body> <Form onSubmit={""}> <Form.Group className="mb-3"> <Form.Control as="textarea" rows={3} placeholder="댓글을 입력하세요..." value={reply.comment} onChange={changeValue} /> </Form.Group> <div className="d-grid gap-2 d-md-flex justify-content-md-end"> <Button variant="primary" type="submit" onClick={submitReply}> 댓글 작성 </Button> </div> </Form> </Card.Body> </Card> {/* 댓글 목록 */} <div className="comment-list"> {board.replies.map((reply) => ( <ReplyItem reply={reply} notifyDeleteReply={notifyDeleteReply} /> ))} </div> </div> ); }; export default Detail;

SaveForm

  • changeValue 함수를 통해 제목, 내용 작성
    • submitPost 함수를 통해 글 작성 통신 이후 홈 화면으로 이동
import axios from "axios"; import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; const SaveForm = (props) => { const navigate = useNavigate(); const jwt = useSelector((state) => state.jwt); const [board, setBoard] = useState({ title: "", content: "", }); async function submitPost(e) { e.preventDefault(); await axios({ method: "POST", url: "http://localhost:8080/api/boards", data: board, headers: { Authorization: jwt, }, }); navigate("/"); } function changeValue(e) { setBoard({ ...board, [e.target.name]: e.target.value }); } return ( <div> <h1>글쓰기</h1> <hr /> <Form> <Form.Group> <Form.Label>Title</Form.Label> <Form.Control type="text" placeholder="Enter title" name="title" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Content</Form.Label> <Form.Control as="textarea" row={5} name="content" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={submitPost}> 글등록 </Button> </Form> </div> ); }; export default SaveForm;

UpdateForm

  • fetchUserInfo 통신을 통해 해당 글 정보를 가져옴
    • 통신한 board의 정보를 담을 state → board 객체로 저장
  • changeValue 함수를 통해 글 정보 수정
    • updateSubmit 함수를 통해 글 수정 통신 이후 Detail 페이지로 이동
import axios from "axios"; import React, { useEffect, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; const UpdateForm = (props) => { const { id } = useParams(); const navigate = useNavigate(); const jwt = useSelector((state) => state.jwt); const [board, setBoard] = useState({ title: "", content: "", }); useEffect(() => { fetchUserInfo(); }, []); async function updateSubmit(e) { e.preventDefault(); try { await axios({ method: "PUT", url: `http://localhost:8080/api/boards/${id}`, data: board, headers: { Authorization: jwt, }, }); navigate(`/board/${id}`); } catch (error) { alert(error.response.data.msg); } } const changeValue = (e) => { setBoard({ ...board, [e.target.name]: e.target.value, }); }; async function fetchUserInfo() { let response = await axios({ method: "GET", url: `http://localhost:8080/api/boards/${id}`, headers: { Authorization: jwt, }, }); let responseBody = response.data; setBoard({ title: responseBody.body.title, content: responseBody.body.content, }); } console.log(board); return ( <div> <h1>글수정</h1> <hr /> <Form> <Form.Group> <Form.Label>Title</Form.Label> <Form.Control value={board.title} type="text" placeholder="Enter title" name="title" onChange={changeValue} /> </Form.Group> <Form.Group> <Form.Label>Content</Form.Label> <Form.Control as="textarea" row={5} value={board.content} name="content" onChange={changeValue} /> </Form.Group> <Button variant="primary" type="submit" onClick={updateSubmit}> 글수정 </Button> </Form> </div> ); }; export default UpdateForm;

ReplyItem

  • 댓글의 디자인 및 정보를 담는 ReplyItem
  • 삭제 버튼을 눌러 해당 댓글 삭제 통신 가능
    • 부모 페이지에서 댓글을 안 보이게 처리해야 하므로 함수를 dribble해서 가져와야 함
    • 통신을 진행한 이후 notifyDeleteReply 호출 → 댓글 리스트 갱신
import axios from "axios"; import React from "react"; import { Button, Card } from "react-bootstrap"; import { useSelector } from "react-redux"; export default function ReplyItem(props) { const { reply, notifyDeleteReply } = props; const jwt = useSelector((state) => state.jwt); async function deleteReply(replyId) { await axios({ method: "DELETE", url: `http://localhost:8080/api/replies/${replyId}`, headers: { Authorization: jwt, }, }); notifyDeleteReply(replyId); } return ( <Card className="mb-3 shadow-sm border-0"> <Card.Body> <div className="d-flex justify-content-between"> <div> <div className="d-flex align-items-center mb-2"> <div className="flex-grow-1"> <h6 className="mb-0 fw-bold">{reply.username}</h6> </div> </div> <p className="mb-2">{reply.comment}</p> </div> {reply.owner && ( <Button variant="danger" onClick={() => deleteReply(reply.id)}> 삭제 </Button> )} </div> </Card.Body> </Card> ); }

변경된 화면

  • 미 로그인 시 : 댓글 삭제 버튼이 없음
notion image
  • 로그인 후 : 글 또는 댓글의 주인이면 글이면 수정/삭제, 댓글은 삭제 버튼 확인 가능
notion image
notion image
  • 글 작성 가능
notion image
  • 글 수정 가능
notion image
  • 댓글 작성 및 삭제 가능
notion image
 
Share article

Sxias ㆍ a32176740@gmail.com