프론트엔드/React

리액트 기초이론 8 - 리액트 라우터

syleemomo 2021. 10. 22. 11:22
728x90

 

리액트 라우터 참고문서

 

React Router | Tutorial

Declarative routing for React apps at any scale

reactrouter.com

 

* 라우팅의 개념

라우팅이란 현재 화면에 보여지는 웹페이지에서 다른 웹페이지로 이동하는 것을 말한다. 라우팅을 하면서 웹페이지의 주소가 변경된다. 

 

* 리액트 라우터 동작방식

예전에는 라우팅을 할때마다 보여주려는 웹페이지의 리소스를 서버에 접속해서 가져왔다.  즉, 라우팅할때마다 서버에서 HTML 파일을 가져와서 렌더링하였다. 이렇게 하면 서버에 접속하는 시간 때문에 웹페이지를 이동할때마다 사용자에게 웹페이지를 보여주는데 시간이 걸렸다.

 

리액트 라우터의 동작 방식

 

리액트 라우터는 SPA (싱글페이지 애플리케이션)을 이용하여 라우팅할때 컴포넌트를 교체하는 방식으로 동작한다. 컴포넌트가 하나의 웹페이지이며, 해당 페이지에 필요한 데이터는 서버에서 전달받아 렌더링한다. 이렇게 하면 새로고침 없이 빠른 페이지 전환이 가능하다. 

 

* 리액트 라우터 설치하기

npm install react-router-dom

리액트 라우터는 서드파티 라이브러리이기 때문에 따로 설치가 필요하다. 위 명령어를 실행하여 설치한다. 

 

* 리액트 라우터 기본적인 사용 예시

import './App.css';
import React, { Component } from 'react';

class App extends Component {
  render(){
    return (
      <div className="App">
       Route to App 
      </div>
    );
  }
}

export default App;

App.js 파일을 위와 같이 작성하자!

import { BrowserRouter } from "react-router-dom";
import App from './App.js'

function Browser(){
    return (
        <BrowserRouter>
            <App></App>
        </BrowserRouter>
    )
}

export default Browser

Browser.js 파일을 위와 같이 생성하자! Browser 컴포넌트는 react-router-dom 라이브러리에서 BrowserRouter 라는 컴포넌트를 가져와서 App 컴포넌트를 감싸준다. 이렇게 하면 App 컴포넌트에서는 라우팅에 관련된 다양한 기능을 사용할 수 있다. 

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Browser from './Browser';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Browser />
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

index.js 파일을 위와 같이 수정하자! 이전에는 App 컴포넌트를 임포트하고 렌더링했지만, 이제는 Browser 컴포넌트를 임포트하고 렌더링한다. App 컴포넌트의 컨텐츠가 아래와 같이 제대로 보여지는지 확인한다.

App 컴포넌트 렌더링 확인하기

 

 

src 폴더 아래에 pages 폴더를 생성하고 아래와 같은 페이지 컴포넌트를 생성하자!

import React from 'react'

function Home(){
    return (
        <h1>HOME PAGE</h1>
    )
}
export default Home

pages 폴더 아래 Home.js 파일을 생성하고 위와 같이 작성하자!

import React from 'react'

function About(){
    return (
        <h1>ABOUT PAGE</h1>
    )
}
export default About

pages 폴더 아래 About.js 파일을 생성하고 위와 같이 작성하자!

import React from 'react'

function NotFound(){
    return (
        <h1>NOT FOUND PAGE !</h1>
    )
}
export default NotFound

pages 폴더 아래 NotFound.js 파일을 생성하고 위와 같이 작성하자!

export { default as Home } from './Home';
export { default as About } from './About';
export { default as NotFound } from './NotFound';

pages 폴더 아래 index.js 파일을 생성하고 위와 같이 작성하자! pages 폴더 아래의 모든 컴포넌트를 index.js 라는 하나의 파일에서 export 한다. 

import './App.css';
import React, { Component } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Home, About, NotFound } from './pages';

class App extends Component {
  render(){
    return (
      <div className="App">
        <Routes>
          <Route exact path="/" element={<Home/>}/>
          <Route exact path="/about" element={<About/>}/>
          <Route path="*" element={<NotFound/>}/>
        </Routes>
      </div>
    );
  }
}

export default App;

이제 App.js 파일을 위와 같이 수정하자!

import { Route, Routes } from 'react-router-dom';
import { Home, About, NotFound } from './pages';

App.js 파일 상단에 위와 같이 Route, Routes, Home, About, NotFound 컴포넌트를 임포트한다. 

<Routes>
  <Route exact path="/" element={<Home/>}/>
  <Route exact path="/about" element={<About/>}/>
  <Route path="*" element={<NotFound/>}/>
</Routes>

페이지를 라우팅할때 Routes, Route 컴포넌트를 사용한다. path 속성은 페이지 URL 경로이고, element 속성은 화면에 보여줄 컴포넌트이다. 즉, 사용자가 / (기본경로) 로 접속하면 Home 컴포넌트를 보여주고, /about 으로 접속하면 About 컴포넌트를 보여준다. 컴포넌트가 하나의 페이지인 셈이다. 각각의 Route 컴포넌트는 Routes 컴포넌트로 묶어준다. 

path 가 와일드카드(*) 로 설정되면 사용자가 입력한 URL 경로와 일치하는 Route 가 없는 경우에 해당한다. 이 경우에 NotFound 컴포넌트를 보여주면 된다. 즉, 404 페이지이다.  

 

Home 페이지
About 페이지
404 페이지

 

 

* Link 컴포넌트와 사용방법

BrowserRouter 로 감싸주지 않고, Link 컴포넌트를 사용하려고 하면 에러가 발생한다.

사용자가 직접 URL 경로를 입력하지 않고 메뉴를 클릭했을때 페이지를 이동하려면 어떻게 하면 될까?

import React from 'react'
import { Link } from 'react-router-dom'

function Menu(){
    return (
        <nav>
            <Link to="/">HOME</Link><br/>
            <Link to="/about">ABOUT</Link>
        </nav>
    )
}
export default Menu

Menu.js 파일을 생성하고 위와 같이 작성하자! Link 컴포넌트는 HTML 태그 중에서 a 태그와 유사하다. 차이점은 a 태그는 클릭시 페이지를 새로고침하지만 Link 컴포넌트는 그렇지 않다. 이는 페이지 렌더링 시간과 성능향상에 도움이 된다.

import './App.css';
import React, { Component } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Home, About, NotFound } from './pages';
import Menu from './Menu'

class App extends Component {
  render(){
    return (
      <div className="App">
        <Menu></Menu>
        <Routes>
          <Route exact path="/" element={<Home/>}/>
          <Route exact caseSensitive path="/about" element={<About/>}/>
          <Route path="*" element={<NotFound/>}/>
        </Routes>
      </div>
    );
  }
}

export default App;

App.js 파일을 위와 같이 수정하자! Menu 컴포넌트를 임포트하고 렌더링한다. 각 링크를 클릭하면 해당 페이지로 이동한다. caseSensitive props 는 /about 과 정확히 일치하는 경우에만 About 페이지를 보여준다.  /ABOUT /About 등과 같이 URL 대소문자를 구분한다.  

 

이전에 만들어둔 Sidebar 컴포넌트를 이용하여 조금 더 실용적인 메뉴를 만들어보자!

import React from 'react'
import { Link } from 'react-router-dom'
import './Menu.css'

function Menu(){
    return (
        <>
            <Link to="/" className="menu-item">HOME</Link>
            <Link to="/about" className="menu-item">ABOUT</Link>
        </>
    )
}
export default Menu

Menu.js 파일을 위와 같이 수정하자! nav 태그를 fragment 태그로 변경하고, Menu.css 파일을 임포트하여 스타일링을 추가하였다. 

.menu-item{
    text-decoration: none;
    flex: 1;
    color: white;
    font-size: 2rem;
    font-weight: bold;
    border-bottom: 1px solid white;
    cursor: pointer;

    display: flex;
    justify-content: center;
    align-items: center;
}
.menu-item:hover{
    background: chocolate;
}

Menu.css 파일을 위와 같이 작성하자! 

import './App.css';
import React, { Component } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Home, About, NotFound } from './pages';
import Menu from './Menu'
import Sidebar from './Sidebar'
import Button from './Button'

class App extends Component {
  state = {
    open: false
  }
  showSidebar = () => {
    this.setState({ open: !this.state.open })
  }
  render(){
    const { open } = this.state
    return (
      <div className="App">
        <Button handleClick={this.showSidebar}>Menu</Button>
        <Sidebar open={open}>
          <Menu></Menu>
        </Sidebar>
        <Routes>
          <Route exact path="/" element={<Home/>}/>
          <Route exact path="/about" element={<About/>}/>
          <Route path="*" element={<NotFound/>}/>
        </Routes>
      </div>
    );
  }
}

export default App;

App.js 파일을 위와 같이 수정하자!

.App {
  text-align: center;
}

App.css 파일을 위와 같이 수정하자!

import Sidebar from './Sidebar'
import Button from './Button'

이전에 만들어둔 Sidebar, Button 컴포넌트를 임포트한다. 

state = {
  open: false
}

Sidebar 를 열고 닫기 위하여 open 상태를 초기화한다. 

showSidebar = () => {
  this.setState({ open: !this.state.open })
}

Menu 버튼을 클릭하면 실행되는 이벤트핸들러 함수이다. open 상태는 Menu 버튼을 클릭할때마다 토글된다. 즉, true 이면 false 로 변경되고, false 이면 true 로 변경된다. 이에 따라 Sidebar 가 열리고 닫힌다. 

render(){
    const { open } = this.state
    return (
      <div className="App">
        <Button handleClick={this.showSidebar}>Menu</Button>
        <Sidebar open={open}>
          <Menu></Menu>
        </Sidebar>
        <Routes>
          <Route exact path="/" element={<Home/>}/>
          <Route exact path="/about" element={<About/>}/>
          <Route path="*" element={<NotFound/>}/>
        </Routes>
      </div>
    );
}

Menu 컴포넌트를 Sidebar 컴포넌트로 감싼다. Menu 컴포넌트는 Sidebar 의 컨텐츠(children)으로써 Sidebar 컴포넌트 내부에서 렌더링된다. Menu 버튼을 클릭하면 showSidebar 이벤트핸들러 함수가 실행되면서 Sidebar 가 나타나고 사라진다. 

Menu 버튼
사이드바 메뉴

 

* Nested Routes 와 URL 파라미터 사용하기

import React from 'react'
import { useParams } from "react-router-dom";

function Post(){
    const params = useParams();
    return (
        <>
            <h1>POST PAGE</h1>
            <h2>This is Post {params.postId}</h2>
        </>
    )
}
export default Post

pages 폴더 아래 Post.js 파일을 생성하고 위와 같이 작성하자! react-router-dom 라이브러리에서 useParams 함수를 임포트한다. useParams 함수는 params 객체를 반환한다. params 객체는 URL 파라미터를 조회할 수 있다. 예를 들면, /posts/12345 와 같은 URL 경로로 접근하면 params 객체의 postId 는 12345 가 된다. 

export { default as Home } from './Home';
export { default as About } from './About';
export { default as Post } from './Post';
export { default as NotFound } from './NotFound';

pages 폴더의 index.js 파일에 Post 컴포넌트를 추가하자!

import './App.css';
import React, { Component } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Home, About, NotFound, Post } from './pages';
import Menu from './Menu'
import Sidebar from './Sidebar'
import Button from './Button'

class App extends Component {
  state = {
    open: false
  }
  showSidebar = () => {
    this.setState({ open: !this.state.open })
  }
  render(){
    const { open } = this.state
    return (
      <div className="App">
        <Button handleClick={this.showSidebar}>Menu</Button>
        <Sidebar open={open}>
          <Menu></Menu>
        </Sidebar>
        <Routes>
          <Route exact path="/" element={<Home/>}/>
          <Route exact path="/about" element={<About/>}/>
          <Route path="/posts" element={<Post/>}>
            <Route path=":postId" element={<Post/>}/>
          </Route>
          <Route path="*" element={<NotFound/>}/>
        </Routes>
      </div>
    );
  }
}

export default App;

App.js 파일을 위와 같이 수정하자! 

<Routes>
  <Route exact path="/" element={<Home/>}/>
  <Route exact path="/about" element={<About/>}/>
  <Route path="/posts" element={<Post/>}>
    <Route path=":postId" element={<Post/>}/>
  </Route>
  <Route path="*" element={<NotFound/>}/>
</Routes>

 Post 관련 Route 가 추가되었다. 사용자가 /posts URL 경로로 접속하면 Post 컴포넌트를 보여준다. Route 컴포넌트 안에 또다른 Route 컴포넌트가 존재한다. 이를 Nested Routes 라고 한다. 이렇게 하면 /posts URL 경로에서 시작해서 또다른 URL 경로를 추가할 수 있다. 현재는 postId 라는 URL 파라미터를 전달한다. 즉, 사용자가 /posts/12345 와 같이 접속하면 Post 컴포넌트 내부로 12345 가 전달되어 params 객체의 postId 로 조회 가능하다. 

import React from 'react'
import { Link } from 'react-router-dom'
import './Menu.css'

function Menu(){
    return (
        <>
            <Link to="/" className="menu-item">HOME</Link>
            <Link to="/about" className="menu-item">ABOUT</Link>

            {/* Post 메뉴 */}
            <Link to="/posts/1" className="menu-item">Post 1</Link>
            <Link to="/posts/2" className="menu-item">Post 2</Link>
            <Link to="/posts/3" className="menu-item">Post 3</Link>
        </>
    )
}
export default Menu

Menu.js 파일을 위와 같이 수정하자! Post 메뉴가 추가되었다. 사용자가 직접 /posts/1 /posts/2 /posts/3 과 같은 URL 경로로 접근하지 않고, 메뉴를 클릭하면 Post 컴포넌트와 해당하는 Post 내용이 화면에 렌더링된다. 

Post 메뉴 추가
Post 메뉴 선택시 보여지는 화면

 

 

POST 관련 데이터를 생성하고 개인 블로그와 같은 조금 더 실용적인 프로젝트를 만들어보자!

const postData = [
    {
        title: 'how to ride on bike',
        content: 'you can ride on bike even when you are so young. cuz we have muscle for that. if you want to go on anywhere, you can ride on bike',
        created: '2021-03-05'
    },
    {
        title: 'how to read on books fast',
        content: 'you can read on books so fast if you read a lot. cuz your brain is deveploped to read so fast.',
        created: '2021-04-22'
    },
    {
        title: 'how to make your own chair with wood',
        content: 'you can make your own chair with wood when you start to make from small tings with wood. and you can enhance that stuff.',
        created: '2021-07-08'
    },
    {
        title: 'how to buy cheap furniture',
        content: 'you can buy cheap furniture when you go to recycled places. cuz they want to sell those at cheap prices',
        created: '2021-09-16'
    },
    {
        title: 'how to learn new things fast',
        content: 'you can learn new things so fast if you have motivation to do it. cuz we learn so fast if we enjoy that',
        created: '2021-11-10'
    },
]
export default postData

postData.js 파일을 생성하고 위와 같이 작성하자! 복사 붙여넣기 하면 된다. 

import React from 'react'
import { Link } from 'react-router-dom'
import './Menu.css'
import posts from './postData'

function Menu(){
    return (
        <>
            <Link to="/" className="menu-item">HOME</Link>
            <Link to="/about" className="menu-item">ABOUT</Link>

            {/* Post 메뉴 */}
            {posts.map( (post, id) => {
                return (
                    <Link key={id} to={`/posts/${id}`} className="menu-item">{post.title}</Link>
                )
            })}
        </>
    )
}
export default Menu

Menu.js 파일을 위와 같이 수정하자! postData 를 임포트하고 Link 컴포넌트를 사용하여 메뉴를 동적으로 생성한다. 

Post 메뉴 추가된 모습

import React from 'react'
import { useParams } from "react-router-dom";
import posts from '../postData'

function Post(){
    const params = useParams();
    const post = posts[params.postId]
    return (
        <>
            <h1>{post.title}</h1>
            <p>{post.content}</p>
            <span>{post.created}</span>
        </>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자! postData 를 임포트하고 URL 파라미터로 전달된 postId 로 특정 블로그 포스트를 선택한다. 해당 블로그 포스트를 화면에 렌더링한다. 

특정 블로그 Post 를 선택한 화면

 

근데 한가지 문제가 있다. /posts 로 접근하면 아래와 같은 에러 메세지를 호출한다. 이유는 /posts 경로로 직접 접근하면 Post 컴포넌트의 params.postId 와 post 가 모두 undefined 가 되기 때문에 post.title 값을 가져올 수 없기 때문이다. post 가 더이상 객체가 아니므로 title 프로퍼티에 접근할 수 없다. 

/posts 경로에 직접 접근하는 경우의 오류 메세지 출력

그러므로 아래 코드와 같이 삼항연산자를 사용하여 post 값이 존재하면 특정 블로그 포스트를 보여주고, post 값이 undefined 이면(/posts 경로로 접근한 경우) POST PAGE 라는 문자열을 화면에 출력하면 된다. 

import React from 'react'
import { useParams } from "react-router-dom";
import posts from '../postData'

function Post(){
    const params = useParams();
    const post = posts[params.postId]
    return (
        post ? 
            <>
            <h1>{post.title}</h1>
            <p>{post.content}</p>
            <span>{post.created}</span>
            </>   :
            <h1>POST PAGE</h1>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자! 이렇게 하면 /posts 페이지에 직접 접근해도 에러가 발생하지 않는다. 

 

그러나 또다른 문제가 존재한다. 만약 포스트 갯수가 100개라면 사이드바 안에 전부 담을수가 없다. 그러므로 사용자가 홈페이지에 접속하면 사이드바에는 아래와 같이 HOME, ABOUT, POST 라는 3개의 메뉴만 존재하도록 코드를 수정하자!

import React from 'react'
import { Link } from 'react-router-dom'
import './Menu.css'
import posts from './postData'

function Menu(){
    return (
        <>
            <Link to="/" className="menu-item">HOME</Link>
            <Link to="/about" className="menu-item">ABOUT</Link>
            <Link to="/posts" className="menu-item">POST</Link>
        </>
    )
}
export default Menu

Menu.js 파일을 위와 같이 수정하자! 각각의 블로그 포스트에 대한 Link 컴포넌트는 모두 제거하고, POST 메뉴를 추가한다.

그렇지만 현재 Menu 컴포넌트는 HOME, ABOUT, POST 만 보여주기 때문에 기능이 제한적이다. 어떠한 메뉴도 보여줄 수 있고 메뉴 갯수가 몇개이든 보여줄 수 있도록 Menu 컴포넌트를 아래와 같이 수정하자! 메뉴에 대한 정보를 담고 있는 배열(menus)을 Menu 컴포넌트 내부로 전달하면 훨씬 더 유연한 컴포넌트가 되며 재사용성도 좋아진다.

import React from 'react'
import { Link } from 'react-router-dom'
import './Menu.css'

function Menu({menus}){
    return (
        <>
            {menus.map ( (menu, id) => {
                return (
                    <Link key={id} to={menu.url} className="menu-item">{menu.name}</Link>
                )
            })}
        </>
    )
}
export default Menu

메뉴에 대한 정보를 담고 있는 menus 를 Menu 컴포넌트의 props 로 전달받은 다음 Link 컴포넌트를 이용하여 메뉴를 동적으로 생성한다. 

import './App.css';
import React, { Component } from 'react';
import { Route, Routes } from 'react-router-dom';
import { Home, About, NotFound, Post } from './pages';
import Menu from './Menu'
import Sidebar from './Sidebar'
import Button from './Button'


class App extends Component {
  homeMenu = [
    {
      url: "/",
      name: "HOME"
    },
    {
      url: "/about",
      name: "ABOUT"
    },
    {
      url: "/posts",
      name: "POST"
    },
  ]
  state = {
    open: false
  }
  showSidebar = () => {
    this.setState({ open: !this.state.open })
  }
  render(){
    const { open } = this.state
    const { homeMenu } = this
    return (
      <div className="App">
        <Button handleClick={this.showSidebar}>Menu</Button>
        <Sidebar open={open}>
          <Menu menus={homeMenu}></Menu>
        </Sidebar>
        <Routes>
          <Route exact path="/" element={<Home/>}/>
          <Route exact path="/about" element={<About/>}/>
          <Route path="/posts" element={<Post/>}>
            <Route path=":postId" element={<Post/>} />
          </Route>
          <Route path="*" element={<NotFound/>}/>
        </Routes>
      </div>
    );
  }
}

export default App;

 App.js 를 위와 같이 수정하자! 

homeMenu = [
    {
      url: "/",
      name: "HOME"
    },
    {
      url: "/about",
      name: "ABOUT"
    },
    {
      url: "/posts",
      name: "POST"
    },
 ]

App 클래스의 멤버변수로 homeMenu 를 선언하고 초기화한다. 해당 변수는 사이드바 메뉴 정보를 담고 있다. 

const { homeMenu } = this

render 함수 안에서 homeMenu 를 조회한다. 

<Sidebar open={open}>
  <Menu menus={homeMenu}></Menu>
</Sidebar>

Menu 컴포넌트의 menus 속성을 homeMenu 로 설정하였다. 이렇게 하면 homeMenu 가 Menu 컴포넌트 내부로 전달되어 동적으로 메뉴를 생성한다. 

import React from 'react'
import { useParams, Link } from "react-router-dom";
import posts from '../postData'
import './Post.css'

function Post(){
    const params = useParams();
    const post = posts[params.postId]
    return (
        <>
            {/* 특정 블로그 포스트 */}
            {post ? 
                <div className="post-container">
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <span>{post.created}</span>
                </div>   :
                <h1>POST PAGE</h1>}

            {/* 블로그 포스트 전체목록  */}
            {posts.map( (post, id) => {
                return (
                    <Link key={id} to={`/posts/${id}`} className="post-item">{post.title}</Link>
                )
            })}
        </>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자! 

.post-container{
    width: 400px;
    margin: 20px auto;
    padding: 20px;
    background: orangered;
    color: white;
    border-radius: 10px;
}
.post-item{
    text-decoration: none;
    display: flex;
    flex-direction: column;
    color: lightsalmon;
    font-size: 1.5rem;
    font-weight: bold;
}
.post-item:hover{
    color: orangered;
}

Post.css 파일을 위와 같이 생성하자!

import React from 'react'
import { useParams, Link } from "react-router-dom";
import posts from '../postData'
import './Post.css'

react-router-dom 라이브러리에서 Link 컴포넌트를 추가로 임포트한다. Post.css 파일을 추가로 임포트한다. 

{/* 블로그 포스트 전체목록  */}
{posts.map( (post, id) => {
    return (
        <Link key={id} to={`/posts/${id}`} className="post-item">{post.title}</Link>
    )
})}

Menu 컴포넌트에서 제거된 해당 코드를 Post 컴포넌트 내부로 이동하였다. 이렇게 하면 아래와 같이 POST 페이지에서 블로그 포스트 전체목록을 보여준다. 

POST 페이지 메인화면

 

블로그 포스트 전체목록에서 특정 포스트를 선택하면 아래와 같이 포스트 내용을 보여준다. 

 

만약 클릭한 포스트 링크의 스타일을 변경하려면 어떻게 하면 될까?

import React from 'react'
import { useParams, NavLink } from "react-router-dom";
import posts from '../postData'
import './Post.css'

function Post(){
    const params = useParams();
    const post = posts[params.postId]
    const applyActiveColor = ({ isActive }) => (isActive ? {color: 'orangered'} : {})
    return (
        <>
            {/* 특정 블로그 포스트 */}
            {post ? 
                <div className="post-container">
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <span>{post.created}</span>
                </div>   :
                <h1>POST PAGE</h1>}

            {/* 블로그 포스트 전체목록  */}
            {posts.map( (post, id) => {
                return (
                    <NavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</NavLink>
                )
            })}
        </>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자!

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

Link 컴포넌트 대신에 NavLink 컴포넌트를 임포트한다. NavLink 컴포넌트는 사용자가 리스트에서 특정 아이템을 선택하면 해당 아이템의 스타일을 변경할 수 있게 해준다. 

const applyActiveColor = ({ isActive }) => (isActive ? {color: 'orangered'} : {})

클릭한 포스트의 스타일을 변경하는 코드이다. 사용자가 특정 아이템을 선택하면 isActive 가 true 로 변경되면서 원하는 스타일이 적용된다. 현재는 color 값을 변경하고 있다. 

<NavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</NavLink>

Link 컴포넌트를 NavLink 컴포넌트로 교체한다. style 속성을 추가하고 applyActiveColor 라는 콜백함수를 설정하였다. 이렇게 하면 사용자가 선택한 포스트의 title 색상이 변경된다. 이제 다시 블로그 포스트 전체목록에서 특정 포스트를 선택해보자!

특정 포스트 선택시 title 색상이 변경된 모습

 

 

* URL 쿼리스트링(query string) 조회하기

만약 사용자가 특정 키워드로 검색해서 읽고 싶은 블로그 포스트들만 추출하고 싶으면 어떻게 하면 될까?

import React from 'react'
import { useParams, NavLink, useSearchParams } from "react-router-dom";
import posts from '../postData'
import './Post.css'

function Post(){
    const params = useParams();
    const post = posts[params.postId]
    let [searchParams, setSearchParams] = useSearchParams()
    const applyActiveColor = ({ isActive }) => (isActive ? {color: 'orangered'} : {})

    const changeQueryString = (e) => {
        const filter = e.target.value
        if(filter){
            setSearchParams({ filter })
        }else{
            setSearchParams({})
        }
    }
    return (
        <>
            {/* 쿼리스트링을 이용한 검색 */}
            <br/><input className="filter-post" value={searchParams.get('filter') || ""} onChange={changeQueryString} placeholder="Search post ..."/>

            {/* 특정 블로그 포스트 */}
            {post ? 
                <div className="post-container">
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <span>{post.created}</span>
                </div>   :
                <h1>POST PAGE</h1>}

            {/* 블로그 포스트 전체목록  */}
            {posts.map( (post, id) => {
                return (
                    <NavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</NavLink>
                )
            })}
        </>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자!

.post-container{
    width: 400px;
    margin: 20px auto;
    padding: 20px;
    background: orangered;
    color: white;
    border-radius: 10px;
}
.post-item{
    text-decoration: none;
    display: flex;
    flex-direction: column;
    color: lightsalmon;
    font-size: 1.5rem;
    font-weight: bold;
}
.post-item:hover{
    color: orangered;
}
.filter-post{
    all: unset;
    margin: 20px;
    width: 400px;
    height: 50px;
    border: 2px solid lightgreen;
    border-radius: 15px;
    text-align: left;
    padding-left: 15px;
    color: lightgreen;
    font-size: 1.2rem;
    font-weight: bold;
}
.filter-post::placeholder{
    color: lightgreen;
    font-size: 1.2rem;
    font-weight: bold;
}

Post.css 파일을 위와 같이 수정하자! input 요소에 대한 스타일이 추가되었다. 

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

react-router-dom 라이브러리에서 useSearchParams 라는 함수를 임포트한다. 해당 함수는 URL 쿼리스트링을 조회하고 변경할 수 있게 해준다. URL 쿼리스트링은 예를 들어 /posts/1?filter=word 와 같이 URL 에서 물음표(?) 뒤에 따라오는 key-value 쌍이다. filter 는 key 이고 word 는 value 이다. 

let [searchParams, setSearchParams] = useSearchParams()

useSearchParams 함수는 searchParams 와 setSearchParams 를 반환한다. searchPrams 는 URL 쿼리스트링의 key 를 이용하여 value 값을 읽을수 있게 도와주는 객체이다. setSearchParams 는 URL 쿼리스트링의 key 를 이용하여 value 값을 변경할 수 있게 도와주는 함수이다. 

const changeQueryString = (e) => {
    const filter = e.target.value
    if(filter){
        setSearchParams({ filter })
    }else{
        setSearchParams({})
    }
}

사용자가 검색창에 뭔가를 입력할때 실행되는 이벤트핸들러 함수이다. 사용자가 입력하는 검색 키워드는 e.target.value 로 조회할 수 있다. 사용자가 입력한 키워드가 존재하면 URL 쿼리스트링의 filter 키를 e.target.value (사용자가 입력한 키워드) 값으로 설정한다. 

{/* 쿼리스트링을 이용한 검색 */}
<br/><input className="filter-post" value={searchParams.get('filter') || ""} onChange={changeQueryString} placeholder="Search post ..."/>

사용자 검색을 위하여 input 요소를 추가한다. value 속성은 searchParams의 get 메서드를 이용하여, URL 쿼리스트링의 filter 키의 값으로 설정한다. 사용자가 검색창에 뭔가를 입력하게 되면 onChange 이벤트에 의하여 changeQueryString 이벤트핸들러 함수가 실행된다. 

사용자가 특정 키워드로 검색한 모습

 

검색창에 뭔가를 입력하면 URL 쿼리스트링인 filter 키의 값이 변경됨을 확인할 수 있다.

 

하지만 현재는 사용자가 검색을 해도 특정 블로그 포스트만 걸러지지 않는다. 즉, 검색 기능이 동작하지 않고 있다. 그럼 이제 실제로 사용자가 검색한 블로그 포스트만 걸러내보자!

{/* 블로그 포스트 전체목록  */}
{posts
    .filter( post => {
        const filter = searchParams.get('filter')
        if(!filter) return true;
        const title = post.title.toLowerCase()
        return title.includes(filter.toLowerCase())
    })
    .map( (post, id) => {
        return (
            <NavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</NavLink>
        )
})}

Post.js 파일에서 해당 부분을 위와 같이 수정해보자! filter 메서드가 추가되었다. 블로그 포스트 전체목록에서 사용자가 검색한 키워드를 포함하는 포스트들만 추출한다. 사용자가 검색한 키워드를 조회하기 위하여 searchParams 의 get 메서드를 사용하였다.

만약 검색 키워드가 존재하지 않으면 true 를 반환함으로써 기존의 전체목록을 보여준다. 검색 키워드가 존재하면 특정 포스트의 제목 (title) 이 검색 키워드를 포함할때만 보여준다. 포스트의 제목 (title) 과 키워드를 동등하게 비교하기 위하여 toLowerCase 라는 문자열 메서드를 사용하였다.

포스트 제목과 키워드를 모두 소문자로 변경한 다음 포함여부를 확인하다. 즉, 대소문자 구분없이 검색이 된다. 예를 들면, on 이나 ON 이나 동일한 포스트가 검색된다. includes 메서드는 문자열에서 특정 키워드를 포함하는지 검사한다.

사용자가 검색한 포스트만 나열한 모습

 

하지만 문제가 있다. 검색된 포스트를 클릭하면 검색창에 검색어가 사라지고 URL 쿼리스트링으로 설정한 filter 값도 사라진다. 또한, 화면에 검색하기 이전의 블로그 포스트 전체목록이 다시 나타난다. 

그럼 사용자가 검색된 포스트를 클릭했을때 현재의 화면을 유지하려면 어떻게 하면 될까?

import React from 'react'
import { useParams, NavLink, useSearchParams, useLocation } from "react-router-dom";
import posts from '../postData'
import './Post.css'

function Post(){
    const params = useParams();
    const post = posts[params.postId]
    let [searchParams, setSearchParams] = useSearchParams()
    const applyActiveColor = ({ isActive }) => (isActive ? {color: 'orangered'} : {})

    const changeQueryString = (e) => {
        const filter = e.target.value
        if(filter){
            setSearchParams({ filter })
        }else{
            setSearchParams({})
        }
    }
    const QueryNavLink = ({ to, children, ...props }) => {
        const location = useLocation();
        return <NavLink to={to + location.search} {...props}>{children}</NavLink>
      }
    return (
        <>
            {/* 쿼리스트링을 이용한 검색 */}
            <br/><input className="filter-post" value={searchParams.get('filter') || ""} onChange={changeQueryString} placeholder="Search post ..."/>

            {/* 특정 블로그 포스트 */}
            {post ? 
                <div className="post-container">
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <span>{post.created}</span>
                </div>   :
                <h1>POST PAGE</h1>}

            {/* 블로그 포스트 전체목록  */}
            {posts
            .filter( post => {
                const filter = searchParams.get('filter')
                if(!filter) return true;
                const title = post.title.toLowerCase()
                return title.includes(filter.toLowerCase())
            })
            .map( (post, id) => {
                return (
                    <QueryNavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</QueryNavLink>
                )
            })}
        </>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자!

import { useParams, NavLink, useSearchParams, useLocation } from "react-router-dom";

react-router-dom 라이브러리에서 useLocation 함수를 임포트한다. 

const QueryNavLink = ({ to, children, ...props }) => {
    const location = useLocation();
    console.log(location)
    return <NavLink to={to + location.search} {...props}>{children}</NavLink>
}

useLocation 반환값 출력한 모습

hash: ""
key: "y407tr4j"
pathname: "/posts/1"
search: "?filter=on"
state: null

useLocation 의 반환값을 출력해보면 위와 같다. 즉, 현재 URL 에 대한 정보를 담고 있다. 이 중에서 search 프로퍼티를 현재의 pathname 에 연결한다. 현재의 pathname 은 QueryNavLink 함수의 to 파라미터로 전달된다. 현재 캡쳐화면에서는 /posts/1 을 의미한다. 

.map( (post, id) => {
    return (
        <QueryNavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</QueryNavLink>
    )
})}

NavLink 컴포넌트를 QueryNavLink 컴포넌트로 교체한다. 결국 to 속성으로 전달되는 현재의 URL 경로 (/posts/1) 에 location.search 로 조회한 쿼리스트링 (?filter=on) 을 연결하기 위하여 NavLink 컴포넌트를 QueryNavLink 컴포넌트로 한번 더 감싸준 것이다.

{...props} 은 QueryNavLink 컴포넌트에서 to, children 제외한 나머지 속성들 (key, className, style) 을 props 라는 하나의 객체로 묶은 다음, 다시 NavLink 컴포넌트에서 나머지 속성들을 풀어서 설정한다. 즉, NavLink 컴포넌트에 key, className, style 속성들이  {...props} 로 한번에 설정된다.  

 

하지만 또다른 문제가 있다. 아래와 같이 fast 키워드로 검색하면 포스트 제목을 클릭했을때 엉뚱한 포스트 내용이 나온다. 즉, 포스트 제목과 내용이 일치하지 않는다. 

포스트 제목과 내용이 일치하지 않는 상황

 

이러한 문제가 발생하는 이유는 포스트 목록은 키워드에 의하여 필터링이 되었지만, 아래 코드와 같이 특정 포스트를 선택할때는 여전히 postData 라는 블로그 전체목록에서 선택하기 때문이다. 즉, posts 를 필터링된 목록으로 바꿔야 한다.

import posts from '../postData'

const post = posts[params.postId] // 특정 포스트 선택
import React from 'react'
import { useParams, NavLink, useSearchParams, useLocation } from "react-router-dom";
import posts from '../postData'
import './Post.css'

function Post(){
    const params = useParams();
    let [searchParams, setSearchParams] = useSearchParams()
    const applyActiveColor = ({ isActive }) => (isActive ? {color: 'orangered'} : {})

    const changeQueryString = (e) => {
        const filter = e.target.value
        if(filter){
            setSearchParams({ filter })
        }else{
            setSearchParams({})
        }
    }
    const QueryNavLink = ({ to, children, ...props }) => {
        const location = useLocation();
        console.log(location)
        return <NavLink to={to + location.search} {...props}>{children}</NavLink>
    }
    // 필터링된 목록으로 렌더링하기
    const postsFiltered = posts
    .filter( post => {
        const filter = searchParams.get('filter')
        if(!filter) return true;
        const title = post.title.toLowerCase()
        return title.includes(filter.toLowerCase())
    })
    const post = postsFiltered[params.postId] // 필터링된 목록에서 특정 포스트 글 선택하기
    return (
        <>
            {/* 쿼리스트링을 이용한 검색 */}
            <br/><input className="filter-post" value={searchParams.get('filter') || ""} onChange={changeQueryString} placeholder="Search post ..."/>

            {/* 특정 블로그 포스트 */}
            {post ? 
                <div className="post-container">
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <span>{post.created}</span>
                </div>   :
                <h1>POST PAGE</h1>}

            {/* 블로그 포스트 전체목록  */}
            {postsFiltered
            .map( (post, id) => {
                return (
                    <QueryNavLink key={id} to={`/posts/${id}`} className="post-item" style={applyActiveColor}>{post.title}</QueryNavLink>
                )
            })}
        </>
    )
}
export default Post

Post.js 파일을 위와 같이 수정하자! 

 // 필터링된 목록으로 렌더링하기
const postsFiltered = posts
                        .filter( post => {
                            const filter = searchParams.get('filter')
                            if(!filter) return true;
                            const title = post.title.toLowerCase()
                            return title.includes(filter.toLowerCase())
                        })
const post = postsFiltered[params.postId] // 필터링된 목록에서 특정 포스트 선택

위와 같이 블로그 포스트 전체목록에서 URL 쿼리스트링인 filter 값으로 원하는 포스트만 걸러내는 코드를 따로 추출하였다. 그런 다음 필터링된 목록에서 postId 를 이용하여 특정 포스트를 선택한다. 이렇게 하면 아래와 같이 제대로 동작하게 된다.

필터링된 목록에서 특정 포스트를 선택한 화면

 

728x90