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을 해보면 부모에서 함수가 실행되어, 값이 잘 넘어온 것을 확인할 수 있다.
똑같은 방식으로 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 컴포넌트까지 잘 넘어온 것을 확인할 수 있다 :)
부모 컴포넌트 <-> 자식 컴포넌트 통신은 매우매우매우 중요한 부분이다!!! 잊지말자! props를 통해 값을 넘겨주거나, props를 통해 함수의 포인터를 넘겨주어 통신을 할 수 있다.
Lifting State up
현재 component 구조를 간단하게 표현해보면 다음과 같다.
데이터가 생성되는 컴포넌트와 데이터가 실제로 사용되는 컴포넌트가 다르다. 두 컴포넌트는 직접적인 연결이 되어있지 않으므로, 가장 최소한으로 연결된 부모를 통해 데이터를 주고 받을 것이다.
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
'React > 프로젝트' 카테고리의 다른 글
미니 프로젝트를 해보자 - To Do (0) | 2022.07.22 |
---|---|
Expense Tracker Project (3/3) - React Rendering Lists (0) | 2022.07.22 |
미니 프로젝트를 만들어보자 - Expense Tracker (0) | 2022.07.20 |
React - 로그인, 로그아웃 여부에 따라 Header 조건부 렌더링하기(1) (0) | 2021.05.11 |