실습 과제 - React Twittler SPA
React Twittler SPA
이번 React Twittler SPA 스프린트에서는 기존 React Twittler Intro 에 React Router 기능을 담은 Twittler 로 발전시킵니다.
학습 목표
- npm 을 이용해 react-router-dom을 설치할 수 있다.
- 컴포넌트 단위로 Client-side routing 을 할 수 있다.
- react-router-dom 를 활용하여 Twittler SPA를 구현할 수 있다.
시작하기
Repository
js-sprint-react-twittler-spa 에서 과제를 fork 합니다. 터미널에서 git clone 명령어로 레포지토리를 클론 후 과제를 진행합니다.
npm script 소개
npm install을 통해 필요한 모듈을 설치합니다.
- npm run start : 실제 React Web App을 개발 모드로 브라우저에서 실행시켜볼 수 있습니다.
- npm run test : 기술 요구사항에 대한 유닛 테스트를 실행하고, 결과를 확인해 볼 수 있습니다. Bare Minimum Requirement는 모두 통과해야 합니다.
react-router 라이브러리 설치하기
과제를 시작하기에 앞서 우리에게 필요한 라이브러리를 먼저 설치하고 시작하겠습니다. React Router를 이용하기 위해 react-router-dom을 설치합니다.
npm install react-router-dom@^6.3.0
# 이번 과제에서는 버전 6를 사용합니다.
라우트 준비하기
React Twittler SPA에서 페이지를 표시하는 컴포넌트는 Tweets, About, MyPage 입니다. 기존 React Twittler Intro에서는 App.js 라는 파일 안에 모든 것들이 담겨있었습니다. 우리는 이를 보다 관리하기 편한 **파일 구조(File Structure)**를 가지기 위해 폴더별로 구분하게 됩니다.
File Structure
React Twittler SPA의 파일구조는 아래와 같습니다.
/
├── /Twittler React SPA
│ ├── README.md
│ ├── /public # create-react-app이 만들어낸 파일, yarn/npm start로 실행할 시에 쓰입니다
│ └── /src # React 컴포넌트가 들어가는 폴더
│ ├── static # dummyData가 들어가는 폴더
│ │ └── dummyData.js
│ ├── Pages # 페이지를 표시하는 컴포넌트가 들어가는 폴더
│ │ ├── About.css
│ │ ├── About.js
│ │ ├── MyPage.css
│ │ ├── MyPage.js
│ │ ├── Tweets.css
│ │ └── Tweets.js
│ ├── App.css
│ ├── App.js
│ ├── Footer.js
│ ├── index.js
│ └── Sidebar.js
├ package.json
└ .gitignore
이번 과제에서는 파일 구조(File Structure)가 미리 준비되어 있습니다. 이후에 스스로 만들어야 할 때를 위해 잘 살펴봅시다.
이제 import와 export 구문을 이용해서 각 폴더에 나누어진 페이지를 표시하는 컴포넌트들을 연결하는 과정을 배우게 됩니다.
연습용 dummy data 소개
이전 과제와 마찬가지로, src/Pages/MyPage.js, src/Pages/Tweets.js에서는 파일 최상단에 dummyTweets가 import되는 것을 확인하실 수 있습니다. 실제 유저의 데이터가 들어오기 전 개발 단계에서 유용합니다. 아래 트윗 객체로 구성된 배열을 static/dummyData.js에서 확인할 수 있습니다.
{
id: 1,
username: 'kimcoding',
picture: `https://randomuser.me/api/portraits/women/1.jpg`,
content: '모든 국민은 인간으로서의 ... ',
createdAt: '2022-02-24T16:17:47.000Z',
updatedAt: '2022-02-24T16:17:47.000Z'
}
parameter 형식 설명
Bare Minimum Requirement
지금까지 배운 내용을 모두 종합하여 React Twittler SPA를 완성합니다.
- 아래 최소 기능 요구 사항(Bare Minimum Requirement)을 구현합니다.
- npm run test 스크립트로 모든 테스트를 통과합니다.
React Router 설치
- react-router-dom 을 npm으로 설치해야 합니다.
상세 컴포넌트 구현하기
- App 루트 컴포넌트(App.js)
- import 를 이용하여 Tweets, MyPage, About 컴포넌트를 불러옵니다.
- Sidebar 메뉴 컴포넌트(Sidebar.js)
- Font Awesome을 활용하여 About 아이콘 <i className="far fa-question-circle"></i>을 넣어야 합니다.
- Font Awesome을 활용하여 MyPage 아이콘 <i className="far fa-user"></i>을 넣어야 합니다.
- Tweets 컴포넌트(Tweets.js)
- import 를 이용하여 Footer 컴포넌트를 연결합니다.
- dummyTweets의 길이만큼 트윗이 보여야 합니다
- MyPage 컴포넌트(MyPage.js)
- import 를 이용하여 Footer 컴포넌트를 연결합니다.
- kimcoding이 작성한 트윗만 보여야 합니다.
React Route 적용하기
- 각 메뉴를 눌렀을 때, 주소에 맞게 페이지 뷰가 구현되어야 합니다.
컴포넌트별 기술 요구사항
- App 루트 컴포넌트(App.js)
- <BrowserRouter>, <Routes>, <Route> 로 React Router 문법에 맞게 컴포넌트가 있어야 합니다.
- 주소에 따른 페이지를 <Route> 컴포넌트를 이용하여 구분 지어 줍니다.
- Tweets컴포넌트의 Route path는 "/"입니다.
- About 컴포넌트의 Route path는 "/about"입니다.
- MyPage 컴포넌트의 Route path는 "/mypage"입니다.
- Sidebar 메뉴 컴포넌트(Sidebar.js)
- <Link> 컴포넌트의 to 속성을 사용하여 SPA 내에서 페이지 전환에 따른 URL 업데이트를 진행해야 합니다.
- Tweets 컴포넌트의 Route path는 "/"입니다.
- About 컴포넌트의 Route path는 "/about"입니다.
- MyPage 컴포넌트의 Route path는 "/mypage"입니다.
- <Link> 컴포넌트의 to 속성을 사용하여 SPA 내에서 페이지 전환에 따른 URL 업데이트를 진행해야 합니다.
CSS 개선하기
Twittler 꾸미기
- 명확한 컴포넌트 표시를 위해 컴포넌트가 색으로 구분되어 있습니다. 좀 더 보기 좋은 Twittler를 만들 수 있게 CSS를 수정해 보세요.
- src/App.css 와 Pages, Components 폴더 안에는 컴포넌트별로 꼭 필요한 CSS가 일부 구현이 되어있습니다. 작성된 CSS에 맞추어 class 명을 수정하거나 HTML 엘리먼트의 구조를 바꿔보세요.
- src/App.css 와 Pages, Components 폴더 안의 CSS는 정답이 아닙니다. CSS에 더 많은 시간을 투자해서 좀 더 화려한 Twittler를 만들어봅시다!
튜토리얼 1
사이드바 구현
우리는 React Twittler Intro에서 Font Awesome을 이용해 사이드바에 들어갈 아이콘을 구현해 보았습니다. 페이지 전환을 위한 추가적인 메뉴 또한 동일하게 구현할 것입니다.
- 사이드바에 About,MyPage 에 들어갈 아이콘을 구현합니다.
- About 아이콘 키워드는 'question-circle', MyPage 아이콘 키워드는 'user' 입니다.
[그림] 메뉴를 추가한 Twittler 사이드바
튜토리얼 2
Tweets 컴포넌트와 MyPage 컴포넌트에 dummyData 넣기
Tweets 컴포넌트(Tweets.js)
React Twittler SPA의 메인 페이지는 Tweets 컴포넌트를 연결해둬야 합니다. React Twittler Intro 에서 더미데이터를 띄운 것처럼 트윗을 직접 화면에 띄워봅시다.
[그림] 트윗을 띄운 Tweets 컴포넌트
MyPage 컴포넌트(MyPage.js)
MyPage 컴포넌트에서는 트윗 작성자가 kimcoding 인 사람의 트윗 내용만 띄울 것입니다. ./static/dummyData.js 파일에는 dummyData가 있습니다. 우리는 여기서 filter 함수를 이용해 우리가 원하는 트윗만 가져올 수 있습니다.
아래 코드가 보이시나요? dummyTweets 를 기반으로 어떻게 username 이 kimcoding 인 사람의 트윗만 띄울 수 있을까요?
// TODO - filter 메소드를 이용하여 username이 kimcoding인 요소만 있는 배열을 filteredTweet에 할당합니다.
const filteredTweets = dummyTweets;
[그림] kimcoding이 작성한 트윗만 띄운 MyPage 컴포넌트
Advanced Challenge
- useNavigate 를 이용하여 뒤로가기 기능을 만들어보세요.
App.js
import React from 'react';
import './App.css';
import './global-style.css';
// TODO - react-router-dom을 설치 후, import 구문을 이용하여 BrowserRouter, Routes, Route 컴포넌트를 불러오세요.
import Sidebar from './Sidebar';
import Tweets from './Pages/Tweets';
// TODO - import문을 이용하여 MyPage, About 컴포넌트를 불러오세요.
import MyPage from './Pages/MyPage';
import About from './Pages/About';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const App = () => {
return (
<div>
{/* TODO - BrowserRouter 컴포넌트를 작성합니다. */}
<BrowserRouter>
<div className="App">
<main>
<Sidebar />
<section className="features">
{/* TODO - Routes와 Route 컴포넌트를 이용하여 경로(path)를 설정하고 Tweets, Mypage, About 컴포넌트를 연결합니다. */}
<Routes>
<Route path="/" element={<Tweets />} />
<Route path="/about" element={<About />} />
<Route path="/mypage" element={<MyPage />} />
</Routes>
</section>
</main>
</div>
</BrowserRouter>
</div>
);
};
// ! 아래 코드는 수정하지 않습니다.
export default App;
MyPage.js
import React from "react";
import { dummyTweets } from "../static/dummyData";
import "./MyPage.css";
// ! 위 코드는 수정하지 않습니다.
// TODO - import문을 이용하여 Footer 컴포넌트를 불러옵니다.
import Footer from "../Footer";
const MyPage = () => {
// TODO - filter 메소드를 이용하여 username이 kimcoding인 요소만 있는 배열을 filteredTweet에 할당합니다.
const filteredTweets = dummyTweets.filter(tweet => tweet.username === "kimcoding");
const currentUserPicture = filteredTweets[0].picture;
return (
<section className="myInfo">
<div className="myInfo__container">
<div className="myInfo__wrapper">
<div className="myInfo__profile">
<img src={currentUserPicture ? currentUserPicture : null} />
</div>
<div className="myInfo__detail">
<p className="myInfo__detailName">
{filteredTweets[0].user ? filteredTweets[0].user : null} Profile
</p>
<p>28 팔로워 100 팔로잉</p>
</div>
</div>
</div>
<ul className="tweets__mypage">
{/* TODO : dummyTweets중 kimcoding 이 작성한 트윗 메세지만 있어야 합니다. */}
{
filteredTweets.map((tweet) => (
<li className="tweet" key={tweet.id}>
<div className="tweet__profile">
<img src={tweet.picture} />
</div>
<div className="tweet__content">
<div className="tweet__userInfo">
<span className="tweet__username">{tweet.username}</span>
<span className="tweet__createdAt">{tweet.createdAt}</span>
</div>
<div className="tweet__message">{tweet.content}</div>
</div>
</li>
))
}
</ul>
{/* TODO : Footer 컴포넌트를 작성합니다. */}
<Footer />
</section>
);
};
export default MyPage;
Tweets.js
import React from 'react';
import { dummyTweets } from '../static/dummyData';
import './Tweets.css';
// ! 위 코드는 수정하지 않습니다.
// TODO - import문을 이용하여 Footer 컴포넌트를 불러오세요.
import Footer from '../Footer';
const Tweets = () => {
return (
<div>
<div className="tweetForm__container">
<div className="tweetForm__wrapper">
<div className="tweetForm__input">
<div className="tweetForm__inputWrapper">
<div className="tweetForm__count" role="status">
<span className="tweetForm__count__text">
{'total: ' + dummyTweets.length}
</span>
</div>
</div>
</div>
</div>
</div>
<ul className="tweets">
{dummyTweets.map((tweet) => {
return (
<li className="tweet" key={tweet.id}>
<div className="tweet__profile">
<img src={tweet.picture} />
</div>
<div className="tweet__content">
<div className="tweet__userInfo">
<span className="tweet__username">{tweet.username}</span>
<span className="tweet__createdAt">{tweet.createdAt}</span>
</div>
<div className="tweet__message">{tweet.content}</div>
</div>
</li>
)
})}
</ul>
{/* TODO - Footer 컴포넌트를 작성합니다. */}
<Footer />
</div>
);
};
export default Tweets;
App.test.js
import React from "react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/extend-expect";
import TestRenderer from "react-test-renderer";
import { access } from "fs/promises";
import { join } from "path";
import App from "../App";
import Sidebar from "../Sidebar";
import Footer from "../Footer";
import Tweets from "../Pages/Tweets";
import MyPage from "../Pages/MyPage";
import About from "../Pages/About";
import { dummyTweets } from "../static/dummyData";
let ReactRouterDom;
describe("App.js React Router 설치", () => {
test("react-router-dom 를 npm 으로 설치해야 합니다. (react-router-dom)", async () => {
let isReactRouterDomInstalled = false;
const defaultPath = join(process.cwd(), "node_modules", "react-router-dom");
try {
ReactRouterDom = await import("react-router-dom");
await access(join(defaultPath));
isReactRouterDomInstalled = true;
} catch (e) {
console.log("react-router-dom is not installed");
}
expect(isReactRouterDomInstalled).toBe(true);
});
});
describe("App.js React Router 컴포넌트 적용", () => {
test("React Router 컴포넌트로 Routes와 Route 컴포넌트가 있어야 합니다.", () => {
const { Routes, Route } = ReactRouterDom;
const appInstance = TestRenderer.create(<App />).root;
const routesInstance = appInstance.findByType(Routes);
/*
react-router-dom v6에서는 Route를 findByType으로 탐색할 수가 없음
routesInstance.props.children로 접근.
*/
expect(appInstance.findByType(Routes).type).toBe(Routes);
const routesChildren = routesInstance.props.children;
expect(routesChildren.find(route => route.type === Route)).toBeTruthy();
expect(routesChildren.filter(route => route.type === Route)).toHaveLength(3);
});
test("App 컴포넌트의 후손 컴포넌트로 Sidebar 컴포넌트가 있어야 합니다.", () => {
const appInstance = TestRenderer.create(<App />).root;
expect(appInstance.findByType(Sidebar).type).toBe(Sidebar);
});
describe("주소에 따른 페이지 뷰 구현을 위해", () => {
test('Route path가 "/" 인 Tweets 컴포넌트가 있어야 합니다.', () => {
const { Routes } = ReactRouterDom;
const rootPath = "/";
const appInstance = TestRenderer.create(<App />).root;
const RoutesInstance = appInstance.findByType(Routes);
const tweetsInstance = RoutesInstance.props.children.find(
(instance) => instance.props.path && instance.props.path === rootPath
);
expect(tweetsInstance).toBeTruthy();
expect(tweetsInstance.props.path).toBe(rootPath);
});
test('Route path가 "/about" 인 About 컴포넌트가 있어야 합니다.', () => {
const { Routes } = ReactRouterDom;
const aboutPath = "/about";
const appInstance = TestRenderer.create(<App />).root;
const RoutesInstance = appInstance.findByType(Routes);
const aboutInstance = RoutesInstance.props.children.find(
(instance) => instance.props.path && instance.props.path === aboutPath
);
expect(aboutInstance).toBeTruthy();
expect(aboutInstance.props.path).toBe(aboutPath);
});
test('Route path가 "/mypage" 인 MyPage 컴포넌트가 있어야 합니다.', () => {
const { Routes } = ReactRouterDom;
const myPagePath = "/mypage";
const appInstance = TestRenderer.create(<App />).root;
const RoutesInstance = appInstance.findByType(Routes);
const myPageInstance = RoutesInstance.props.children.find(
(instance) => instance.props.path && instance.props.path === myPagePath
);
expect(myPageInstance).toBeTruthy();
expect(myPageInstance.props.path).toBe(myPagePath);
});
});
});
describe("Sidebar.js 사이드바 구현", () => {
test('Font Awesome을 이용한 Tweets 메뉴 아이콘이 있어야 합니다.(className : ".far .fa-comment-dots")', () => {
const { container } = render(<App />);
const commentIcon = container.querySelector(".far.fa-comment-dots");
expect(commentIcon).not.toBeNull();
expect(commentIcon).toBeInstanceOf(HTMLElement);
expect(commentIcon.tagName).toBe("I");
});
test('Font Awesome을 이용한 About 메뉴 아이콘이 있어야 합니다.(className : ".far .fa-question-circle")', () => {
const { container } = render(<App />);
const aboutIcon = container.querySelector(".far.fa-question-circle");
expect(aboutIcon).not.toBeNull();
expect(aboutIcon).toBeInstanceOf(HTMLElement);
expect(aboutIcon.tagName).toBe("I");
});
test('Font Awesome을 이용한 MyPage 메뉴 아이콘이 있어야 합니다.(className : ".far .fa-user")', () => {
const { container } = render(<App />);
const mypageIcon = container.querySelector(".far.fa-user");
expect(mypageIcon).not.toBeNull();
expect(mypageIcon).toBeInstanceOf(HTMLElement);
expect(mypageIcon.tagName).toBe("I");
});
describe("Sidebar 컴포넌트에는", () => {
test("React Router의 Link 컴포넌트가 3개 있어야 합니다.", () => {
const { BrowserRouter, Link } = ReactRouterDom;
const sidebarInstance = TestRenderer.create(
<BrowserRouter>
<Sidebar />
</BrowserRouter>
).root;
expect(sidebarInstance.findAllByType(Link)).toHaveLength(3);
});
test('Tweets 아이콘의 Link 컴포넌트는 "/" 로 연결되야 합니다.', () => {
const { container } = render(<App />);
const linkToAttr = container.querySelectorAll("a");
expect(linkToAttr[0]).toHaveAttribute("href", "/");
});
test('About 아이콘의 Link 컴포넌트는 "/about" 로 연결되야 합니다.', () => {
const { container } = render(<App />);
const linkToAttr = container.querySelectorAll("a");
expect(linkToAttr[1]).toHaveAttribute("href", "/about");
});
test('MyPage 아이콘의 Link 컴포넌트는 "/mypage" 로 연결되야 합니다.', () => {
const { container } = render(<App />);
const linkToAttr = container.querySelectorAll("a");
expect(linkToAttr[2]).toHaveAttribute("href", "/mypage");
});
});
});
describe("MyPage.js Components", () => {
test("MyPage 컴포넌트의 자식 컴포넌트로 Footer 컴포넌트가 있어야 합니다.", () => {
const mypageInstance = TestRenderer.create(
<MyPage dummyTweets={[]} />
).root;
expect(mypageInstance.findByType(Footer).type).toBe(Footer);
});
describe("MyPage 데이터 렌더링 테스트", () => {
test("kimcoding이 작성한 트윗만 보여야 합니다.", () => {
const { queryByText } = render(<MyPage dummyTweets={[]} />);
expect(queryByText("kimcoding")).toHaveTextContent(
dummyTweets[0].username
);
});
});
});
describe("Tweets.js Components", () => {
test("Tweets 컴포넌트의 후손 컴포넌트로 Footer 컴포넌트가 있어야 합니다.", () => {
const tweetsInstance = TestRenderer.create(
<Tweets dummyTweets={[]} />
).root;
expect(tweetsInstance.findByType(Footer).type).toBe(Footer);
});
describe("Tweets 데이터 렌더링 테스트", () => {
test("dummyTweets의 길이 만큼 트윗이 보여야 합니다.", () => {
const { queryByText } = render(<Tweets dummyTweets={[]} />);
expect(queryByText("kimcoding")).toHaveTextContent(
dummyTweets[0].username
);
expect(queryByText("parkhacker")).toHaveTextContent(
dummyTweets[1].username
);
expect(queryByText("leedesign")).toHaveTextContent(
dummyTweets[2].username
);
expect(queryByText("songfront")).toHaveTextContent(
dummyTweets[3].username
);
expect(queryByText("choiback")).toHaveTextContent(
dummyTweets[4].username
);
});
});
});
describe("React Router로 SPA 구현하기", () => {
test('처음 접속 시, URL path가 "/" 이여야 합니다.', async () => {
const { Routes } = ReactRouterDom;
const rootPath = "/";
const appInstance = TestRenderer.create(<App />).root;
const routesInstance = appInstance.findByType(Routes);
const hasHomePath = routesInstance.props.children.some(
(prop) => prop.props.path === "/"
);
expect(hasHomePath).toBe(true);
expect(location.pathname).toBe(rootPath);
});
test("About 메뉴를 누르면 URL path가 /about으로 라우트 되어야 합니다.", async () => {
const { Routes } = ReactRouterDom;
const aboutPath = "/about";
const { container } = render(<App />);
const aboutIcon = container.querySelector(".far.fa-question-circle");
userEvent.click(aboutIcon);
const appInstance = TestRenderer.create(<App />).root;
const routesInstance = appInstance.findByType(Routes);
const aboutElement = routesInstance.props.children.find(
(prop) => prop.props.element.type === About
);
expect(location.pathname).toBe(aboutPath);
expect(aboutElement.props.path).toBe(aboutPath);
});
test("MyPage 메뉴를 누르면 URL path가 /mypage로 라우트 되어야 합니다.", async () => {
const { Routes } = ReactRouterDom;
const myPagePath = "/mypage";
const { container } = render(<App />);
const mypageIcon = container.querySelector(".far.fa-user");
userEvent.click(mypageIcon);
const appInstance = TestRenderer.create(<App />).root;
const routesInstance = appInstance.findByType(Routes);
const myPageElement = routesInstance.props.children.find(
(prop) => prop.props.element.type === MyPage
);
expect(location.pathname).toBe(myPagePath);
expect(myPageElement.props.path).toBe(myPagePath);
});
});
Test 코드를 모두 통과하였습니다.
React State & Props
이전 유닛들에서는 React에서 컴포넌트 기반으로 화면을 구성하는 방법과, 라우팅을 통해 경로에 따라 다른 뷰를 보여주는 방법을 학습했습니다. 이제 웹에서 우리가 필요한 데이터를 다루는 방법을 알아볼 차례입니다. 이번 챕터에서는 React에서 데이터를 다루는 두 가지 방법인 State와 Props에 대해 학습합니다.
Before You Learn
다음 항목에 대해 잘 알고 있는지 스스로 점검해 보세요. 스스로 생각하기에 보충이 필요하다면, 다음 키워드를 복습하는 것을 추천합니다.
- JSX
- React SPA
- React Router
학습 목표
- state, props의 개념에 대해서 이해하고, 실제 프로젝트에 바르게 적용할 수 있다.
- React 함수 컴포넌트(React Function Component)에서 state hook을 이용하여 state를 정의 및 변경할 수 있다.
- React 컴포넌트(React Component)에 props를 전달할 수 있다.
- 이벤트 핸들러 함수를 만들고 React에서 이용할 수 있다.
- 실제 웹 애플리케이션의 컴포넌트를 보고 어떤 데이터가 state이고 props에 적합한지 판단할 수 있다.
- 실제 웹 애플리케이션 개발 시 적합한 state와 props의 위치를 스스로 정할 수 있다.
- React의 단방향 데이터 흐름(One-way data flow)에 대해 자신의 언어로 설명할 수 있다.
- JSX 문법의 기본과 컴포넌트 기반 개발에 대해서 숙지한다.
- React Router DOM으로 React에서 SPA(Single-Page Application)을 구현할 수 있다.
- state hook을 이용하여, 컴포넌트에서 데이터를 변화시킬 수 있다.
- props를 이용하여, 부모 컴포넌트의 데이터를 자식 컴포넌트로 전달할 수 있다.
- 바람직한 컴포넌트 구조와 state와 props의 위치에 대해 고민한다.
Chapter1. React State & Props
Chapter1-1. Props
props의 특징
- 컴포넌트의 속성(property)을 의미합니다. 이전 State & Props Intro에서 잘 설명되었듯, props는 성별이나 이름처럼 변하지 않는 외부로부터 전달받은 값으로, 웹 애플리케이션에서 해당 컴포넌트가 가진 속성에 해당합니다.
- 부모 컴포넌트(상위 컴포넌트)로부터 전달받은 값입니다. React 컴포넌트는 JavaScript 함수와 클래스로, props를 함수의 전달인자(arguments)처럼 전달받아 이를 기반으로 화면에 어떻게 표시되는지를 기술하는 React 엘리먼트를 반환합니다. 따라서, 컴포넌트가 최초 렌더링 될 때 화면에 출력하고자 하는 데이터를 담은 초깃값으로 사용할 수 있습니다.
- 객체 형태입니다. props로 어떤 타입의 값도 넣어 전달할 수 있도록 props는 객체의 형태를 가집니다.
- props는 읽기 전용입니다. props는 성별이나 이름처럼 외부로부터 전달받아 변하지 않는 값입니다. 그래서 props는 함부로 변경될 수 없는 읽기 전용(read-only) 객체입니다. 함부로 변경되지 않아야 하기 때문입니다.
읽기 전용 객체가 아니라면 props를 전달받은 하위 컴포넌트 내에서 props를 직접 수정 시 props를 전달한 상위 컴포넌트의 값에 영향을 미칠 수 있게 됩니다. 즉, 개발자가 의도하지 않은 side effect가 생기게 되고 이는 React의 단방향, 하향식 데이터 흐름 원칙(React is all about one-way data flow down the component hierarchy)에 위배됩니다. 이 내용에 대해서는 Chapter1-5. React에서의 데이터 흐름에서 자세히 배웁니다.
How to use props
props를 사용하는 방법은 아래와 같이 3단계 순서로 나눌 수 있습니다.
- 하위 컴포넌트에 전달하고자 하는 값(data)과 속성을 정의한다.
- props를 이용하여 정의된 값과 속성을 전달한다.
- 전달받은 props를 렌더링한다.
위 단계에 맞추어 props를 사용하기 위해 우선 <Parent> 와 <Child> 라는 컴포넌트를 선언하고, <Parent> 컴포넌트 안에 <Child> 컴포넌트를 작성합니다.
function Parent() {
return (
<div className="parent">
<h1>I'm the parent</h1>
<Child />
</div>);
};
function Child() {
return (
<div className="child"></div>);
};
[코드] <Parent>, <Child> 컴포넌트 선언
컴포넌트를 만들었으니 이제 전달하고자 하는 속성을 정의해 봅시다. HTML에서 속성과 값을 할당하는 방법과 같습니다. 아래의 코드에서는 <a> 요소의 href 속성에 "www.javascript.com" 라는 값을 주었습니다.
<a href="www.javascript.com">Click me to visit javascript</a>
[코드] HTML 속성과 값 다루는 법
React에서 속성 및 값을 할당하는 방법도 이와 유사합니다. 다만, 전달하고자 하는 값을 중괄호 {}를 이용하여 감싸주면 됩니다.
<Child attribute={value} />
[코드] React에서 JSX 속성 및 값을 할당하는 방법 1
위 방법을 이용하여 text라는 속성을 선언하고, 이 속성에 "I'm the eldest child"라는 문자열 값을 할당하여 <Child> 컴포넌트에 전달해 봅시다.
<Child text={"I'm the eldest child"} />
[코드] React에서 JSX 속성 및 값을 할당하는 방법 2
자, 이제 <Parent> 컴포넌트에서 전달한 "I'm the eldest child"라는 문자열을 <Child> 컴포넌트에서 받아 봅시다. 방법은 간단합니다. 함수에 인자를 전달하듯이 React 컴포넌트에 props를 전달하면 되고, 이 props가 필요한 모든 데이터를 가지고 오게 됩니다.
function Child(props) {
return (
<div className="child"></div>
);
};
[코드] Child 컴포넌트에서 props 매개변수 사용 예시
props를 전달받았으니, 마지막으로 이 props를 렌더링해 봅시다. props를 렌더링하려면 JSX 안에 직접 불러서 사용하면 됩니다. 다만, props는 객체라고 하였고, 이 객체의 { key : value }는 <Parent> 컴포넌트에서 정의한 { attribute : value }의 형태를 띠게 됩니다. 따라서 JavaScript에서 객체의 value에 접근할 때 dot notation을 사용하는 것과 동일하게 props의 value 또한 dot notation으로 접근할 수 있습니다. 아래와 같이 props.text를 JSX에 중괄호와 함께 작성하면 잘 작동합니다.
props를 전달받았으니, 마지막으로 이 props를 렌더링해 봅시다. props를 렌더링하려면 JSX 안에 직접 불러서 사용하면 됩니다. 다만, props는 객체라고 하였고, 이 객체의 {key : value}는 <Parent> 컴포넌트에서 정의한 {attribute : value}의 형태를 띠게 됩니다. 따라서 JavaScript에서 객체의 value에 접근할 때 dot notation을 사용하는 것과 동일하게 props의 value 또한 dot notation으로 접근할 수 있습니다. 아래와 같이 props.text를 JSX에 중괄호와 함께 작성하면 잘 작동합니다.
function Child(props) {
return (
<div className="child">
<p>{props.text}</p>
</div>
);
};
[코드] <Child> 컴포넌트에서 props.txt 렌더링 예시
Chapter1-2. State
애플리케이션의 "상태"
State & Props 에서 state는 Toggle Switch나 Counter처럼 컴포넌트 내부에서 변할 수 있는 값이라고 하였습니다. 실제 애플리케이션에서는 무엇이 "상태"라고 할 수 있을까요? 쇼핑몰 장바구니를 예로 들어보겠습니다. 사용자는 구매할 물건과 당장은 구매하지 않을 물건을 체크박스에 체크하여 구분 짓습니다. 이를 장바구니 내에서의 상태로 구분해 본다면 check 된 상태와 check 되지 않은 상태입니다.
[그림] 장바구니 내에서의 상태
체크박스를 코드로 구현해 보면 아래와 같습니다. 아래 예시에서는 단순히 체크된 상태에 따라 보이는 글씨가 달라지지만, 이를 쇼핑몰에 적용하면 체크 여부에 따라 구매할 물건의 개수나 구매 금액이 변경되고, 이에 따라 사용자의 화면 또한 달라집니다. 이처럼 컴포넌트 내에서 변할 수 있는 값, 즉 상태는 React state로 다뤄야 합니다.
Open Sandbox 버튼을 클릭하면 큰 화면에서 확인할 수 있습니다.
State hook, useState
useState 사용법
React에서는 state 를 다루는 방법 중 하나로 useState 라는 특별한 함수를 제공합니다. 이 함수의 사용 방법과 작동 방식을 위의 체크박스 예로 들어 살펴보겠습니다.
- useState 를 이용하기 위해서는 React로부터 useState 를 불러와야 합니다. import 키워드로 useState 를 불러옵시다. 예제에서는 이미 작성되어 있습니다.
import { useState } from "react";
- 이후 useState 를 컴포넌트 안에서 호출해 줍니다. useState 를 호출한다는 것은 "state" 라는 변수를 선언하는 것과 같으며, 이 변수의 이름은 아무 이름으로 지어도 됩니다. 일반적인 변수는 함수가 끝날 때 사라지지만, state 변수는 React에 의해 함수가 끝나도 사라지지 않습니다.
- 문법적으로 보면 아래 예시의 isChecked, setIsChecked 는 useState 의 리턴값을 구조 분해 할당한 변수입니다.
function CheckboxExample() {
// 새로운 state 변수를 선언하고, 여기서는 이것을 isChecked 라 부르겠습니다.
const [isChecked, setIsChecked] = useState(false);
}
function CheckboxExample() {
// 1번 코드를 풀어쓰면
const [isChecked, setIsChecked] = useState(false); // 1번
//...
// 2번 코드와 같습니다.
const stateHookArray = useState(false); // 2번
const isChecked = stateHookArray[0];
const setIsChecked = stateHookArray[1];
}
- useState 를 호출하면 배열을 반환하는데, 배열의 0번째 요소는 현재 state 변수이고, 1번째 요소는 이 변수를 갱신할 수 있는 함수입니다. useState 의 인자로 넘겨주는 값은 state의 초깃값입니다.
const [state 저장 변수, state 갱신 함수] = useState(상태 초기 값);
- 수도 코드를 실제 코드로 작성해 봅시다.
function CheckboxExample() {
const [isChecked, setIsChecked] = useState(false);
// const [state 저장 변수, state 갱신 함수] = useState(state 초깃값);
- isChecked : state를 저장하는 변수
- setIsChecked : state isChecked 를 변경하는 함수
- useState : state hook
- false : state 초깃값
- 이 state 변수에 저장된 값을 사용하려면 JSX 엘리먼트 안에 직접 불러서 사용하면 됩니다. 여기서는 isChecked 가 boolean 값을 가지기 때문에 true or false 여부에 따라 다른 결과가 보이도록 삼항연산자를 사용합니다.
<span>{isChecked ? "Checked!!" : "Unchecked"}</span>
state 갱신하기
- state를 갱신하려면 state 변수를 갱신할 수 있는 함수인 setIsChecked 를 호출합니다.
- 이번 예시의 경우, input[type=checkbox] JSX 엘리먼트의 값 변경에 따라서 isChecked 가 변경되어야 합니다. 브라우저에서 checked로 값이 변경되었다면, React의 isChecked 도 변경되어야겠죠?
- input[type=checkbox] 엘리먼트의 값이 변경되면 onChange 이벤트가 발생하고, 이벤트 핸들러 함수가 작동되는 패턴은 DOM을 다뤄보시면서 익숙해지셨죠? 유효성 검사 스프린트에서 input[type=text] 엘리먼트의 값이 변경될 때, 이벤트 핸들러 함수를 작동시키는 패턴을 복습해 보세요.
- React도 마찬가지입니다. 사용자가 체크박스 값을 변경하면 onChange 이벤트가 이벤트 핸들러 함수인 handleChecked 를 호출하고, 이 함수가 setIsChecked 를 호출하게 됩니다. setIsChecked 가 호출되면 호출된 결과에 따라 isChecked 변수가 갱신되며, React는 새로운 isChecked 변수를 CheckboxExample 컴포넌트에 넘겨 해당 컴포넌트를 다시 렌더링 합니다.
function CheckboxExample() {
const [isChecked, setIsChecked] = useState(false);
const handleChecked = (event) => {
setIsChecked(event.target.checked);
};
return (
<div className="App">
<input type="checkbox" checked={isChecked} onChange={handleChecked} />
<span>{isChecked ? "Checked!!" : "Unchecked"}</span>
</div>
);
}
주의점
지금까지 state hook 사용법에 대해서 학습했습니다. state hook 사용 시 주의점에 대해서도 알아봅시다.
- React 컴포넌트는 state가 변경되면 새롭게 호출되고, 리렌더링 됩니다.
- 아래 예시의 체크박스를 눌러보시면, 누를 때마다 콘솔에 "rerendered?" 가 찍히는 것을 확인하실 수 있습니다. 즉, 컴포넌트의 상태가 변경될 때마다 새롭게 호출되고, 리렌더링 됩니다.
Chapter1-3. 이벤트 처리
React의 이벤트 처리(이벤트 핸들링; Event handling) 방식은 DOM의 이벤트 처리 방식과 유사합니다. 단, 몇 가지 문법 차이가 있습니다.
- React 에서 이벤트는 소문자 대신 카멜 케이스(camelCase) 를 사용합니다.
- JSX를 사용하여 문자열이 아닌 함수로 이벤트 처리 함수(이벤트 핸들러; Event handler)를 전달합니다.
예를 들어 HTML에서 이벤트 처리 방식이 아래와 같다면,
<button onclick="handleEvent()">Event</button>
React의 이벤트 처리 방식은 아래와 같습니다.
<button onClick={handleEvent}>Event</button>
React 에서 이벤트를 처리하는 기본 방식은 위와 같습니다. 다음은 자주 사용되는 이벤트 처리에 대한 예시입니다.
onChange
<input> <textarea> <select> 와 같은 폼(Form) 엘리먼트는 사용자의 입력값을 제어하는 데 사용됩니다. React 에서는 이러한 변경될 수 있는 입력값을 일반적으로 컴포넌트의 state 로 관리하고 업데이트합니다. onChange 이벤트가 발생하면 e.target.value 를 통해 이벤트 객체에 담겨있는 input 값을 읽어올 수 있습니다. 컴포넌트 return 문 안의 input 태그에 value 와 onChange 를 넣어주었습니다. onChange 는 input 의 텍스트가 바뀔 때마다 발생하는 이벤트입니다. 이벤트가 발생하면 handleChange 함수가 작동하며, 이벤트 객체에 담긴 input 값을 setState 를 통해 새로운 state 로 갱신합니다.
function NameForm() {
const [name, setName] = useState("");
const handleChange = (e) => {
setName(e.target.value);
}
return (
<div>
<input type="text" value={name} onChange={handleChange}></input>
<h1>{name}</h1>
</div>)
};
onClick
onClick 이벤트는 말 그대로 사용자가 클릭이라는 행동을 하였을 때 발생하는 이벤트입니다. 버튼이나 <a> tag 를 통한 링크 이동 등과 같이 주로 사용자의 행동에 따라 애플리케이션이 반응해야 할 때 자주 사용하는 이벤트입니다. 그럼 위의 onChange 예시에 버튼을 추가하여 버튼 클릭 시 input tag 에 입력한 이름이 alert을 통해 알림 창이 팝업 되도록 코드를 추가해 보겠습니다.
function NameForm() {
const [name, setName] = useState("");
const handleChange = (e) => {
setName(e.target.value);
}
return (
<div>
<input type="text" value={name} onChange={handleChange}></input>
<button onClick={alert(name)}>Button</button>
<h1>{name}</h1>
</div>);
};
위와 같이 onClick 이벤트에 alert(name) 함수를 바로 호출하면 컴포넌트가 렌더링 될 때 함수 자체가 아닌 함수 호출의 결과가 onClick 에 적용됩니다. 때문에 버튼을 클릭할 때가 아닌, 컴포넌트가 렌더링 될 때에 alert 이 실행되고 따라서 그 결과인 undefined (함수는 리턴 값이 없을 때 undefined 를 반환합니다.) 가 onClick 에 적용되어 클릭했을 때 아무런 결과도 일어나지 않습니다. 따라서 onClick 이벤트에 함수를 전달할 때는 함수를 호출하는 것이 아니라 아래와 같이 리턴문 안에서 함수를 정의하거나 리턴문 외부에서 함수를 정의 후 이벤트에 함수 자체를 전달해야 합니다.
// 함수 정의하기
return (
<div>
...
<button onClick={() => alert(name)}>Button</button>
...
</div>);
};
// 함수 자체를 전달하기
const handleClick = () => {
alert(name);
};
return (
<div>
...
<button onClick={handleClick}>Button</button>
...
</div>);
};
위 예시를 종합하면 아래와 같습니다. 주석 처리 된 부분을 해제해 보면서 이벤트 처리 방법을 연습해 보세요.
Chapter1-4. Controlled Component
인터넷 이전에 사람들은 어떻게 정보를 저장하고 전달할 수 있었을까요? 종이에 글을 적어서 문서를 만들고, 이를 우편에 부쳐서 전달할 수 있었습니다. 이제는 트위터와 같은 SNS를 통해서 클릭 한 번으로 사진부터 동영상까지 보낼 수 있지만요.
우편 부칠 때 가장 중요한 정보는 무엇인가요? 보내는 사람, 받는 사람, 주소, 종이의 내용 등이 중요한 정보입니다. 이 내용은 바뀔 수 있나요? 우편의 종류에 따라 다르지만 바뀔 수 있습니다. 김코딩이 박해커에게 안부 인사를 편지로 하는 경우 보내는 사람은 김코딩, 받는 사람은 박해커가 되고 박해커가 송기획에게 제안서를 보내면 보내는 사람은 박해커, 받는 사람은 송기획이 됩니다.
[그림] 일반 우편 봉투
트윗도 마찬가지입니다. 다만, 트윗은 받는 사람이 정해져 있습니다. 팔로워가 받는 사람으로 정해져 있죠. 보내는 사람의 경우, 누가 작성하는가에 따라 변경될 수 있습니다. 트윗에도 우편 봉투가 있다면, 트윗 우편 봉투의 상태가 됩니다. 트윗 우편 봉투의 내용도 마찬가지로 변경될 수 있는 값, 상태가 됩니다.
[그림] 트윗 전송 폼 예시
우편 봉투에 해당하는 위 그림을 하나의 트윗 전송 폼 컴포넌트라고 합시다. state는 무엇이 되어야 하나요? "변경될 수 있는 값"인 보내는 사람(username)과 보낼 내용(tweet)이 state가 되어야 합니다.
React에서는 이렇게 상태에 해당하는 데이터를 state로 따로 관리하고 싶어 합니다. 이렇게 React가 state를 통제할 수 있는 컴포넌트를 Controlled Component라고 합니다. Hooks로 Controlled Component 구현에 대해서 더 자세히 알고 싶으시면 공식 문서의 해당 링크를 참고해 주세요.
어떻게 React가 state를 통제할 수 있을까요? input에 값 입력 시, state도 그때그때 바뀌면(onChange) 됩니다. 그리고 이 변경된 state와 input의 value 또한 같게 작성해야 합니다.
Chpater1-5. React 데이터 흐름