이벤트 핸들링
리액트의 합성 이벤트 시스템과 이벤트 핸들러 작성, 인자 전달, 흔한 함정을 살펴봅니다. 19장 이벤트 타이핑과 27장 Server Actions의 토대입니다.
5장에서 state와 useState를 배우면서 자연스럽게 onClick이라는 이벤트 핸들러를 썼습니다. 그 자체로도 동작은 했지만, 리액트의 이벤트 처리 방식에는 알아 둘 것들이 몇 가지 있습니다. 본 챕터에서 본격적으로 다루겠습니다.
본 챕터에서 짚는 이벤트 모델은 19장 (이벤트와 폼 타이핑)에서 TypeScript로 굳히고, 27장 (Server Actions와 폼)에서 새 모델 (<form action={fn}>)로 한 번 더 확장됩니다. 본 챕터의 기본 패턴을 단단히 잡아 두면 그 뒷장이 가볍게 읽힙니다.
리액트에서 이벤트 처리하는 법 #
리액트에서 이벤트는 JSX 속성으로 핸들러 함수를 전달하는 방식으로 처리합니다. HTML의 onclick이 아니라 camelCase의 **onClick**을 쓴다는 점만 다릅니다.
function App() {
function handleClick() {
alert('버튼이 클릭되었습니다!');
}
return <button onClick={handleClick}>클릭</button>;
}
export default App;여기서 자주 하는 실수가 하나 있습니다. 함수를 호출 (handleClick()) 하면 안 되고, 함수 자체 (handleClick)를 전달해야 합니다.
<button onClick={handleClick()}>클릭</button>이렇게 쓰면 컴포넌트가 렌더링되는 즉시 handleClick()이 실행되어 alert이 떠 버립니다. 게다가 onClick에는 handleClick의 반환값(undefined)이 등록되므로 진짜 클릭에는 아무 일도 일어나지 않습니다.
<button onClick={handleClick}>클릭</button>함수 참조만 넘기고 호출은 리액트가 클릭 시점에 합니다. 이 차이를 꼭 기억하세요.
인라인 핸들러 #
간단한 핸들러는 아예 JSX 안에 화살표 함수로 직접 작성하기도 합니다.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(prev => prev + 1)}>
카운트: {count}
</button>
);
}() => setCount(prev => prev + 1)은 클릭 시점에 호출되는 익명 함수입니다. 한 줄짜리 단순한 핸들러는 인라인으로 두는 게 편하고, 로직이 길어지면 별도 함수로 빼는 게 가독성에 좋습니다. 정해진 규칙은 없고 팀이나 본인의 취향에 따릅니다.
함수에 인자 전달하기 #
핸들러 함수에 인자를 넘겨야 할 때는 인라인 화살표 함수로 감싸야 합니다.
function App() {
function handleClick(name) {
alert(`${name}님 안녕하세요!`);
}
return (
<>
<button onClick={() => handleClick('철수')}>철수에게 인사</button>
<button onClick={() => handleClick('영희')}>영희에게 인사</button>
</>
);
}다음과 같이 쓰면 안 됩니다.
<button onClick={handleClick('철수')}>...</button>위에서 봤듯 이건 렌더링 즉시 호출되어 버립니다. 인자를 넘기려면 반드시 화살표 함수로 한 번 감싸 “클릭될 때 호출하라"는 의미를 만들어야 합니다.
이벤트 객체 #
이벤트 핸들러는 첫 번째 매개변수로 이벤트 객체를 받습니다. 이 객체에는 어떤 요소에서 어떤 이벤트가 일어났는지에 대한 정보가 담겨 있습니다.
import { useState } from 'react';
function InputDemo() {
const [text, setText] = useState('');
function handleChange(e) {
setText(e.target.value);
}
return (
<div>
<input type="text" value={text} onChange={handleChange} />
<p>입력값: {text}</p>
</div>
);
}
export default InputDemo;매개변수 이름은 보통 e 또는 event로 짓습니다. e.target은 이벤트가 발생한 DOM 요소이고, e.target.value로 입력값을 꺼낼 수 있습니다.
리액트의 이벤트 객체는 정확히는 브라우저의 네이티브 이벤트가 아니라 **합성 이벤트 (SyntheticEvent)**입니다. 리액트가 모든 브라우저에서 동일하게 동작하도록 한 번 감싼 객체입니다. API는 네이티브 이벤트와 거의 같아서 평소에는 신경 쓸 일이 없습니다. e.preventDefault(), e.target, e.key 같은 익숙한 속성 / 메서드를 그대로 쓸 수 있습니다.
19장 (이벤트와 폼 타이핑)에서는 이 합성 이벤트의 정확한 타입 — ChangeEvent<HTMLInputElement>, FormEvent, KeyboardEvent — 을 다루겠습니다. 또 e.target과 e.currentTarget의 타입 차이도 그곳에서 짚겠습니다.
자주 쓰는 이벤트들 #
가장 많이 쓰는 이벤트 핸들러를 정리하면:
onClick— 클릭onChange— 입력 요소 (input, textarea, select)의 값이 바뀔 때onSubmit— 폼이 제출될 때onKeyDown/onKeyUp— 키보드 키가 눌리거나 떼질 때onMouseEnter/onMouseLeave— 마우스가 요소 위에 들어오거나 벗어날 때onFocus/onBlur— 포커스 진입 / 해제
각 이벤트는 그에 맞는 정보를 이벤트 객체에 담아 줍니다. onChange 라면 e.target.value를, onKeyDown이라면 e.key (누른 키 이름)를 보면 됩니다.
function SearchBox() {
function handleKeyDown(e) {
if (e.key === 'Enter') {
alert('엔터 키가 눌렸습니다');
}
}
return <input type="text" onKeyDown={handleKeyDown} />;
}기본 동작 막기 #
브라우저는 어떤 이벤트에 대해 기본 동작을 가지고 있습니다. 폼이 제출되면 페이지가 새로고침되고, 링크를 클릭하면 페이지가 이동합니다. 이 기본 동작을 막으려면 이벤트 객체의 **preventDefault()**를 호출합니다.
import { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
function handleSubmit(e) {
e.preventDefault(); // 폼 제출로 인한 페이지 새로고침을 막음
console.log('제출된 이메일:', email);
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit">로그인</button>
</form>
);
}
export default LoginForm;폼은 제출 버튼을 누르거나 입력창에서 엔터를 치면 자동으로 onSubmit이 발화됩니다. 이때 e.preventDefault()가 없으면 브라우저가 페이지를 새로고침해 버려서, 우리가 작성한 처리 로직이 의미를 잃습니다.
27장 (Server Actions와 폼)에서는 이 preventDefault()의 필요성이 사라지는 새 모델을 다루겠습니다. <form action={serverFn}> 안에서는 페이지 새로고침이 일어나지 않고 서버 함수가 실행됩니다. 본 챕터의 패턴이 그 모델로 자연스럽게 이어진다고 머릿속에 두시면 됩니다.
이벤트 핸들러를 props로 전달하기 #
이벤트 핸들러도 그냥 함수일 뿐이므로 props로 자식 컴포넌트에 전달할 수 있습니다. 이 패턴은 자식이 일으킨 이벤트를 부모가 처리해야 할 때 매우 자주 쓰입니다.
function Button({ label, onClick }) {
return (
<button onClick={onClick} style={{ padding: '8px 16px' }}>
{label}
</button>
);
}
export default Button;import Button from './Button';
function App() {
function handleSave() {
alert('저장되었습니다');
}
function handleCancel() {
alert('취소되었습니다');
}
return (
<>
<Button label="저장" onClick={handleSave} />
<Button label="취소" onClick={handleCancel} />
</>
);
}부모는 어떤 행동을 할지 (handler)를 전달하고, 자식은 언제 그 행동이 일어나는지 (클릭)를 알려 주는 구조입니다. 핸들러 prop의 이름은 관례상 on으로 시작하게 짓습니다 (onClick, onSave, onItemSelect 등).
이 “자식 이벤트 → 부모 핸들러” 패턴이 11장 (상태 끌어올리기)의 핵심 도구가 됩니다.
핸들러 안에서 state 변경하기 #
5장에서 본 패턴인데, 이벤트 핸들러 안에서 state를 변경하는 것이 가장 흔한 패턴입니다.
import { useState } from 'react';
function Toggle() {
const [isOn, setIsOn] = useState(false);
function handleToggle() {
setIsOn(prev => !prev);
}
return (
<div>
<p>현재 상태: {isOn ? 'ON' : 'OFF'}</p>
<button onClick={handleToggle}>토글</button>
</div>
);
}
export default Toggle;이벤트가 일어나면 → 핸들러가 실행되고 → state가 갱신되고 → 화면이 다시 그려집니다. 이 흐름이 리액트 앱의 가장 기본적인 패턴입니다.
직접 해보기 #
간단한 입력 폼을 만들어 보겠습니다. 사용자가 이름과 메시지를 입력하고 “추가"를 누르면 화면 아래에 메시지가 추가되는 컴포넌트입니다. 7장 / 8장에서 이걸 확장해 갑니다.
src/MessageForm.jsx를 만듭니다.
import { useState } from 'react';
function MessageForm() {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [lastSubmitted, setLastSubmitted] = useState(null);
function handleSubmit(e) {
e.preventDefault();
if (!name || !message) return;
setLastSubmitted({ name, message });
setName('');
setMessage('');
}
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
placeholder="이름"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div style={{ marginTop: '8px' }}>
<input
type="text"
placeholder="메시지"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<button type="submit" style={{ marginTop: '8px' }}>추가</button>
</form>
{lastSubmitted && (
<p style={{ marginTop: '12px' }}>
마지막 입력: <strong>{lastSubmitted.name}</strong>. {lastSubmitted.message}
</p>
)}
</div>
);
}
export default MessageForm;src/App.jsx에 연결합니다.
import MessageForm from './MessageForm';
function App() {
return (
<>
<h1>메시지 폼</h1>
<MessageForm />
</>
);
}
export default App;이름과 메시지를 입력하고 엔터나 “추가” 버튼을 눌러 보세요. 마지막에 제출된 값이 아래에 표시되고, 입력 필드는 비워집니다. e.preventDefault()를 빼면 폼이 새로고침되어 입력값이 사라지는 것도 실험해 보세요.
lastSubmitted && (...) 부분이 처음 보이실 텐데, 이것은 조건부 렌더링입니다. 값이 있으면 보여 주고 없으면 안 보여 주는 패턴입니다. 다음 7장에서 자세히 다루겠습니다.연습문제 #
- 위
MessageForm에 “리셋” 버튼을 추가해 한 번 누르면lastSubmitted가null로 돌아가도록 만들어 보세요.<button type="button" onClick={() => setLastSubmitted(null)}>리셋</button>형태.type="button"을 명시하지 않으면 폼 제출로 동작하니 주의합니다. - 키보드 이벤트 연습.
src/SearchBox.jsx를 만들고<input>에onKeyDown핸들러를 달아 사용자가 엔터를 누르면 현재 입력값을alert으로 띄우도록 작성해 보세요.if (e.key === 'Enter') alert(...)패턴. - 핸들러를 props로 전달하는 연습.
Button컴포넌트를 만들고label과onClick두 prop을 받게 한 뒤, 부모에서 같은Button을 세 번 사용하면서 각각 다른 핸들러를 전달해 보세요. 클릭하면 콘솔에 각각 다른 메시지가 찍히도록 만듭니다.
한 줄 요약:
onClick처럼 camelCase 속성으로 핸들러를 등록한다. 함수를 호출하지 말고 전달 한다 ({handleClick}이지{handleClick()}이 아니다). 인자를 넘기려면 화살표 함수로 감싼다. 핸들러는 첫 매개변수로 합성 이벤트 객체e를 받는다.e.preventDefault()로 브라우저 기본 동작을 막을 수 있다. 핸들러도 props로 자식에 내려보낼 수 있다 (on으로 시작하는 이름).
다음 챕터 #
또한 MessageForm 예제에서 슬쩍 본 {lastSubmitted && ...} 패턴이 바로 다음 주제로 이어집니다. 다음 7장 조건부 렌더링에서는 화면의 일부를 상태에 따라 보였다 안 보였다 하거나 다른 모습으로 바꾸는 다양한 패턴을 정리하겠습니다.