사용자와의 상호작용과 사용자 이벤트에 대해 정리한다.
리액트는 선언적 접근 방법을 따른다. 상호 작용이 가능한 앱을 만들어서 사용자들의 클릭에 반응하고 입력한 데이터에도 반응할 수 있도록 한다.
버튼을 클릭했을 때 이벤트를 추가해보자
document.getElementById('root').addEventListener(...)
위 방식은 명령형 방식이다.
리액트에서는 JSX안에 이벤트 리스너를 추가한다. 리액트는 모든 기본 이벤트를 on-
으로 시작하는 이벤트 핸들러 props에 값으로 함수가 필요하다. 그 값에는 이벤트가 발생했을 때 실행되는 코드가 들어간다.
JSX 안의 이벤트 함수 값에 모두 선언형, 화살표 함수를 추가하기에는 일반적이지 않다. 따로 함수를 선언하고 onClick={} 안에 정의된 함수명을 넣는다.
const ExpenseItem = (props) => {
const clickHandler = () => {
console.log('Clicked!')
};
return (
<button onClick={clickHandler}>Change Title</button>
);
}
onClick={}안에 있는 clickHandler에 소괄호가 없는 이유는 JSX코드가 평가될 때 clickHandler가 실행되지 않고, 클릭했을 때 clickHandler가 실행되도록 하기 위함이다. 이벤트 리스너 함수명의 관례는 -Handler
이다. (개취)
리액트는 JSX 코드를 평가하여 컴포넌트 함수를 호출한다. 리액트는 JSX 내 컴포넌트 함수들을 계속해서 꼬리물듯이 nested로 호출한다. 컴포넌트는 전체적인 결과를 다시 평가하고 DOM 명령어로 번역해서 화면에 렌더링한다.
리액트 앱 페이지에 방문했을 때 화면에 로드되면서 일어나는 일은 일반적으로 index.js파일에 있는 가장 먼저 App 컴포넌트가 첫 번째로 호출된다. 리액트는 모든 컴포넌트들을 따라 모든 컴포넌트 함수를 실행하고 화면에 렌더링한다.
유일한 단점은 리액트는 절대 반복하지 않는다는 점이다. 리액트는 앱이 처음 렌더링되었을 때, 모든 과정을 실행하고 그 이후에는 끝난다. 최신 앱들은 화면에 보이는 것들을 변경하고 싶을 것이다. 그러기 때문에 특정 값들이 변경되어야하고, 컴포넌트가 재평가 되어야 한다.
그래서 리액트는 state
라는 특별한 개념이 도입한다.
state는 리액트에만 특화된 것은 아니지만 중요한 개념이다. 일반적인 JS 변수는 값이 변경된다할지라도 컴포넌트 함수는 재평가되지 않는다.
리액트에게 컴포넌트 함수를 재실행하게 하기 위해 특별한 함수인 useState 함수를 리액트 라이브러리로부터 제공받아 사용해야한다. state를 사용하기 위해서는 react의 useState를 import해야한다.
import {useState} from "react";
컴포넌트 함수 안에서 useState()를 호출한다. useState를 리액트 훅이라고 부른다. 훅들은 use-
의 규칙을 가진다. 리액트 훅들은 리액트 컴포넌트 함수 안에서 직접적으로 호출되어야 한다(그 안에 또다른 함수에서 호출X. 예외는 존재한다.).
import {useState} from "react";
const 컴포넌트함수 = (props) => {
const [title, setTitle] = useState(props.title);
// ...
}
useState()함수의 전달인자로 초기 값이 들어간다. useState()의 리턴 값은 반응형 변수(관리되고 있는 값)
와 반응형 변수에 새로운 값을 설정하는 함수(값을 업데이트하는 함수)
이다. JS의 배열 구조분해할당을 사용한다.
import {useState} from "react";
const 컴포넌트함수 = (props) => {
const [title, setTitle] = useState(props.title);
const clickHandler = () => {
setTitle('변경 하려는 값');
console.log(`이전 값 : ${title}`);
}
return (
<button onClick={clickHandler}>Title 변경</button>
);
}
이벤트 핸들러 함수에 title의 값을 직접적으로 변경하지 않고 setTitle()을 실행한다. 새로운 값을 단순히 할당하는 것이 아니라 state값을 변경시켜 컴포넌트를 재실행하고 반환되는 JSX 코드를 재평가하기 위함이다.
clickHandler() 함수 안에 있는 콘솔로그에 이전 값이 보여진다. setTitle()을 실행한다고 해서 바로 state의 값이 변하는 것이 아니다. state의 변경을 예약한다.
만약 변경되어야 하는 데이터를 갖고 있는데, 변경 데이터가 UI에 반영되어야 한다면 state가 필요하다. 일반적인 JS 변수는 재평가되지 않는다. 리액트는 state가 등록된 해당 컴포넌트를 재평가하기 위해 useState()가 필요하다.
특정 컴포넌트의 인스턴스를 위해 state를 등록한다. 컴포넌트 함수가 실행되면 컴포넌트가 생성된다. 같은 컴포넌트가 실행되어도 각각 독립적으로 관리되어야 한다. 특정 컴포넌트에만 따로 독립적으로 동작해야 원하는 동작을 구현할 수 있기 때문이다. 즉, 리액트는 컴포넌트 인스턴스 기반으로 독립적인 state를 가지며 각각 평가된다. 각기 다른 스코프를 가지며, 다른 state에 영향을 주지 않는다.
위 코드에서 const를 사용하여 useState의 리턴 값에 대해 배열 구조분해할당을 하고 있는데, 상수로 사용할 수 있는 이유는
1) setTitle() 함수를 실행하여 컴포넌트가 재랜더링(재평가)되기 때문이며
2) title에 직접적으로 값을 넣는게 아니기 때문이다.
그리고 setTitle() 함수를 호출하면 컴포넌트가 재실행된다. 이때의 state를 관리하는 리액트 함수인 useState()의 리턴 값인 title이 최신의 상태 값을 받을 수 있다. 컴포넌트가 재실행될 때마다 항상 최신의 state의 화면을 가진다.
setTitle()함수의 전달인자는 초기 값인데, 컴포넌트가 재실행되는 경우에는 초기 값을 사용하지 않는다.(말그대로 리렌더링이니깐)
Vue 3에서의 setup() 훅과 유사한 느낌이다.
useState()의 리턴 값의 배열 구조분해할당 시에는 2개의 값을 얻는데, 현재 상태 값과 업데이트를 하는 함수이다. 그리고 상태 값을 변경하려면 업데이트 함수를 호출해야한다. JSX 코드에 렌더링하기 위해 첫번째 전달인자인 상태 값을 사용한다. state가 변경하면 컴포넌트 함수는 재실행되고 JSX 코드를 재평가한다.
Vue 3에서 ref()와 reactive()와 동일한 개념이다. 하지만 Vue에서는 직접적으로 반응형 변수의 값을 세팅해주는 것에 비해 React에서는 만들어진 업데이트 함수를 직접 호출하여 반응형 값을 변경한다.
사용자 동작을 받기 위해 리스너를 추가한다. JSX 코드 내 요소에 on-
을 추가한다.
input text의 on- 리스너 종류
1) onInput : 사용자가 데이터를 입력할 때마다 (input, textarea)
2) onChange : input 태그가 blur될 때 (input, textarea, select(dropdown))
const changeHandler = (event) => {
console.log(event.target.value);
};
<input type="text" onChange={changeHandler} />
로그에 출력되는 event 객체는 다음과 같다.
const [a, setA] = useState('');
const [b, setB] = useState(0);
const changeAHandler = e => setA(e.target.value);
const changeBHandler = e => setB(e.target.value);
위 코드와 같이 상태 값의 개념이 반복될 때, 다른 대안이 존재한다. 프리미티브 타입의 값을 레퍼런스 타입인 객체로 변경해주는 것이다.
const [userInput, setUserInput] = useState({
a: '',
b: 0,
});
// 완전히 새로운 객체로 대체한다.
const aChangeHandler = e => setUserInput({
...userInput
a: e.target.value,
});
const bChangeHandler = e => setUserInput({
...userInput
b: e.target.value,
});
핸들러 안에 있는 setUserInput()에 들어가는 객체는 새로운 객체로 대체된다. 다른 속성 값을 가져오는 방법은 스프레드를 사용하여 userInput의 값을 가져올 수 있다.
const [userInput, setUserInput] = useState({
a: '',
b: 0,
});
// 완전히 새로운 객체로 대체한다.
const aChangeHandler = e => setUserInput((prevState) => ({
...prevState
a: e.target.value,
}));
const bChangeHandler = e => setUserInput((prevState) => ({
...prevState
b: e.target.value,
}));
이전 상태의 스냅샷을 사용한다. setUserInput안에 값이 아닌 콜백함수를 사용한다. 이 콜백함수의 전달인자는 이전 상태의 state(prevState)이다.
만일 리액트가 state 업데이트 스케쥴을 갖고 있어서, 바로 실행하지 않는 경우가 있기 때문에 prevState를 사용하여 구현한다. 많은 state 업뎃을 계획한 경우에 이전의 state 스냅샷에 의해서 잘못된 값이 나올 수 있다.
그리고 이전 값에 의존하는 방법의 경우 setUserInput() 함수의 내부 로직이 순수함수가 된다. 하지만 이전 방식의 코드에서는 순수성이 깨지게 된다.
const submitHander = (e) => {
e.preventDefault();
};
<form onSubmit={submitHander}>
// ...
</form>
<form>
태그에 onSubmit일 때, 핸들러 함수를 실행하면 브라우저는 페이지가 다시 로드된다. 브라우저는 form이 submit 될 때마다 웹 페이지를 호스팅하는 서버에 request를 보내기 때문이다. 이를 방지하기 위해 JS 내장함수인 e.preventDefault();
를 호출하여 기본 요청이 보내지는 것을 막는다. 요청이 가지 않기 때문에 페이지는 로드되지 않게 된다.
const [a, setA] = useState('');
const [b, setB] = useState(0);
const submitHandler = (e) => {
e.preventDefault();
const data = {
title: a,
amount: b,
};
// logic
setA('');
setB(0);
};
return (
<form onSubmit={submitHandler}>
<input
type="text"
value={a}
onChange={aChangeHandler}
/>
<input
type="number"
value={0}
onChange={bChangeHandler}
/>
</form>
);
기본 요소인 input 태그도 컴포넌트라고 생각해보자. onChange 속성에 이벤트 리스너를 추가해준 것이다. 이 구조를 확대해서 생각해보자.
props는 부모->자식 컴포넌트 순으로 바로 아래에 있는 컴포넌트에만 던져줄 수 있다.
1) 부모 컴포넌트에서 함수를 정의한다.
// 부모 컴포넌트
const saveDataHandler = (data) => {
const data = {
...data,
id: Math.random().toString,
};
console.log(data);
}
2) 부모 컴포넌트의 JSX 코드에 이벤트핸들러를 추가한다. 이때 on- 명칭은 관례상 표기하는 것이다.
// 부모 컴포넌트의 JSX 코드
return (
<div>
<자식-컴포넌트 onSave={saveDataHandler} />
</div>
);
3) 자식 컴포넌트에서 props로 부모 컴포넌트에서 정의한 이벤트 핸들러를 받는다. 부모 컴포넌트에서 정의된 함수의 포인터를 자식 컴포넌트로 넘겨주게 되어, 자식 컴포넌트에서 커스텀 이벤트리스너(on- 함수)를 실행시키고, 이에 연결된 이벤트 핸들러 함수를 실행시키도록 한다.
// 자식 컴포넌트
const 자식컴포넌트 = (props) => {
// 반응형 변수를 선언
const submitHandler = (e) => {
e.preventDefault();
// 유저가 세팅한 반응형 변수의 값을 받음
props.onSave(유저가세팅한변수Data);
};
}
export default 자식컴포넌트;
이러한 과정을 체이닝해서 최상단 컴포넌트와 소통할 수 있다.
Vue 3에서는 props, emit을 모두 사용하여 양방향 바인딩을 할 수 있고, props만 사용하여 단방향 바인딩을 할 수 있다. 정말 초창기에 Vue를 개발할 때 이러한 방법으로 메소드를 넘겨주어 개발하였으나, 추후 emit의 존재를 알고 양방향으로 개발하였다. 그에 비해 현재까지 공부한 범위에서는 React는 부모 컴포넌트에서 정의한 함수를 자식으로 props를 통해 내려주는 방식으로 양방향을 구현하는 것 같다.
자식 컴포넌트끼리는 직접적으로 연결되어 있지 않기 때문에, Data/State를 바로 줄 수 없다. 그러므로 바로 위에 있는 부모 컴포넌트에 state를 전달해준다. 실제로 부모 컴포넌트에 상태를 정의해서 사용하는 것이 아닌 자식 컴포넌트에서 정의된 state를 전달인자로서 사용만 하였다. 그리고 Data/State를 위해 컴포넌트 트리에 필요한 만큼만 끌어올려서 사용하여, 데이터를 생성하는 컴포넌트와 데이터가 필요한 컴포넌트 간에 최단거리로 접근할 수 있도록 해주면 된다.
이벤트를 수신하고 자식 -> 부모 컴포넌트로 데이터를 전달하고 state로 작업한다.