목차
6 장

이벤트 핸들링

리액트의 합성 이벤트 시스템과 이벤트 핸들러 작성, 인자 전달, 흔한 함정을 살펴봅니다. 19장 이벤트 타이핑과 27장 Server Actions의 토대입니다.

5장에서 state와 useState를 배우면서 자연스럽게 onClick이라는 이벤트 핸들러를 썼습니다. 그 자체로도 동작은 했지만, 리액트의 이벤트 처리 방식에는 알아 둘 것들이 몇 가지 있습니다. 본 챕터에서 본격적으로 다루겠습니다.

본 챕터에서 짚는 이벤트 모델은 19장 (이벤트와 폼 타이핑)에서 TypeScript로 굳히고, 27장 (Server Actions와 폼)에서 새 모델 (<form action={fn}>)로 한 번 더 확장됩니다. 본 챕터의 기본 패턴을 단단히 잡아 두면 그 뒷장이 가볍게 읽힙니다.

리액트에서 이벤트 처리하는 법 #

리액트에서 이벤트는 JSX 속성으로 핸들러 함수를 전달하는 방식으로 처리합니다. HTML의 onclick이 아니라 camelCase의 **onClick**을 쓴다는 점만 다릅니다.

src/App.jsx
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 안에 화살표 함수로 직접 작성하기도 합니다.

src/Counter.jsx
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(prev => prev + 1)}>
      카운트: {count}
    </button>
  );
}

() => setCount(prev => prev + 1)은 클릭 시점에 호출되는 익명 함수입니다. 한 줄짜리 단순한 핸들러는 인라인으로 두는 게 편하고, 로직이 길어지면 별도 함수로 빼는 게 가독성에 좋습니다. 정해진 규칙은 없고 팀이나 본인의 취향에 따릅니다.

함수에 인자 전달하기 #

핸들러 함수에 인자를 넘겨야 할 때는 인라인 화살표 함수로 감싸야 합니다.

src/App.jsx
function App() {
  function handleClick(name) {
    alert(`${name}님 안녕하세요!`);
  }

  return (
    <>
      <button onClick={() => handleClick('철수')}>철수에게 인사</button>
      <button onClick={() => handleClick('영희')}>영희에게 인사</button>
    </>
  );
}

다음과 같이 쓰면 안 됩니다.

잘못된 예
<button onClick={handleClick('철수')}>...</button>

위에서 봤듯 이건 렌더링 즉시 호출되어 버립니다. 인자를 넘기려면 반드시 화살표 함수로 한 번 감싸 “클릭될 때 호출하라"는 의미를 만들어야 합니다.

이벤트 객체 #

이벤트 핸들러는 첫 번째 매개변수로 이벤트 객체를 받습니다. 이 객체에는 어떤 요소에서 어떤 이벤트가 일어났는지에 대한 정보가 담겨 있습니다.

src/InputDemo.jsx
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.targete.currentTarget의 타입 차이도 그곳에서 짚겠습니다.

자주 쓰는 이벤트들 #

가장 많이 쓰는 이벤트 핸들러를 정리하면:

  • onClick — 클릭
  • onChange — 입력 요소 (input, textarea, select)의 값이 바뀔 때
  • onSubmit — 폼이 제출될 때
  • onKeyDown / onKeyUp — 키보드 키가 눌리거나 떼질 때
  • onMouseEnter / onMouseLeave — 마우스가 요소 위에 들어오거나 벗어날 때
  • onFocus / onBlur — 포커스 진입 / 해제

각 이벤트는 그에 맞는 정보를 이벤트 객체에 담아 줍니다. onChange 라면 e.target.value를, onKeyDown이라면 e.key (누른 키 이름)를 보면 됩니다.

src/SearchBox.jsx
function SearchBox() {
  function handleKeyDown(e) {
    if (e.key === 'Enter') {
      alert('엔터 키가 눌렸습니다');
    }
  }

  return <input type="text" onKeyDown={handleKeyDown} />;
}

기본 동작 막기 #

브라우저는 어떤 이벤트에 대해 기본 동작을 가지고 있습니다. 폼이 제출되면 페이지가 새로고침되고, 링크를 클릭하면 페이지가 이동합니다. 이 기본 동작을 막으려면 이벤트 객체의 **preventDefault()**를 호출합니다.

src/LoginForm.jsx
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로 자식 컴포넌트에 전달할 수 있습니다. 이 패턴은 자식이 일으킨 이벤트를 부모가 처리해야 할 때 매우 자주 쓰입니다.

src/Button.jsx
function Button({ label, onClick }) {
  return (
    <button onClick={onClick} style={{ padding: '8px 16px' }}>
      {label}
    </button>
  );
}

export default Button;
src/App.jsx
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를 변경하는 것이 가장 흔한 패턴입니다.

src/Toggle.jsx
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를 만듭니다.

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에 연결합니다.

src/App.jsx
import MessageForm from './MessageForm';

function App() {
  return (
    <>
      <h1>메시지 </h1>
      <MessageForm />
    </>
  );
}

export default App;

이름과 메시지를 입력하고 엔터나 “추가” 버튼을 눌러 보세요. 마지막에 제출된 값이 아래에 표시되고, 입력 필드는 비워집니다. e.preventDefault()를 빼면 폼이 새로고침되어 입력값이 사라지는 것도 실험해 보세요.

위 코드의 lastSubmitted && (...) 부분이 처음 보이실 텐데, 이것은 조건부 렌더링입니다. 값이 있으면 보여 주고 없으면 안 보여 주는 패턴입니다. 다음 7장에서 자세히 다루겠습니다.

연습문제 #

  1. MessageForm에 “리셋” 버튼을 추가해 한 번 누르면 lastSubmittednull로 돌아가도록 만들어 보세요. <button type="button" onClick={() => setLastSubmitted(null)}>리셋</button> 형태. type="button"을 명시하지 않으면 폼 제출로 동작하니 주의합니다.
  2. 키보드 이벤트 연습. src/SearchBox.jsx를 만들고 <input>onKeyDown 핸들러를 달아 사용자가 엔터를 누르면 현재 입력값을 alert으로 띄우도록 작성해 보세요. if (e.key === 'Enter') alert(...) 패턴.
  3. 핸들러를 props로 전달하는 연습. Button 컴포넌트를 만들고 labelonClick 두 prop을 받게 한 뒤, 부모에서 같은 Button을 세 번 사용하면서 각각 다른 핸들러를 전달해 보세요. 클릭하면 콘솔에 각각 다른 메시지가 찍히도록 만듭니다.

한 줄 요약: onClick처럼 camelCase 속성으로 핸들러를 등록한다. 함수를 호출하지 말고 전달 한다 ({handleClick}이지 {handleClick()}이 아니다). 인자를 넘기려면 화살표 함수로 감싼다. 핸들러는 첫 매개변수로 합성 이벤트 객체 e를 받는다. e.preventDefault()로 브라우저 기본 동작을 막을 수 있다. 핸들러도 props로 자식에 내려보낼 수 있다 (on으로 시작하는 이름).

다음 챕터 #

또한 MessageForm 예제에서 슬쩍 본 {lastSubmitted && ...} 패턴이 바로 다음 주제로 이어집니다. 다음 7장 조건부 렌더링에서는 화면의 일부를 상태에 따라 보였다 안 보였다 하거나 다른 모습으로 바꾸는 다양한 패턴을 정리하겠습니다.

X