React

[React] 메모장 SPA 어플리케이션

sian han 2022. 12. 18. 16:24

※ React 를 사용해 만든 메모장 SPA 어플리케이션

 

인프런의 프로젝트로 배우는 React.js 강의를 듣고 응용하여 만든 프로젝트입니다.

⬇️

 

 

 

  ✅ 목표

   - React, Hook, SPA, ES6 이해 및 학습

 

⚒️ Dependency

   - axios

   - json-server

   - prop-types

   - react-router-dom

 

🔫 요구사항

   - 메모 등록, 조회, 수정, 삭제

   - 조회 시 로딩 중 Spinner 보여주기

   - NavBar 페이지 나누기

 


▶ 라우팅

 

 

App.js

<Route path="/" exact>
	<HomePage />
</Route>,
<Route path="/blogs" exact>
	<ListPage />
</Route>,
<Route path="/blogs/create" exact>
	<CreatePage />
</Route>,
<Route path="/blogs/edit" exact>
	<EditPage />
</Route>

 

이런 방법도 있지만

아래와 같이 작성하면 컴포넌트가 많아질 때 코드의 재사용성을 높일 수 있다.

 

 

routes.js

const routes =[

  {
    path:'/',
    component: HomePage
  },
  {
    path:'/memos',
    component: ListPage
  },
  {
    path:'/memos/create',
    component: CreatePage
  },
  {
    path:'/memos/:id/edit',
    component: EditPage
  },
  {
    path: '/memos/:id',
    component:ShowPage
  },
];

export default routes;

 

 

App.js

function App() {
  return (
    <Router>
      <NavBar />
      <div className="container mt-3">
        <Switch>
          {routes.map((route)=> {
            return <Route 
                    key ={route.path} 
                    exact path ={route.path} 
                    component ={route.component}/>
          })}
        </Switch>
      </div>
    </Router>
  );
}

export default App;

 

컴포넌트가 추가되면 routes.js 배열에 추가만해주면 된다.

그리고 App.js 에서 map 함수에서 호출하여 <Route/> 컴포넌트의 value로 넣어주면 깔끔해진다.

 

사용된 컴포넌트는 다음과 같은 역할을 한다

 

▷ BrowserRouter (as Router)

웹 어플리케이션이 History API 를 사용해 페이지를 새로고침 없이 주소를 변경할 수 있게 해준다. 

 

▷ Switch 

Route 로 생성된 자식 컴포넌트중 매칭되는 첫번째 Route 를 렌더링 해준다. 

Switch 를 사용하지 않으면 현재위치와 일치하는 컴포넌트들이 모두 렌더링되어 화면에 보여진다.

 

▷ Route

지정된 경로에 따라 다른 컴포넌트를 보여준다.

 


▶ NavBar

가장 먼저 페이지 상단에 고정되어 있는 NavBar 컴포넌트를 만들었다

 

import { Link, NavLink } from "react-router-dom";

const NavBar = () => {
  return (
    <nav className="navbar navbar navbar-dark bg-dark">
      <div className="container">
        <Link className="navbar-brand" to="/">
          Home
        </Link>
        <ul className="navbar-nav">
          <li className="nav-item">
            <NavLink
              activeClassName="active"
              className="nav-link"
              aria-current="page"
              to="/memos"
            >
              MEMO
            </NavLink>
          </li>
        </ul>
      </div>
    </nav>
  );
};

export default NavBar;

React-router-dom@5 의 컴포넌트인 NavLink 는 Link 컴포넌트의 스페셜 기능으로서,

특정 링크에 스타일을 추가할 수 있다. 여기서 NavLink 는 'acitveClassName' 속성에 active 를 줘서

해당 링크로 접속하게 되면 밝게 빛나게 해주는 역할을 하고있다.

 


▶ 메모장 목록

db 에 저장된 메모장 목록을 보여주는 페이지이다.

코드가 길어서 역할별로 소개해보기로 하겠다.

 

 

▷ DB에 메모장 List 데이터 요청하기

useEffect 를 통해 컴포넌트가 마운트 될 때 getPost() 함수를 수행하여 db로부터 메모장 목록을 받아온다. 

const getPosts = () => {
    axios.get("http://localhost:3001/memos").then((res) => {
      setPosts(res.data); //받아온 데이터를 state 안에 넣어주기
      setLoading(false); //로딩이 완료되면 false 로 변경
    });
  };
  
  useEffect(() => {
    getPosts(); //useEffect 는 getPost 가 한번만 실행되도록 도와줌
  }, []);

getPost() 함수는 db에 메모 목록을 요청하고 응답을 받아오는 메서드이다.

useEffect 는 컴포넌트가 마운트(처음 나타났을 때) 되었을 때 특정 작업을 수행하는 React 의 Hook 이다.

위 코드와 같이 두번째 인자로 빈 배을 넣으면 마운트 되었을 때만 작업을 수행하고,

값을 넣을 경우 해당 값이 update 될 때마다 작업을 수행한다. 

 

그리고 loading useState 값을 true 로 set 한다.

loading state 가 어떤 역할을 하는지는 바로 아래에서 설명하겠다.

 

 

▷ LoadingSpinner 컴포넌트

const renderBlogList = () => {
    if (loading) {
        return <LoadingSpinner />;
    }else{
        return <메모리스트/>
    }
...

 

getPost() 함수에서 서버로부터 응답을 받아온 후 loading=false 가 되면

가져온 데이터를 뿌려서 리스트를 보여주고, 그렇지 않으면 LoadingSpinner 컴포넌트를 보여준다.

setLoading(false) 상태에서 사용자가 볼 화면은 아래와 같다. 

LoadingSpinner 컴포넌트는 Bootstrap 에서 템플릿을 가져다 쓰고 export 했다.

 

 

 

▷ 메모 삭제하기

Delete 버튼을 누르면 메모를 삭제할 수 있다. deleteMemo 는 버튼이 클릭되면 실행되는 함수이다.

const deleteMemo = (e, id) => {
    axios.delete(`http://localhost:3001/memos/${id}`).then(() => {
    });
  };

DB에 데이터를 삭제요청하는 코드만 넣었더니 두가지 문제가 발생한다.

 

1. 이벤트 버블링

  - 삭제 버튼을 누르면

  - 삭제 버튼을 누르면 실행되는 동작과

  - 삭제버튼의 부모 엘리먼트인 <Card/> 컴포넌트를 눌렀을 때 실행되는 동작이

  - 함께 실행된다.

 

 

const deleteMemo = (e, id) => {
    e.stopPropagation();
    axios.delete(`http://localhost:3001/memos/${id}`).then(() => {
    });
  };

 e.stopPropagation 를 통해 이벤트가 상위 엘리먼트에 전달되지 않게 막아 준다. 해결 !

 

 

2. 삭제해도 화면에 그대로 남아있음

  - 삭제 버튼을 누르면

  - DB에서만 삭제가 되고

  - 화면에는 삭제된 메모가 그대로 남아있음

  - 새로고침을 해야만 메모가 사라짐

 

리스트를 자동 업데이트하여 삭제된 메모를 없애주는 작업을 해보자

const deleteMemo = (e, id) => {
    e.stopPropagation();
    axios.delete(`http://localhost:3001/memos/${id}`).then(() => {
    
    setPosts((prevPosts) => {
        return prevPosts.filter((post) => {
          return post.id !== id;
        });
      });
    });
  };

 

filter 함수는 남아있는 리스트에서 삭제된 메모의 id 와 같지 않을 경우에만 남겨둔다.

따라서 삭제된 메모는 filter 에 의해 걸러진다. 해결 !

 

 

 

▷ 목록 보여주기

ListPage.js

const renderBlogList = () => {
...
if (posts.length === 0) {
      return <div>No blog posts found</div>;
    }

    return posts.map((post) => {
    
    return (
        <Card
          key={post.id}
          title={post.title}
          onClick={() => history.push(`/memos/${post.id}`)}
        >
          <div>
            <button
              className="btn btn-danger btn-sm"
              //onClick= {()=>console.log('delete')}
              onClick={(e) => deleteBlog(e, post.id)}
            >
              Delete
            </button>
          </div>
        </Card>
      );
    });
  };

만약 등록된 메모가 없다면 문구를 보여주고, 그렇지 않을 땐 map 함수를 실행해서 데이터를 뿌려준다.

 

해당(ListPage.js) 컴포넌트에서는 메모장 리스트 데이터가 확인 가능하지만 자식 컴포넌트인 Card 는 알 수 없다.

따라서 Card 컴포넌트로 데이터를 props 로 넘겨주었다.

 

 

Card.js

import PropTypes from 'prop-types'
const Card = ({ title, children, onClick }) => {

  return (
    <div 
    className="card mb-3 cursor-pointer"
    onClick = {onClick}> 
      <div className="card-body">
        <div className="d-flex justify-content-between">
          <div>{title}</div>
          {children && <div>{children}</div>}
        </div>
      </div>
    </div>
  );
};
...

 

자식 컴포넌트에서 props 를 전달 받을 때

const 자식컴포넌트=(props)=>{}

 

 

위와 같이 전달할 수 있는데, 이럴땐 props.title 로 전달 받은 props 를 꺼내 쓸 수 있다.

 

const Card = ({ title, children, onClick }) =>{}

이렇게 props 를 구조분해하여 전달할 수도 있다. 바로 title 에 접근이 가능하다.

 

 

React 에 내장된 타입검사 기능인 prop-types 라이브러리를 사용해 타입을 검증할 수 있다.

import PropTypes from 'prop-types'
...
Card.propTypes = { 
    title : PropTypes.string.isRequired,
    children : PropTypes.element,
    onClick : PropTypes.func, //Card 컴포넌트에서만 onClick 이벤트 가능하도록
};
//props 의 default 값을 설정
Card.defaultProps = {
    children : null,
    onClick: ()=> {},
};

export default Card;

다음과 같이 타입을 지정할 수 있다. 지정된 타입이 아닌값이 할당되면 console 창에 warning 이 발생한다.

 또한`isRequired`와 연결하여 prop가 제공되지 않았을 때 경고가 보이도록 할 수 있다.

 

다시 ListPage.js 로 돌아와서, ListPage 컴포넌트는 아래 html 을 리턴한다

return (
    <div>
      <div className="d-flex justify-content-between">
        <h1>Blogs</h1>
        <Link to="/memos/create" className="btn btn-success mb-2">
          Create New
        </Link>
      </div>
      {renderBlogList()}
    </div>
  );

Create New 버튼을 누르면 메모를 생성하는 페이지로 넘어간다.

 


▶ 메모 생성, 수정

생성 및 수정은 한개의 컴포넌트에 넣었다.

 

생성(오), 수정(왼)

 

▷ 서버에 메모 상세 데이터 요청

const Form = ({editing}) => {
  const {id} = useParams();
...
    useEffect(()=>{ 
        if(editing){ 
          axios.get(`http://localhost:3001/memos/${id}`).then(res => { 
            setTitle(res.data.title);
            setOriginalTitle(res.data.title);
            setBody(res.data.body);
            setOriginalBody(res.data.body);
          })
        }
      }, [id,editing]);

Form 컴포넌트는 EditPage 부모컴포넌트로부터 props(editing) 을 받는다.

만약 editing 이 true 라면 useParam 으로 받은 id를 통해 해당 메모의 데이터를 요청하고

이를 4개의 state 에 set 한다.

 

 

▷ 수정 버튼 활성화

const isEdited = () =>{
    return title !== originalTitle || body !== originalBody
}

4개의 state 는 수정된 값이 있을 때 버튼을 활성화 시켜주는 역할을 한다. 

그러니까 title 과 body 둘중 하나라도 변경이 되어야만 아래와 같이 Edit 버튼이 활성화되는 것이다.

 

before
After

 

이 버튼을 누르면 onSubmit() 이 실행된다. onsubmit 은 서버에 데이터 등록 또는 수정을 요청하는 함수이다

const onSubmit = () => {
    if(editing) {
      axios.patch(`http://localhost:3001/memos/${id}`, {
        title: title,
        body: body,
      }).then((res)=>{
        history.push(`/memos/${id}`)
      })
    }else{
    axios
      .post("http://localhost:3001/memos", {
        title: title,
        body: body,
        createdAt: Date.now(), // Date.now() : js 에서 시간을 가져오기
      })
      .then(() => {
        history.push("/memos");
      });
    }
  };

 

▷ 취소 버튼

const goBack = () => {
    if(editing){
      history.push(`/memos/${id}`);
    }else{
      history.push(`/memos`);
    }
  };

cancle 버튼을 눌렀을 때 발생하는 goback() 은 수정페이지면 상세페이지로,

생성 페이지면 메모 목록 페이지로 이동하게 한다.

 

Form.js 가 리턴하는 html 은 특별히 설명할 것이 없어서 첨부하지 않겠다.

코드는 아래 github 에서 확인 가능하다.

 


▶ 메모 상세 보기

 

▷ 서버에 메모 상세 정보 요청

const { id } = useParams();
const getPost = (id) => {
    axios.get(`http://localhost:3001/memos/${id}`).then((res) => {
      setPost(res.data);
      setLoading(false);
    });
  };

  useEffect(() => {
    console.log("hello");
    getPost(id);
  }, [id]);

useParams 를 이용해 url 의 id 부분을 가져올 수 있다.

데이터를 요청하는 함수를 만들고 useEffect 를 통해 실행시키는 과정은 List 를 불러오는 것과 동일하여 설명을 생략하겠다.

아 이번엔 useEffect 에 빈배열이 아니라 id 를 넣어서 id 가 변경될 때마다 useEffect 함수가 실행되도록 했다. 

이걸 의존성 배열안에 값을 넣는다. 라고 표현하는 것 같다. 

 

메모를 생성할 때 작성일을 아래와 같이 넣어 저장했다. 

createdAt: Date.now() //저장 : "createdAt": 1671310331099

이러면 우리가 인식하기 어려운 숫자로 저장이 되는데,

이 데이터를 우리가 읽기 쉽도록 printDate 함수를 만들었다.

 const printDate = (timestamp) => {
    return new Date(timestamp).toLocaleString();
  }; //작성일 : 2022. 12. 18. 오전 5:52:11

 

 


▶ 프로젝트를 마치며

재밌다 ! 리액트 !

역시 어려워서 싫었던 것이 아니라 익숙하지 않아서 싫었던거슬 ..

새로운거 배울때 제일 중요한거 ~ 열린마음 ~ 마음 열어 ~

 

 

github : https://github.com/HAN-SEOHYUN/react-memo

 

 

 

 

 

 

 

 

 

 

 

 

 

 

참고

https://velog.io/@hoon_dev/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0Route-Link-Switch-5

'React' 카테고리의 다른 글

[React 프로젝트] 프로젝트 생성 / 발생에러 및 해결  (0) 2022.11.28
[React] map / key  (0) 2022.07.06
[React] useEffect  (0) 2022.07.01
[React] useConext + Context API  (0) 2022.06.30
[React] useRef  (0) 2022.06.28