Dynamic하게 아이템을 띄우기
JSX 내에서 JS 코드를 실행하려면 { } 안에 적어주면 가능했다. 기존에 이미 작업한 코드를 한번 더 복습해보자!
{props.data.map((data) => (
<ExpenseItem
title={data.title}
amount={data.amount}
date={data.date}
/>
))}
Stateful List
새로운 아이템이 추가될 때마다 UI가 업데이트되도록 App.js에서 state를 추가해주자. 기존에 있던 데이터를 컴포넌트 밖으로 빼고 새로운 expense를 추가할 때마다 state를 업데이트하도록 수정하였다.
import { useState } from "react";
import Expenses from "./components/Expenses/Expenses";
import NewExpense from "./components/NewExpense/NewExpense";
const DUMMY_EXPENSES = [
{
id: "e1",
title: "Toilet Paper",
amount: 94.12,
date: new Date(2020, 7, 14),
},
{ id: "e2", title: "New TV", amount: 799.49, date: new Date(2021, 2, 12) },
{
id: "e3",
title: "Car Insurance",
amount: 294.67,
date: new Date(2021, 2, 28),
},
{
id: "e4",
title: "New Desk (Wooden)",
amount: 450,
date: new Date(2021, 5, 12),
},
];
const App = () => {
const [expenses, setExpenses] = useState(DUMMY_EXPENSES);
const addExpenseHandler = (expense) => {
setExpenses((prevExpenses) => {
return [expense, ...prevExpenses];
});
};
return (
<div>
<NewExpense onAddExpense={addExpenseHandler} />
<Expenses data={expenses} />
</div>
);
};
export default App;
그나저나 계속 에러가 나고 있다. 이것부터 좀 해결해보자.
Key란?
현재 코드를 개발자도구에서 살펴보면 새로운 아이템이 추가될 때마다 맨 아래에 실제 코드가 추가되는 것을 볼 수 있다. 하지만 실제로 새로운 아이템은 상단에 추가되고 있다. 리액트는 현재 리스트의 아이템들이 모두 비슷하게 생겼기 때문에 단지 array의 변경만 받아와 새로운 아이템을 추가한 뒤 모든 아이템을 훑어보며 array의 변경사항을 반영한다. 즉, 비효율적으로 작동중이다. 퍼포먼스 문제 뿐만 아니라 각 아이템 별로 state를 갖고 있는 경우 state끼리 잘못된 업데이트가 발생할 수도 있다.
따라서 리액트에게 각각의 아이템을 구분할 수 있도록 key라는 prop을 주자. 마침 우리는 각각을 구분할 수 있는 고유한 값으로 id를 갖고 있다.
{props.data.map((data) => (
<ExpenseItem
key={data.id}
title={data.title}
amount={data.amount}
date={data.date}
/>
))}
만약 리액트에서 리스트의 아이템을 mapping out할 때면, 꼭 key prop을 부여해주도록 하자.
Filter 기능 구현하기
유저가 선택한 연도의 데이터만 보이게 하자. 어려울 것 없이, 기존에 가지고 있는 배열에서 조건에 일치하는 것들만 골라내면 된다! filter() 함수를 사용한다.
import { useState } from "react";
import Card from "../UI/Card";
import ExpenseItem from "./ExpenseItem";
import "./Expenses.css";
import ExpensesFilter from "./ExpensesFilter";
const Expenses = (props) => {
const [filteredYear, setFilteredYear] = useState("2022");
const enteredYearHandler = (enteredYear) => {
setFilteredYear(enteredYear);
};
const filteredExpenses = props.data.filter(
(item) => item.date.getFullYear().toString() === filteredYear
);
return (
<div>
<Card className="expenses">
<ExpensesFilter
selected={filteredYear}
onEnteredYear={enteredYearHandler}
/>
{filteredExpenses.map((data) => (
<ExpenseItem
key={data.id}
title={data.title}
amount={data.amount}
date={data.date}
/>
))}
</Card>
</div>
);
};
export default Expenses;
Conditional Content
현재 구조는 아무런 데이터가 없으면 빈 공간만 출력되고 있다. 아무런 데이터가 없을 때는 "데이터가 없습니다"와 같이 다른 화면을 띄워주는 것이 보다 유저에게 친절한 화면일 것이다. 선택된 데이터의 길이에 따라 삼항연산자를 사용하여 조건부로 렌더링해보자.
{filteredExpenses.length === 0 ? (
<p>No expenses found.</p>
) : (
filteredExpenses.map((data) => (
<ExpenseItem
key={data.id}
title={data.title}
amount={data.amount}
date={data.date}
/>
))
)}
삼항연산자를 사용하면 코드가 약간 읽기 지저분해진다. 따라서 && 연산자를 사용해서 코드를 쪼개보자. 이정도 센스는 발휘해보자 :)
{filteredExpenses.length === 0 && <p>No expenses found.</p>}
{filteredExpenses.length > 0 &&
filteredExpenses.map((data) => (
<ExpenseItem
key={data.id}
title={data.title}
amount={data.amount}
date={data.date}
/>
))}
하지만 JSX에는 최대한 로직을 배제하고 간단하게 적어주는 것이 좋다고 하니, return문 밖으로 빼보자~!
import { useState } from "react";
import Card from "../UI/Card";
import ExpenseItem from "./ExpenseItem";
import "./Expenses.css";
import ExpensesFilter from "./ExpensesFilter";
const Expenses = (props) => {
const [filteredYear, setFilteredYear] = useState("2022");
const enteredYearHandler = (enteredYear) => {
setFilteredYear(enteredYear);
};
const filteredExpenses = props.data.filter(
(item) => item.date.getFullYear().toString() === filteredYear
);
let expensesContent = <p>No expenses found.</p>;
if (filteredExpenses.length > 0) {
expensesContent = filteredExpenses.map((data) => (
<ExpenseItem
key={data.id}
title={data.title}
amount={data.amount}
date={data.date}
/>
));
}
return (
<div>
<Card className="expenses">
<ExpensesFilter
selected={filteredYear}
onEnteredYear={enteredYearHandler}
/>
{expensesContent}
</Card>
</div>
);
};
export default Expenses;
Conditional Return Statements
조건에 따라 내용을 설정할 수도 있지만, 조건에 따라 JSX를 다르게 리턴할 수도 있다. 다르게 리턴해주는 부분만을 Expenses 컴포넌트에서 쪼개자. ExpensesList라는 이름의 js, css 파일을 새롭게 만들어주었다.
import ExpenseItem from "./ExpenseItem";
import "./ExpensesList.css";
const ExpensesList = (props) => {
if (props.items.length === 0) {
return <h2 className="expenses-list__fallback">Found no expenses.</h2>;
}
return (
<ul className="expenses-list">
{props.items.map((data) => (
<ExpenseItem
key={data.id}
title={data.title}
amount={data.amount}
date={data.date}
/>
))}
</ul>
);
};
export default ExpensesList;
import { useState } from "react";
import Card from "../UI/Card";
import ExpenseItem from "./ExpenseItem";
import "./Expenses.css";
import ExpensesFilter from "./ExpensesFilter";
import ExpensesList from "./ExpensesList";
const Expenses = (props) => {
const [filteredYear, setFilteredYear] = useState("2022");
const enteredYearHandler = (enteredYear) => {
setFilteredYear(enteredYear);
};
const filteredExpenses = props.data.filter(
(item) => item.date.getFullYear().toString() === filteredYear
);
return (
<div>
<Card className="expenses">
<ExpensesFilter
selected={filteredYear}
onEnteredYear={enteredYearHandler}
/>
<ExpensesList items={filteredExpenses} />
</Card>
</div>
);
};
export default Expenses;
보다 semantic한 코드를 위해 모든 아이템을 ul태그로 묶었고(ExpensesList.js), 각각의 아이템에게는 li태그를 추가했다. (ExpenseItem.js)
import Card from "../UI/Card";
import ExpenseDate from "./ExpenseDate";
import "./ExpenseItem.css";
const ExpenseItem = (props) => {
return (
<li>
<Card className="expense-item">
<ExpenseDate date={props.date} />
<div className="expense-item__description">
<h2>{props.title}</h2>
<div className="expense-item__price">${props.amount}</div>
</div>
</Card>
</li>
);
};
export default ExpenseItem;
조건부 렌더링 추가 연습하기
Form을 활성화할 수 있는 버튼을 새롭게 만들어보자. 화면 업데이트가 필요하므로 state를 새롭게 선언해주었다. showForm이라는 이름의 state가 참이면 form을 보여주고, 거짓이면 버튼만을 보여주도록 설정하였다.
또한 form 내의 cancel 버튼으로 form을 끌 수 있도록 setShowForm 함수를 props로 넘겨주었다.
import { useState } from "react";
import ExpenseForm from "./ExpenseForm";
import "./NewExpense.css";
const NewExpense = (props) => {
const [showForm, setShowForm] = useState(false);
const saveExpenseDataHandler = (enteredExpenseData) => {
const expenseData = {
...enteredExpenseData,
id: Math.random().toString(),
};
props.onAddExpense(expenseData);
};
const btnClickHandler = () => {
setShowForm((prevShowForm) => !prevShowForm);
};
return (
<div className="new-expense">
{!showForm && <button onClick={btnClickHandler}>Add New Expense</button>}
{showForm && (
<ExpenseForm
onSetShowForm={setShowForm}
onSaveExpenseData={saveExpenseDataHandler}
/>
)}
</div>
);
};
export default NewExpense;
import { useState } from "react";
import "./ExpenseForm.css";
const ExpenseForm = (props) => {
const [enteredTitle, setEnteredTitle] = useState("");
const [enteredAmount, setEnteredAmount] = useState("");
const [enteredDate, setEnteredDate] = useState("");
const titleChangeHandler = (event) => {
setEnteredTitle(event.target.value);
};
const amountChangeHandler = (event) => {
setEnteredAmount(event.target.value);
};
const dateChangeHandler = (event) => {
setEnteredDate(event.target.value);
};
const sumbitHandler = (event) => {
event.preventDefault();
const expenseData = {
title: enteredTitle,
amount: enteredAmount,
date: new Date(enteredDate),
};
setEnteredAmount("");
setEnteredDate("");
setEnteredTitle("");
props.onSaveExpenseData(expenseData);
props.onSetShowForm((prevShowForm) => !prevShowForm);
};
const cancelBtnClickHandler = () => {
props.onSetShowForm((prevShowForm) => !prevShowForm);
};
return (
<form onSubmit={sumbitHandler}>
<div className="new-expense__controls">
// ... 생략
</div>
<div className="new-expense__actions">
<button type="button" onClick={cancelBtnClickHandler}>
Cancel
</button>
<button type="submit">Add Expense</button>
</div>
</form>
);
};
export default ExpenseForm;
Chart 추가하기
각 월별로 얼마나 예산을 썼는지 보여주는 간단한 차트를 추가하고 이 Expense 프로젝트를 마무리하려고 한다. 차트와 관련된 항목을 묶어주기 위해 새로운 폴더 Chart를 추가했다.
차트를 보여주는 Chart.js, 실질적인 bar를 보여주는 ChartBar.js 두개의 js 파일과 각각 css 파일을 추가했다.
지금은 12개월을 보여줘야하는 것을 알기에 ChartBar 컴포넌트는 12개를 직접 만들어줘도 되지만, 그건 그렇게 효율적인 코딩방식은 아니어보인다. 따라서 Chart 컴포넌트는 부모로부터 어떤 데이터를 로딩해야될지 결정하는 dataPoints라는 배열을 전달받아 각각 value, label 값을 갖는 ChartBar 컴포넌트를 생성하려고 한다.
import "./Chart.css";
import ChartBar from "./ChartBar";
const Chart = (props) => {
return (
<div className="chart">
{props.dataPoints.map((dataPoint) => (
<ChartBar
key={dataPoint.label}
value={dataPoint.value}
label={dataPoint.label}
maxValue={null}
/>
))}
</div>
);
};
export default Chart;
maxValue값은 임의로 null값으로 설정해주었다.
Dynamic Style 추가하기
이제 ChartBar를 구현해야 하는데 이부분이 약간 복잡하다. 전달받은 value값과 maxValue값을 가지고 몇 %나 채워져있는지 계산하여 구현해야 하는데 이는 Math.round 함수를 통해 쉽게 구현할 수 있다.
import "./ChartBar.css";
const ChartBar = (props) => {
let barFillHeight = "0%";
if (props.maxValue > 0) {
barFillHeight = Math.round((props.value / props.maxValue) * 100) + "%";
}
return (
<div className="chart-bar">
<div className="chart-bar__inner">
<div className="chart-bar__fill"></div>
</div>
<div className="chart-bar__label">{props.label}</div>
</div>
);
};
export default ChartBar;
자 이제 barFillHeight 값으로 css 내의 높이를 설정해줘야 한다. css를 직접 건드리는 것은 style 속성을 이용하면 가능했다.
* 주의!
HTML 태그 내의 style 속성과 React의 style 속성은 약간 다르다. 일반적인 문법에서 문자열로 직접 적어주었다면, 리액트에서는 style 속성이 하나의 object로 작동한다. dynamic하게 값들을 넣어주기 위해 {} 를 사용하였고, style 속성이 object형이기 때문에 한번 더 내부에 {} 를 적어주었다. 헷갈리지 말자!
* key:value 형태로 전달되는 style prop 값들은 만약 키에 "-"가 들어가는 경우(ex. background-color) 문자열(ex.'background-color')로 넘겨주거나 camelCase 방식(ex. backgroundColor)으로 넘겨줘야한다.
// ... 생략
<div
className="chart-bar__fill"
style={{ height: barFillHeight }}
></div>
</div>
dataPoint 설정하러 가기
차트를 보여줄 수 있는 UI는 모두 설정해줬다. 이제 실제로 어떤 데이터를 차트로 보여줄 것인지 결정하는 ExpensesChart 컴포넌트를 생성했다. 해당 컴포넌트는 Chart 컴포넌트로 값을 넘겨줘야 하는데, 우리는 이 값을 object 여러 개를 배열로 넘겨줄 것이다.
const chartDataPoints = [
{label: 'Jan', value: 0},
{label: 'Feb', value: 0},
{label: 'Mar', value: 0},
{label: 'Apr', value: 0},
{label: 'May', value: 0},
{label: 'Jun', value: 0},
{label: 'Jul', value: 0},
{label: 'Aug', value: 0},
{label: 'Sep', value: 0},
{label: 'Oct', value: 0},
{label: 'Nov', value: 0},
{label: 'Dec', value: 0},
]
1월부터 12월까지 label, value 값을 기본값으로 설정해주었다. 이제 실제로 어떤 value를 넣어줘야할 지 filtered된 expense값들을 살펴보자. (이 데이터는 Expenses 컴포넌트에 filteredExpenses라는 값으로 존재한다.)
유저가 설정한 연도에 해당하는 모든 데이터를 다 읽어온 뒤 각각 해당하는 달에 value값을 더해줘야 한다. expense의 date값에서 month값만 가져오는 방법은 JS의 Date 자료형에서 기본적으로 제공하는 getMonth() 함수를 통해 가져올 수 있으며 마침 이 값은 1월이 인덱스값 0으로 리턴된다. 즉 위에서 설정한 chartDataPoints의 인덱스으로 month를 사용해줄 수 있다! 추가 가공할 필요가 없어 아주 좋다.
for (const expense of props.expenses){
const expenseMonth = expense.date.getMonth(); // Jan:0 -> same w/ dataPoints idx
chartDataPoints[expenseMonth].value += expense.amount;
}
이제 chartDataPoints는 유저가 필터링한 연도의 값들을 잘 월별로 갖고 있을 것이다. Chart 컴포넌트에 props로 넘겨주면 되겠다. 하지만 아직 작업이 전부 완료된 것은 아니다. Chart 컴포넌트에서 maxValue값을 추가로 계산하여 ChartBar 컴포넌트로 넘겨줘야 한다. maxValue값은 dataPoints의 각 월별 금액 중 가장 큰 값으로 설정해주면 되는데, Math.max() 함수를 사용하려고 한다. max() 함수는 인자로 최댓값을 계산하고자 하는 요소들을 콤마로 구분하여 받으므로 dataPoints 내 value값만을 올바른 포맷으로 넘겨주자.
import "./Chart.css";
import ChartBar from "./ChartBar";
const Chart = (props) => {
const dataPointValues = props.dataPoints.map((dataPoint) => dataPoint.value);
const totalMaxValue = Math.max(...dataPointValues);
return (
<div className="chart">
{props.dataPoints.map((dataPoint) => (
<ChartBar
key={dataPoint.label}
value={dataPoint.value}
label={dataPoint.label}
maxValue={totalMaxValue}
/>
))}
</div>
);
};
export default Chart;
이제 Expenses 컴포넌트에서 ExpensesChart 컴포넌트를 사용하는 것만 남았다!
import { useState } from "react";
import Card from "../UI/Card";
import ExpenseItem from "./ExpenseItem";
import "./Expenses.css";
import ExpensesChart from "./ExpensesChart";
import ExpensesFilter from "./ExpensesFilter";
import ExpensesList from "./ExpensesList";
const Expenses = (props) => {
const [filteredYear, setFilteredYear] = useState("2022");
const enteredYearHandler = (enteredYear) => {
setFilteredYear(enteredYear);
};
const filteredExpenses = props.data.filter(
(item) => item.date.getFullYear().toString() === filteredYear
);
return (
<div>
<Card className="expenses">
<ExpensesFilter
selected={filteredYear}
onEnteredYear={enteredYearHandler}
/>
<ExpensesChart expenses={filteredExpenses} />
<ExpensesList items={filteredExpenses} />
</Card>
</div>
);
};
export default Expenses;
이제 원하는 대로 잘 동작한다.
Bug Fix
현재 여러개의 값들을 추가해줄 때 amount값이 string으로 추가되는 문제가 있어, 강제로 number형으로 형변환해주도록 ExpenseForm.js의 submitHandler함수를 수정하였다.
const sumbitHandler = (event) => {
event.preventDefault();
const expenseData = {
title: enteredTitle,
amount: +enteredAmount,
date: new Date(enteredDate),
};
setEnteredAmount("");
setEnteredDate("");
setEnteredTitle("");
props.onSaveExpenseData(expenseData);
props.onSetShowForm((prevShowForm) => !prevShowForm);
};
마무리
간단한 프로젝트가 끝났다. 아직 개선점이 많다고 하니 얼른 강의를 마저 따라가면서 리액트 핵심기술들을 모두 익혀보자.
여기까지 작업한 코드를 올려두었다.
https://github.com/cheonyeji/react_recap_ch3/tree/chapter3
'React > 프로젝트' 카테고리의 다른 글
미니 프로젝트를 해보자 - To Do (0) | 2022.07.22 |
---|---|
Expense Tracker Project (2/3) - React State & Event 처리 (0) | 2022.07.21 |
미니 프로젝트를 만들어보자 - Expense Tracker (0) | 2022.07.20 |
React - 로그인, 로그아웃 여부에 따라 Header 조건부 렌더링하기(1) (0) | 2021.05.11 |