Dynamic하게 style 반영하기

inline으로 css를 반영하는 것은 기존 css 요소를 모두 덮어쓸 수 있을 뿐만 아니라, 코드의 중복이 일어날 확률이 높으므로 지양하는 것이 좋다. 즉 className에 접근하여 상태에 따라 'invalid' 라는 클래스명을 추가/제거해주면서 동적으로 css를 추가하는 것이 바람직할 것이다.

 

form-control 클래스명에 invalid가 추가되는 경우, 유저가 아무것도 입력하지 않았을 때 경고 처리를 해주기 위해 다음과 같이 CSS를 작성했다.

.form-control.invalid input {
  border-color: red;
  background: #ffd7d7;
}

.form-control.invalid label {
  color: red;
}

 

유저가 제대로 된 값을 입력하기 시작한 경우 다시 isValid state를 true로, 제출 버튼 클릭 시 아무것도 입력하지 않은 경우 isValid state를 false로 바꿔주는 로직을 추가했다. 또한 form-control 클래스명을 `` (백틱) 으로 전달하여 ${} 문법을 통해 내부에 JS 코드를 작성할 수 있도록 했다.

 

import React, { useState } from "react";

import Button from "../../UI/Button/Button";
import "./CourseInput.css";

const CourseInput = (props) => {
  const [enteredValue, setEnteredValue] = useState("");
  const [isValid, setIsValid] = useState(true);

  const goalInputChangeHandler = (event) => {
    if (event.target.value.trim().length !== 0) {
      setIsValid(true);
    }
    setEnteredValue(event.target.value);
  };

  const formSubmitHandler = (event) => {
    event.preventDefault();
    if (event.target.value.trim().length === 0) {
      setIsValid(false);
      return;
    }
    props.onAddGoal(enteredValue);
  };

  return (
    <form onSubmit={formSubmitHandler}>
      <div className={`form-control ${!isValid ? 'invalid' : ''}`}>
        <label>Course Goal</label>
        <input type="text" onChange={goalInputChangeHandler} />
      </div>
      <Button type="submit">Add Goal</Button>
    </form>
  );
};

export default CourseInput;

 

유저가 아무것도 입력하지 않거나, 스페이스바만 누른 채로 버튼을 클릭했을 때

 

Styled Components 사용하기

한 컴포넌트에만 적용할 수 있는, scoped styling을 적용하기 위해 사용해보자. css파일을 import해오는 방식은 얼마든지 다른 요소에 영향을 미칠 수 있으니까 말이다. 수많은 개발자들이 함께 작업하는 파일에서 실수로 클래스명이 겹치는 악몽은 직접 겪지 말자.

 

https://styled-components.com/

 

styled-components

Visual primitives for the component age. Use the best bits of ES6 and CSS to style your apps without stress 💅🏾

styled-components.com

 

위 페이지에서 보다 자세한 정보를 볼 수 있다. 일단은 우리의 프로젝트에 설치하자.

npm install --save styled-components

위 명령어를 통해 설치할 수 있다. 

 

기존에 버튼 컴포넌트를 관리하던 Button.js Button.css파일을 styled-components를 사용하여 하나의 파일로 합쳐보자.

기존 파일은 다음과 같다.

 

import React from 'react';

import './Button.css';

const Button = props => {
  return (
    <button type={props.type} className="button" onClick={props.onClick}>
      {props.children}
    </button>
  );
};

export default Button;
.button {
  font: inherit;
  padding: 0.5rem 1.5rem;
  border: 1px solid #8b005d;
  color: white;
  background: #8b005d;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.26);
  cursor: pointer;
}

.button:focus {
  outline: none;
}

.button:hover,
.button:active {
  background: #ac0e77;
  border-color: #ac0e77;
  box-shadow: 0 0 8px rgba(0, 0, 0, 0.26);
}

 

styled component는 styled.___ 문법으로 사용 가능하다. styled. 뒤에 올 수 있는 것들은 전부 HTML 태그들이다. styled component는 JSX element를 반환하며 해당 요소 안에 css를 적용하는 방법은 ``을 사용하여 그 안에 코드를 넣어주면 된다. 하지만 css selector가 필요 없으므로 생략하거나 추가로 작성하고자 하는 경우 & 연산자를 사용한다. 적용된 모습을 보면 다음과 같다.

import styled from "styled-components";

const Button = styled.button`
  font: inherit;
  padding: 0.5rem 1.5rem;
  border: 1px solid #8b005d;
  color: white;
  background: #8b005d;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.26);
  cursor: pointer;

  &:focus {
    outline: none;
  }

  &:hover,
  &:active {
    background: #ac0e77;
    border-color: #ac0e77;
    box-shadow: 0 0 8px rgba(0, 0, 0, 0.26);
  }
`;

export default Button;

 

코드를 바꾸더라도 아무런 문제 없이 프로젝트는 잘 작동한다. 그리고 잊지말자! styled가 반환하는 객체는 JSX element이므로 변수는 대문자로 시작해야 한다.

 

Styled components & Dynamic props

앞서 form-control 부분 역시 styled-components를 적용해보자. 기존 코드는 다음과 같다.

.form-control {
  margin: 0.5rem 0;
}

.form-control label {
  font-weight: bold;
  display: block;
  margin-bottom: 0.5rem;
}

.form-control input {
  display: block;
  width: 100%;
  border: 1px solid #ccc;
  font: inherit;
  line-height: 1.5rem;
  padding: 0 0.25rem;
}

.form-control input:focus {
  outline: none;
  background: #fad0ec;
  border-color: #8b005d;
}

.form-control.invalid input {
  border-color: red;
  background: #ffd7d7;
}

.form-control.invalid label {
  color: red;
}
import React, { useState } from "react";

import Button from "../../UI/Button/Button";
import "./CourseInput.css";

const CourseInput = (props) => {
  const [enteredValue, setEnteredValue] = useState("");
  const [isValid, setIsValid] = useState(true);

  const goalInputChangeHandler = (event) => {
    if (event.target.value.trim().length !== 0) {
      setIsValid(true);
    }
    setEnteredValue(event.target.value);
  };

  const formSubmitHandler = (event) => {
    event.preventDefault();
    if (enteredValue.trim().length === 0) {
      setIsValid(false);
      return;
    }
    props.onAddGoal(enteredValue);
  };

  return (
    <form onSubmit={formSubmitHandler}>
      <div className={`form-control ${!isValid ? "invalid" : ""}`}>
        <label>Course Goal</label>
        <input type="text" onChange={goalInputChangeHandler} />
      </div>
      <Button type="submit">Add Goal</Button>
    </form>
  );
};

export default CourseInput;

 

styled-components를 적용하면 다음과 같다. nested된 요소 역시 & 연산자를 사용하면 된다.

 

import React, { useState } from "react";
import styled from "styled-components";

import Button from "../../UI/Button/Button";
import "./CourseInput.css";

const FormControl = styled.div`
  margin: 0.5rem 0;

  & label {
    font-weight: bold;
    display: block;
    margin-bottom: 0.5rem;
  }

  & input {
    display: block;
    width: 100%;
    border: 1px solid #ccc;
    font: inherit;
    line-height: 1.5rem;
    padding: 0 0.25rem;
  }

  & input:focus {
    outline: none;
    background: #fad0ec;
    border-color: #8b005d;
  }

  &.invalid input {
    border-color: red;
    background: #ffd7d7;
  }

  &.invalid label {
    color: red;
  }
`;

const CourseInput = (props) => {
  const [enteredValue, setEnteredValue] = useState("");
  const [isValid, setIsValid] = useState(true);

  const goalInputChangeHandler = (event) => {
    if (event.target.value.trim().length !== 0) {
      setIsValid(true);
    }
    setEnteredValue(event.target.value);
  };

  const formSubmitHandler = (event) => {
    event.preventDefault();
    if (enteredValue.trim().length === 0) {
      setIsValid(false);
      return;
    }
    props.onAddGoal(enteredValue);
  };

  return (
    <form onSubmit={formSubmitHandler}>
      <FormControl>
        <label>Course Goal</label>
        <input type="text" onChange={goalInputChangeHandler} />
      </FormControl>
      <Button type="submit">Add Goal</Button>
    </form>
  );
};

export default CourseInput;

 

자 그러나 className을 dynamic하게 넘겨주던 부분이 사라졌다. 이 부분도 다시 완성해보자. 물론 form-control이라는 클래스명은 더 이상 적어줄 필요가 없으니 다음과 같이 적어주기만 하면 된다. styled component로 만든 요소 역시 JSX element이므로 className 속성을 사용할 수 있다. 

<FormControl className={!isValid && 'invalid'}>

 

이 방식이 조금 더 클래식한 방식이라면, props를 사용하여 바꿔줄 수도 있다. 이 방식이 조금 더 css상으로는 깔끔하게 보일 것이다. 

<FormControl isvalid={!isValid}>

위와 같이 FormControl 요소에 isvalid(boolean 형) props를 넘겨줄 수도 있다! 그러면 이제 우리는 props.isvalid값에 따라 css를 다르게 작용해줄 수 있다. 왜냐하면 div 뒤에 css를 적어줄 때 `` 을 사용했기 때문에 ${}를 사용하여 얼마든지 js 구문을 쓸 수 있다.

 

import React, { useState } from "react";
import styled from "styled-components";

import Button from "../../UI/Button/Button";
import "./CourseInput.css";

const FormControl = styled.div`
  margin: 0.5rem 0;

  & label {
    font-weight: bold;
    display: block;
    margin-bottom: 0.5rem;
    color: ${(props) => (props.isvalid ? "red" : "black")};
  }

  & input {
    display: block;
    width: 100%;
    border: 1px solid ${(props) => (props.isvalid ? "red" : "#ccc")};
    background: ${(props) => (props.isvalid ? "#ffd7d7" : "transparent")};
    font: inherit;
    line-height: 1.5rem;
    padding: 0 0.25rem;
  }

  & input:focus {
    outline: none;
    background: #fad0ec;
    border-color: #8b005d;
  }
`;

const CourseInput = (props) => {
  const [enteredValue, setEnteredValue] = useState("");
  const [isValid, setIsValid] = useState(true);

  const goalInputChangeHandler = (event) => {
    if (event.target.value.trim().length !== 0) {
      setIsValid(true);
    }
    setEnteredValue(event.target.value);
  };

  const formSubmitHandler = (event) => {
    event.preventDefault();
    if (enteredValue.trim().length === 0) {
      setIsValid(false);
      return;
    }
    props.onAddGoal(enteredValue);
  };

  return (
    <form onSubmit={formSubmitHandler}>
      <FormControl isvalid={!isValid}>
        <label>Course Goal</label>
        <input type="text" onChange={goalInputChangeHandler} />
      </FormControl>
      <Button type="submit">Add Goal</Button>
    </form>
  );
};

export default CourseInput;

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 warning이 계속 발생한다

 

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

 

GitHub - cheonyeji/react_recap_ch3

Contribute to cheonyeji/react_recap_ch3 development by creating an account on GitHub.

github.com

 

Event Listening & Event Handler

일반적인 JS 방식에서 해당 element를 찾아 addEventListener를 만들어줬다면, 리액트에서는 JSX 내에서 특별한 prop을 사용하여 이벤트를 들을 수 있다. on으로 시작하는 속성은 모두 이벤트와 관련된 요소이다. 

 

<button onClick={}>버튼</button>

위와 같이 onClick 속성에 넘겨줘야 하는 값은 함수의 이름이다. 

const clickHandler = () => console.log("clicked!);

흔히 할 수 있는 실수는 다음과 같다. 이 코드는 파싱되는 즉시 함수 실행이 되어버린다. 즉 JSX 코드가 읽히는 순간에 실행될 것이다.

<button onClick={clickHandler()}>버튼</button>

 

우리는 버튼을 클릭할 때에만 해당 함수를 실행하고 싶기 때문에 함수를 실행( () 붙이기) 하는 것이 아니라, 함수의 이름만 넘겨줘야 한다.

<button onClick={clickHandler}>버튼</button>

* 이벤트를 처리해주는 함수 이름은 ~Handler로 끝나는 편이 식별이 쉽다. 개인적으로 이 방식이 깔끔해서 나도 동일하게 작성하려 한다.

 

Component 함수는 어떻게 실행되는가?

이벤트에서 화면에 띄우는 변수를 바꾸더라도 화면은 바뀌지 않는다. 왜 바뀌지 않을까?

Component 자체가 하나의 함수임을 기억한다면 화면이 바뀌지 않는 것은 당연하다. JSX 코드를 리액트가 읽는 순간 그 안에 있는 컴포넌트 함수를 호출한다. 더이상 호출해야 하는 함수가 없을 때까지 리액트는 JSX 코드를 읽어가며 함수를 모두 호출할 것이다. 함수가 모두 호출된 후에는 해당 코드를 브라우저의 DOM instruction으로 번역하여 브라우저로 넘겨준다. 

리액트는 위와 같이 작동하며, 여기에서 유의할 점은 JSX 코드를 읽는 작업을 리액트가 반복하지 않는다는 것이다. 그렇다면 화면을 업데이트하고 싶을 때는 어떻게 해야할까? 

 

여기에서 state라는 리액트에서 사용하는 개념이 등장한다.

 

State 사용하기

특정 데이터가 바뀔 때 리액트에게 해당 부분을 다시 읽어오도록 알려줄 수 있는 속성이 바로 state이다. 이를 사용하려면 react 라이브러리에서 useState라는 요소를 import해야 한다.

 

use로 시작하는 요소들은 React Hook이다. react hook은 반드시 리액트 컴포넌트 함수 내에서만 사용이 되어야 한다. 컴포넌트 함수 내에 다른 함수로 중첩되어서는 안되며, 반드시 컴포넌트 함수 바로 안에서만 위치해야 한다. (예외사항이 있기는 하나 그 부분은 추후에 다루겠다)

 

useState함수는 초기값을 인자로 넘겨줄 수 있으며, state 변수와 그 변수를 수정할 수 있는 함수 2개를 배열로 리턴한다. 따라서 useState의 기본적인 사용 방식은 다음과 같다.

const [value, setValue] = useState(data);

 

state에 값을 할당할 때는 '=' 연산자를 이용하는 것이 아니라, setState 함수를 사용하여 값을 할당한 뒤 리액트에게 해당 값이 수정되었으니 해당 컴포넌트를 재실행하라고 알려준다. 즉, 업데이트가 가능하다는 것이다!

 

useState 뜯어보기

state가 컴포넌트 내에 정의되어 있고 해당 컴포넌트가 여러 번 호출된다면, 각각의 state는 component별로 고유하다. 어떤 컴포넌트의 state 값을 바꾼다고 다른 요소가 영향을 받지 않는다는 것이다. 

 

자 그러면 왜 계속하여 바뀌는 값인 state를 const형식으로 할당하였을까? state를 업데이트할 때 '=' 연산자를 사용하지 않고 set___ 함수를 사용하여 값을 업데이트하기 때문이다. useState로 전달된 초기값은 말 그대로 가장 처음 state를 초기화할 때만 사용되며 그 이후는 계속하여 업데이트된 최신 값만 가져온다. 

 

state와 이벤트를 함께 잘 사용한다면 얼마든지 UI를 업데이트할 수 있을 것이다.

 

Form Input 추가하기

유저로부터 title, amount, date를 받아올 수 있는 form을 추가하였다. 아직은 실제로 받아오는 데이터를 처리하지 못한다. 새로운 expense data기 때문에 NewExpense라는 폴더를 새로 만들어 추가하였다. 

import ExpenseForm from "./ExpenseForm";
import "./NewExpense.css";

const NewExpense = () => {
  return (
    <div className="new-expense">
      <ExpenseForm />
    </div>
  );
};

export default NewExpense;
import "./ExpenseForm.css";

const ExpenseForm = () => {
  return (
    <form>
      <div className="new-expense__controls">
        <div className="new-expense__control">
          <label>Title</label>
          <input type="text" />
        </div>
        <div className="new-expense__control">
          <label>Amount</label>
          <input type="number" min="0.01" step="0.01" />
        </div>
        <div className="new-expense__control">
          <label>Date</label>
          <input type="date" min="2019-01-01" max="2022-12-31" />
        </div>
      </div>
      <div className="new-expense__actions">
        <button type="submit">Add Expense</button>
      </div>
    </form>
  );
};

export default ExpenseForm;

 

위 코드를 App.js에 반영하였다.

import Expenses from "./components/Expenses/Expenses";
import NewExpense from "./components/NewExpense/NewExpense";

const App = () => {
  const expenses = [
    ... // 생략
  ];
  return (
    <div>
      <NewExpense />
      <Expenses data={expenses} />
    </div>
  );
};

export default App;

 

Listening to user input 

input에 들어오는 모든 입력을 처리하기 위해 onInput을 사용해도 괜찮으나, 어떤 input 타입에도 사용할 수 있도록 onChange prop을 사용하여 처리해보자. 

<input type="text" onChange={titleChangeHandler} />

 

위와 같이 onChange 함수가 실행될 때마다 titleChangeHandler 함수가 실행될 것이다. 그러면 유저가 입력한 input 값은 어떻게 받아올까? 이 부분은 일반적인 JS 방식과 동일하다. addEventListener 함수로 2개의 인자를 넘겨줄 때, 이벤트 명과 그 이벤트가 호출될 때마다 실행할 함수를 넘겨줬다. 두번째 함수는 자동으로 event 객체를 받아올 수 있었다! onChange로 전달하는 함수 역시 자동으로 event 객체를 받아올 수 있다.

const titleChangeHandler = (event) => {
	console.log(event.target.value);
}

event를 console.log 해보면 target 객체 안에 입력한 값들이 value 속성에 들어가고 있는 것을 볼 수 있다. 이제 유저가 입력한 값을 받아올 수 있게 되었다!

 

여러 개의 state 관리하기

이제 input값 별로 입력한 요소를 저장할 수 있도록 코드를 수정하자. 각 input별로 하나의 state를 갖도록 했다. 

import { useState } from "react";
import "./ExpenseForm.css";

const ExpenseForm = () => {
  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);
  };

  return (
    <form>
      <div className="new-expense__controls">
        <div className="new-expense__control">
          <label>Title</label>
          <input type="text" onChange={titleChangeHandler} />
        </div>
        <div className="new-expense__control">
          <label>Amount</label>
          <input
            type="number"
            min="0.01"
            step="0.01"
            onChange={amountChangeHandler}
          />
        </div>
        <div className="new-expense__control">
          <label>Date</label>
          <input
            type="date"
            min="2019-01-01"
            max="2022-12-31"
            onChange={dateChangeHandler}
          />
        </div>
      </div>
      <div className="new-expense__actions">
        <button type="submit">Add Expense</button>
      </div>
    </form>
  );
};

export default ExpenseForm;

* input값으로 받아오는 요소는 number이든 date이든 string으로 넘어온다. input값을 저장해줄 state를 초기화해줄 때는 빈 문자열로 초기화해줘도 문제가 없다.

 

하나의 state로 모든 input 데이터를 저장하기

title, amount, date를 모두 묶어서 object 형태로 저장하는 방법도 가능하다.

const ExpenseForm = () => {
  const [userInput, setUserInput] = useState({
    enteredTitle: "",
    enteredAmount: "",
    enteredDate: "",
  });

  const titleChangeHandler = (event) => {
    setUserInput({
        ...userInput,
        enteredTitle: event.target.value
    });
  };
  const amountChangeHandler = (event) => {
    setUserInput({
      ...userInput,
      enteredAmount: event.target.value,
    });
  };
  const dateChangeHandler = (event) => {
    setUserInput({
      ...userInput,
      enteredDate: event.target.value,
    });
  };

  return (
    ... // 생략
  );
};

export default ExpenseForm;

각각 개별의 state로 관리해도, 묶어서 하나의 object로 관리해도 아무런 문제가 없다. 개인의 취향이다! 하지만 현재 이 코드는 불완전하다. 왜일까?

 

Previous State에 기반하여 state 업데이트하기

위의 코드는 100% 안전하게 업데이트한다고 보장하지 못한다. 왜냐하면, 리액트는 state update를 스케쥴해두기 때문이다. set함수를 만나자마자 바로 실행하는 것이 아니다. 따라서 이론적으로 동시에 수많은 state를 업데이트하도록 스케쥴해두었다면 잘못된 state를 가져올 수도 있는 것이다. 따라서 가장 최신의 previous state값을 가져오도록 보장하는 방식은 다음과 같다. 

setUserInput((prevState) => {
	return { ...prevState, enteredTitle: event.target.value };
});

set함수로 익명의 arrow fn을 넘겨주면 그 인자로 가장 최신의 state값을 가져오는 것이 보장된다. 따라서 예전 state값에 기반하여 state를 업데이트해야하는 경우, 위와 같은 방식을 사용해야 한다. 

 

다음 단계로 넘어가기 전에, 일단은 여러 개의 state를 사용했던 방식으로 코드를 되돌리고 다시 진행하자.

 

Form Submission 처리하기

button type="submit" 요소에 onClick 이벤트를 주는 것 대신, submit 타입의 버튼이 form 내에서 클릭된 경우 form 태그에 onSumbit 이벤트를 처리하는 것이 조금 더 바람직하다. 

 

submit 이벤트가 실행되는 즉시 페이지가 새로고침되는 것을 볼 수 있다. JS 방식에서 사용했던 것과 동일하게 event의 기본적인 동작을 일어나지 않도록 하는 event.preventDefault() 코드를 추가하자. 그리고 유저의 input값을 받아오는 state 값들을 하나로 묶어주었다.

// .. 생략
const sumbitHandler = (event) => {
    event.preventDefault();

    const expenseData = {
      title: enteredTitle,
      amount: enteredAmount,
      date: new Date(enteredDate),
    };

    console.log(expenseData);
  };
  return (
    <form onSubmit={sumbitHandler}>
 // .. 생략

 

* 여기서 의문인 부분이 "submit되는 순간에 각 state들이 가장 최신의 값이라고 확신할 수 있는가?" 이다. 이 부분은 조금 더 고민해보자.

 

다만 form을 제출한 뒤에 아직 데이터들이 input에 그대로 남아있는 것을 확인할 수 있다. 이 또한 해결하러 가보자.

 

Two-way binding

input 값의 변화만 받아오는 것이 아니라, 새로운 value 값을 input에 넣어줄 수도 있을 것이다. (two-way binding)

submit된 순간에 state값을 모두 빈 문자열로 초기화해주고 input 태그의 속성 중 하나인 value 속성에 각각의 state값을 넣어준다면, submit되는 순간에 모든 input 태그가 초기화된 모습을 확인할 수 있다.

import { useState } from "react";
import "./ExpenseForm.css";

const ExpenseForm = () => {
  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("");
    console.log(expenseData);
  };
  return (
    <form onSubmit={sumbitHandler}>
      <div className="new-expense__controls">
        <div className="new-expense__control">
          <label>Title</label>
          <input
            type="text"
            value={enteredTitle}
            onChange={titleChangeHandler}
          />
        </div>
        <div className="new-expense__control">
          <label>Amount</label>
          <input
            type="number"
            min="0.01"
            step="0.01"
            value={enteredAmount}
            onChange={amountChangeHandler}
          />
        </div>
        <div className="new-expense__control">
          <label>Date</label>
          <input
            type="date"
            min="2019-01-01"
            max="2022-12-31"
            value={enteredDate}
            onChange={dateChangeHandler}
          />
        </div>
      </div>
      <div className="new-expense__actions">
        <button type="submit">Add Expense</button>
      </div>
    </form>
  );
};

export default ExpenseForm;

 

Child component -> Parent component 통신하기 (Bottom-up)

props를 사용하여 parent->child로 값을 전달하는 것은 해봤다. 그렇다면 반대는 어떻게 하면 될까?

 

onChange 함수를 사용하여 값을 호출했듯, 우리도 우리의 component가 custom 함수를 갖도록 설정할 수 있을 것이다. 부모 component에서 함수를 생성하여 props를 통해 넘겨주고, 자식 component가 props.함수명으로 해당 함수를 실행한다면 함수의 호출이 다른 component에서 일어나기 때문에 얼마든지 값을 넘겨줄 수 있을 것이다. 

 

ExpenseForm -> NewExpense로 값을 넘겨주자. (child->parent)

먼저 NewExpense에서 ExpenseForm 컴포넌트에 props로 함수를 넘겨주는 코드를 작성하였다.

import ExpenseForm from "./ExpenseForm";
import "./NewExpense.css";

const NewExpense = () => {
  const saveExpenseDataHandler = (enteredExpenseData) => {
    const expenseData = {
      ...enteredExpenseData,
      id: Math.random().toString(),
    };
    console.log(expenseData);
  };
  return (
    <div className="new-expense">
      <ExpenseForm onSaveExpenseData={saveExpenseDataHandler} />
    </div>
  );
};

export default NewExpense;

props의 명은 함수임을 한눈에 알 수 있게 on~으로 시작하였다.

 

이제 자식인 ExpenseForm 컴포넌트 내에서 부모로 값을 넘겨줘야 되는 순간(submit)에 props의 onSaveExpenseData 함수를 호출하여 넘겨줄 값을 인자로 넣어주면 되겠다!

 

import { useState } from "react";
import "./ExpenseForm.css";

const ExpenseForm = (props) => {
  // ... 생략

  const sumbitHandler = (event) => {
    event.preventDefault();

    const expenseData = {
      title: enteredTitle,
      amount: enteredAmount,
      date: new Date(enteredDate),
    };

    setEnteredAmount("");
    setEnteredDate("");
    setEnteredTitle("");

    props.onSaveExpenseData(expenseData);
  };
  return (
    // ... 생략
};

export default ExpenseForm;

 

submit을 해보면 부모에서 함수가 실행되어, 값이 잘 넘어온 것을 확인할 수 있다.

console.log가 실행된 위치가 부모 컴포넌트

 

똑같은 방식으로 NewExpense -> App 컴포넌트로 값을 전달하자. 이제 App 컴포넌트 내에 하드코딩된 expenses 배열에 전달받은 expenseData를 추가해주기만 하면 된다. 일단은 전달까지만 해보자.

 

// ... 생략
  const addExpenseHandler = (expense) => {
    console.log(expense);
  };
  return (
    <div>
      <NewExpense onAddExpense={addExpenseHandler} />
      <Expenses data={expenses} />
    </div>
  );
 };

export default App;
import ExpenseForm from "./ExpenseForm";
import "./NewExpense.css";

const NewExpense = (props) => {
  const saveExpenseDataHandler = (enteredExpenseData) => {
    const expenseData = {
      ...enteredExpenseData,
      id: Math.random().toString(),
    };
    props.onAddExpense(expenseData);
  };
  return (
    <div className="new-expense">
      <ExpenseForm onSaveExpenseData={saveExpenseDataHandler} />
    </div>
  );
};

export default NewExpense;

submit 버튼을 눌러보면 값이 App 컴포넌트까지 잘 넘어온 것을 확인할 수 있다 :)

App.js에서 실행된 console.log

부모 컴포넌트 <-> 자식 컴포넌트 통신은 매우매우매우 중요한 부분이다!!! 잊지말자! props를 통해 값을 넘겨주거나, props를 통해 함수의 포인터를 넘겨주어 통신을 할 수 있다. 

 

Lifting State up

현재 component 구조를 간단하게 표현해보면 다음과 같다.

component tree

데이터가 생성되는 컴포넌트와 데이터가 실제로 사용되는 컴포넌트가 다르다. 두 컴포넌트는 직접적인 연결이 되어있지 않으므로, 가장 최소한으로 연결된 부모를 통해 데이터를 주고 받을 것이다. 

 

filter 기능 추가하기

유저가 선택한 연도의 데이터만 보여줄 수 있도록 필터 기능을 추가해보려 한다. 아직 UI 상으로 보여지는 것까지는 구현하지 않고, 실제로 선택된 데이터만 컴포넌트를 통해 넘어오는 것을 연습하자.

 

Expenses 컴포넌트에 ExpensesFilter 컴포넌트를 추가했다. 해당 컴포넌트는 단순히 유저가 드롭다운방식을 통해 원하는 연도를 select 할 수 있게 했다.

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);
  };
  return (
    <div>
      <Card className="expenses">
        <ExpensesFilter
          selected={filteredYear}
          onEnteredYear={enteredYearHandler}
        />
        {props.data.map((data) => (
          <ExpenseItem
            title={data.title}
            amount={data.amount}
            date={data.date}
          />
        ))}
      </Card>
    </div>
  );
};

export default Expenses;
import "./ExpensesFilter.css";

const ExpensesFilter = (props) => {
  const selectChangeHandler = (event) => {
    props.onEnteredYear(event.target.value);
  };
  return (
    <div className="expenses-filter">
      <div className="expenses-filter__control">
        <label>Filter by year</label>
        <select value={props.selected} onChange={selectChangeHandler}>
          <option value="2022">2022</option>
          <option value="2021">2021</option>
          <option value="2020">2020</option>
          <option value="2019">2019</option>
        </select>
      </div>
    </div>
  );
};

export default ExpensesFilter;

 

Expenses 컴포넌트가 filter된 year에 의해 바뀌어야 하므로 해당 컴포넌트에 선택된 year값을 저장할 수 있는 state를 만들어주었다. 그리고 초기값을 ExpensesFilter로 props를 통해 넘겨 select 태그에 반영되도록 하였다. 

 

Stateful vs Stateless component

state를 관리하는 컴포넌트를 Stateful 컴포넌트라 칭한다. 몇개의 컴포넌트만 state를 관리할 것이고 나머지 Stateless 컴포넌트로 그 값들을 넘겨줄 것이다. 

 

 

여기까지 정리한 부분을 깃허브에 업로드해두었다.

https://github.com/cheonyeji/react_recap_ch3/tree/chapter2

 

GitHub - cheonyeji/react_recap_ch3

Contribute to cheonyeji/react_recap_ch3 development by creating an account on GitHub.

github.com

 

React Basics 게시물에서 이어지는 내용이다.

 

실제로 미니 프로젝트를 하나 만들어보자!

App.js, index.js, index.css가 들어있는 src 폴더 내에 components라는 이름으로 모든 컴포넌트를 저장할 폴더를 만들었다. App.js는 가장 최상단에 위치한 요소(root)이며, 해당 App.js 안에 여러 컴포넌트들이 중첩될 것이니까 폴더 depth를 달리 주었다.

 

리액트에서는 각 컴포넌트의 파일명을 대문자로 시작하며, camelcase 방식을 따르는 것이 보편적이라고 한다. 따라하자! 컴포넌트가 무엇인지 App.js를 통해 살펴보니, 별 거 없다. 하나의 JS 함수다! 그리고 각 컴포넌트는 일반 html 태그와 구분하기 위해 무조건 대문자로 시작해야 한다. 

function ExpenseItem() {
  return <h2>Expense item!</h2>;
}

export default ExpenseItem;

이 요소를 루트 컴포넌트, App.js에 추가한다.

import ExpenseItem from "./components/ExpenseItem";

function App() {
  return (
    <div>
      <h2>Let's get started!</h2>
      <ExpenseItem />
    </div>
  );
}

export default App;

 

리액트에서 주의해야할 점은 리턴하는 요소가 하나의 root 요소여야 한다는 것이다. 나란히 나란히 div 태그를 붙여서 리턴하면 vscode가 엄청나게 눈치를 줄 것이다. ExpenseItem 컴포넌트에 기본적으로 들어가야 될 요소들을 넣어보자.

function ExpenseItem() {
  return (
    <div>
      <div>March 7th 2022</div>
      <div>
        <h2>Birthday Cake</h2>
        <div>$10.7</div>
      </div>
    </div>
  );
}

export default ExpenseItem;

위 코드를 저장하면 아주,,, 못생긴 화면이 반겨준다. 자, Styling 하러 가보자!

 

React Styling

리액트에서 CSS 방식은 크게 다르지 않다. 그냥 CSS 파일 만들어서 필요한 내용들 작성해주면 된다. 그리고 css 파일을 import해주면 끝이다! 일반적인 JS에서 CSS를 적용해줄 때 class 속성을 부여했지만, JSX 문법에서는 className이라는 속성을 사용하여 클래스 명을 부여한다. 

 

import "./ExpenseItem.css";

function ExpenseItem() {
  return (
    <div className="expense-item">
      <div>March 7th 2022</div>
      <div className="expense-item__description">
        <h2>Birthday Cake</h2>
        <div className="expense-item__price">$10.7</div>
      </div>
    </div>
  );
}

export default ExpenseItem;

* css 작업은 일단 넘어가겠다..! 일단은 리액트 문법에 익숙해지는 걸 일순위로

 

Dynamic Data 표현하기

현재 방식은 ExpenseItem이라는 컴포넌트가 재사용성이 좋다고는 결코 말할 수 없다. 매번 새로운 아이템이 생길 때마다 하드코딩을 해야되는 상황이다. 그러면, dynamic하게 data가 들어오는 경우 어떻게 컴포넌트로 넘길 수 있을까? 어떻게하면 컴포넌트를 원 목적대로 재사용이 가능하도록 작성할 수 있을까? 

 

하드코딩된 데이터를 모두 변수에 넣어, 컴포넌트 함수는 단순히 전달받은 데이터를 띄우는 blueprint 역할을 하게 하면 될 것이다. 다음과 같이 말이다.

import "./ExpenseItem.css";

function ExpenseItem() {
  const expenseData = new Date(2022, 2, 7);
  const expenseTitle = "Birthday Cake";
  const expenseAmount = 10.7;

  return (
    <div className="expense-item">
      <div>{expenseData.toISOString()}</div>
      <div className="expense-item__description">
        <h2>{expenseTitle}</h2>
        <div className="expense-item__price">${expenseAmount}</div>
      </div>
    </div>
  );
}

export default ExpenseItem;

 

하지만 여전히, 화면에 띄우고자 하는 원본 데이터 자체가 컴포넌트 안에 들어있는 상태이다. 즉, 재사용성은 보장되지 않은 상태이다. 그러면 컴포넌트에 데이터를 전달하려면 어떻게 해야할까?

 

Props로 컴포넌트 간 데이터 전달하기

리액트에서 다른 컴포넌트에 저장된 데이터를 단순히 갖다 쓰는 것은 불가능하다. 따라서 우리는 컴포넌트에 속성값으로 데이터를 넘겨준 뒤, 해당 컴포넌트에서 전달받은 속성을 통해 그 데이터를 꺼내쓸 수 있도록 할 것이다. html 요소는 얼마든지 속성을 가질 수 있다. 리액트 컴포넌트 역시, props를 통해 값을 전달받을 수 있다!

 

컴포넌트는 하나의 객체(props)를 전달받는다. props 안에 속성으로 넘겨준 값들이 들어가 있다. 속성을 키로 받아오기 때문에, 컴포넌트로 넘긴 속성과 props를 통해 접근하는 키 값은 반드시 일치해야 한다.

 

이제 App.js에서 ExpenseItem 컴포넌트로 데이터를 전달하는 상황을 가정하자. 원래는 서버에서 값을 받아오는 식으로 진행되겠지만, 여기에서는 더미 데이터를 사용했다.

import ExpenseItem from "./components/ExpenseItem";

function App() {
  const 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),
    },
  ];
  return (
    <div>
      <h2>Let's get started!</h2>
      <ExpenseItem
        title={expenses[0].title}
        amount={expenses[0].amount}
        date={expenses[0].date}
      />
      <ExpenseItem
        title={expenses[1].title}
        amount={expenses[1].amount}
        date={expenses[1].date}
      />
      <ExpenseItem
        title={expenses[2].title}
        amount={expenses[2].amount}
        date={expenses[2].date}
      />
      <ExpenseItem
        title={expenses[3].title}
        amount={expenses[3].amount}
        date={expenses[3].date}
      />
    </div>
  );
}

export default App;

* 물론 여기서 배열로 접근하는 부분은 최적화 가능한 부분이다. 일단은 넘어가자. 

 

이렇게 App 컴포넌트에서 ExpenseItem 컴포넌트로 값을 전달해줬음에도 화면에는 차이가 없다. 아직 ExpenseItem 컴포넌트는 전달받은 값으로 띄우고 있지 않기 때문에, props를 사용해서 전달받은 데이터를 렌더링하도록 고쳐보자.

 

import "./ExpenseItem.css";

function ExpenseItem(props) {
  return (
    <div className="expense-item">
      <div>{props.date.toISOString()}</div>
      <div className="expense-item__description">
        <h2>{props.title}</h2>
        <div className="expense-item__price">${props.amount}</div>
      </div>
    </div>
  );
}

export default ExpenseItem;

* 여기에서 destructoring 사용하여 props를 { date, title, amount} 꺼내줘도 된다. 그치만 일단은 props 개념을 익히기 위해 굳이굳이 키워드를 사용하여 객체에서 키값으로 접근해보자.

 

JS 로직을 적용해보자

현재 날짜가 아주 읽기가 어렵게 되어있다. Month - Year - Day의 포맷으로 바꿔보자. 이 부분은 JS에서 제공하는 기본 기능으로 충분히 해결이 가능하다. 다만 JSX 내에서 JS 로직을 직접 적용하지는 말고, JSX 밖에서 변수에 값을 넣어준 뒤 JSX 코드 자체는 깔끔하게 유지하는 방향으로 코딩하자.

 

import "./ExpenseItem.css";

function ExpenseItem(props) {
    const month = props.date.toLocaleString('en-US', {month: 'long'});
    const day = props.date.toLocaleString('en-US', {day : "2-digit"});
    const year = props.date.getFullYear();

  return (
    <div className="expense-item">
      <div>
        <div>{month}</div>
        <div>{year}</div>
        <div>{day}</div>
      </div>
      <div className="expense-item__description">
        <h2>{props.title}</h2>
        <div className="expense-item__price">${props.amount}</div>
      </div>
    </div>
  );
}

export default ExpenseItem;

 

슬슬 component를 쪼개볼까?

컴포넌트에 이것저것 기능을 추가하다 보면, 어쩔 수 없이 컴포넌트가 점점 무거워지는 모습을 볼 수 있다. 당연한 과정이다! 자, 이제 각각 하나의 기능만 수행하도록 컴포넌트 안의 요소를 다른 컴포넌트로 쪼개보자.

 

방금 다룬 date 정보만 해도 하나의 컴포넌트로 쪼개기 참 좋아보인다. 자 쪼개보자. 

쪼개는 것을 어려워할 필요는 없다. 안에 들어있던 로직, JSX 코드를 그대로 새 컴포넌트로 옮긴 뒤 원 코드 영역에 해당 컴포넌트만 적어주면 된다!

import "./ExpenseDate.css";

function ExpenseDate(props) {
  const month = props.date.toLocaleString("en-US", { month: "long" });
  const day = props.date.toLocaleString("en-US", { day: "2-digit" });
  const year = props.date.getFullYear();

  return (
    <div className="expense-date">
      <div className="expense-date__month">{month}</div>
      <div className="expense-date__year">{year}</div>
      <div className="expense-date__day">{day}</div>
    </div>
  );
}

export default ExpenseDate;

* CSS 파일은 생략

 

ExpenseItem 컴포넌트에서 ExpenseDate 컴포넌트를 사용할 때는 잊지말고 props로 date 정보를 넘겨줘야 된다. 이제 App -> ExpenseItem -> ExpenseDate 로 데이터가 총 3번 전달된 상황이다. 

 

import ExpenseDate from "./ExpenseDate";
import "./ExpenseItem.css";

function ExpenseItem(props) {
  return (
    <div 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>
    </div>
  );
}

export default ExpenseItem;

 

App.js 를 더 간결하게 만들어보자

App 컴포넌트 내에 ExpenseItem이 계속해서 생겨나는 것에 약간 부담이 있으니 코드를 쪼개보자! [0] [1] [2] [3] 으로 접근하기가 너무 싫어서 강의에 나와있는 코드랑은 약간 방향을 틀었다.

 

import Expenses from "./components/Expenses";

function App() {
  const 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),
    },
  ];
  return (
    <div>
      <h2>Let's get started!</h2>
      <Expenses data={expenses} />
    </div>
  );
}

export default App;
import ExpenseItem from "./ExpenseItem";
import "./Expenses.css";

function Expenses(props) {
  return (
    <div className="expenses">
      {props.data.map((data) => (
        <ExpenseItem title={data.title} amount={data.amount} date={data.date} />
      ))}
    </div>
  );
}

export default Expenses;

 

Composition에 대하여 (children props)

현재 레이아웃 상으로 css가 겹치는 부분이 존재한다. 동글동글 테두리 부분이 겹친다. 이런 식으로 겹치는 부분을 단순히 감싸주기만 하는 wrapper component로 쪼개려면 사용해보지 않았던 개념이 등장한다. 

 

이 부분은 공식문서를 참고하면 더 좋을 듯 하다! 

https://ko.reactjs.org/docs/composition-vs-inheritance.html

 

합성 (Composition) vs 상속 (Inheritance) – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

Card라는 이름으로 각각의 요소를 감싸주는 Wrapper 컴포넌트를 만들어보자. 

function Card() {
  return <div className="card"></div>;
}

export default Card;
import Card from "./Card";
import ExpenseDate from "./ExpenseDate";
import "./ExpenseItem.css";

function ExpenseItem(props) {
  return (
    <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>
  );
}

export default ExpenseItem;

위와 같이 겹치는 css를 포함하는 card 컴포넌트를 wrapper로 전달했더니 화면에 아무것도 표시가 되지 않는다. <> </> 사이의 요소를 전달받지 못해서 이런 문제가 발생했다. 따라서 <> 태그와 </> 태그 사이의 요소를 받아올 수 있도록 기본적으로 전달받은 props의 children 속성을 사용한다! (children은 예약어)

 

코드를 열심히 수정해서 card 컴포넌트 내부에 값을 제대로 넣어줬음에도 깨지는 에러가 발생한다.

function Card(props) {
  return <div className="card">{props.children}</div>;
}

export default Card;

레이아웃이 박살났어요

 

자 이 문제는 expense-item 클래스명이 card로 제대로 들어가지 않아 생긴 문제다. 즉, children 뿐만 아니라 원래 들어있던 클래스명도 같이 가져와야 한다는 것이다. className 이라는 속성을 통해 접근할 수 있다. (className 역시 예약어)

import "./Card.css";

function Card(props) {
  const classes = "card " + props.className;
  return <div className={classes}>{props.children}</div>;
}

export default Card;

 

이제 ExpenseItem과 Expenses로 가서 Card 컴포넌트로 감싸주면 원래 UI와 동일하게 구현된다. composition 개념 잘 잡고 가자!

import Card from "./Card";
import ExpenseDate from "./ExpenseDate";
import "./ExpenseItem.css";

function ExpenseItem(props) {
  return (
    <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>
  );
}

export default ExpenseItem;
import Card from "./Card";
import ExpenseItem from "./ExpenseItem";
import "./Expenses.css";

function Expenses(props) {
  return (
    <Card className="expenses">
      {props.data.map((data) => (
        <ExpenseItem title={data.title} amount={data.amount} date={data.date} />
      ))}
    </Card>
  );
}

export default Expenses;

 

완성~

 

다만 여전히 데이터는 static하고 유저와 상호작용하는 부분이 없다. 더 나아가보자!

 


JSX 좀 더 뜯어보기

리액트 구 버전에서는 모든 컴포넌트 상단에 react 라이브러리를 import 해줬어야 했다. 그 이유를 살펴보자면 다음과 같다. JSX 코드를 사용하지 않는 방식으로 코드를 바꿔보자.

return React.createElement({'div', {}, 
	React.createElement('h2', {}, "Let's get started!"),
	React.createElement(Expenses, {data:expenses} )
);
  return (
    <div>
      <h2>Let's get started!</h2>
      <Expenses data={expenses} />
    </div>
  );

위와 아래는 같은 코드다. JSX 코드를 위와 같은 코드로 변환해주기 위해 과거에는 React를 직접 import 해줬어야 했다.

createElement에 전달되는 3개의 인자는 태그, 전달값, 태그안의 값들(여러개가 될 수 있음)이다. 따라서 항상 기억하자! JSX 코드가 제대로 작동되도록 하는 데에는 import React from 'react'가 생략되었으나 기본적으로 들어가 있기 때문에 가능한 것이다.

 

또한 위 코드를 보면 자연스럽게 왜 JSX가 최종적으로 리턴할 수 있는 태그가 한개인 이유를 알 수 있다. 

 

 

Component 파일들 정리하기

점점 컴포넌트가 늘어날수록 정리의 필요성은 커질 것이다. 각각의 기능과 관련된 컴포넌트를 그 기능별로 묶어주고, 전체적으로 통용되는 UI는 UI로 묶어주면 더욱 보기 수월할 것이다! 한번 실제로 정리해보자. 원래는 components 폴더 안에 함께 들어있던 코드들을 다음과 같이 정리해주었다.

Expenses 폴더 / UI 폴더로 나눠 정리

물론 정리 방식은 개인의 자유다 :) 그렇지만 하나의 큰 폴더 안에 모든 컴포넌트를 몽땅 넣어놓는 극악무도한 짓은 하지 말자.

 

Alternative Function Syntax

function 문법 대신 arrow fn을 사용해도 괜찮다. 정말로 개인의 선택이기 때문에 편한 방식을 택하면 된다~! 하지만 나는 arrow function으로 코드 전부 바꿔줄거다 ㅎㅎ

 

여기까지 작업한 파일은 깃헙에 업로드했다!

https://github.com/cheonyeji/react_recap_ch3

 

GitHub - cheonyeji/react_recap_ch3

Contribute to cheonyeji/react_recap_ch3 development by creating an account on GitHub.

github.com

 

로그인, 로그아웃 여부에 따라 Header 조건부 렌더링하기 (1)

- component가 모두 접근할 수 있도록 context를 사용한다. 

 

context.js에서 createContext import 해준 뒤 다음과 같이 기본적으로 context와 provider 작성을 해준다.

 

// UserContext : Application 내의 데이터 저장소
const UserContext = React.createContext();

const UserContextProvider = ({children}) => {
	<UserContext.Provider>{children}</UserContext.Provider>
}

export default UserContextProvider;

 

여기에서 추가적으로 값을 전달해야할 것이며, state도 갖고 있어야 한다. 

+ Recent posts