useReducer는 컴포넌트에 리듀서를 추가할 수 있게 해주는 React Hook이에요.
const [state, dispatch] = useReducer(reducer, initialArg, init?)
useReducer(reducer, initialArg, init?) {/usereducer/}컴포넌트의 최상위 레벨에서 useReducer를 호출해서 리듀서로 상태를 관리하세요.
import { useReducer } from 'react';
function reducer(state, action) {
// ...
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...
reducer: 상태가 어떻게 업데이트되는지를 지정하는 리듀서 함수예요. 순수 함수여야 하고, state와 action을 인자로 받아서 다음 state를 반환해야 해요. state와 action은 어떤 타입이든 될 수 있어요.initialArg: 초기 state를 계산할 때 사용되는 값이에요. 어떤 타입의 값이든 될 수 있어요. 이 값에서 초기 state가 어떻게 계산되는지는 다음 init 인자에 따라 달라져요.init: 초기 state를 반환해야 하는 초기화 함수예요. 지정하지 않으면 초기 state가 initialArg로 설정돼요. 지정하면 초기 state가 init(initialArg)를 호출한 결과로 설정돼요.useReducer는 정확히 두 개의 값을 가진 배열을 반환해요:
init(initialArg) 또는 initialArg (init이 없는 경우)로 설정돼요.dispatch 함수.useReducer는 Hook이기 때문에, 컴포넌트의 최상위 레벨 또는 커스텀 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그렇게 해야 한다면, 새 컴포넌트를 분리해서 state를 그쪽으로 옮기세요.dispatch 함수는 안정적인 정체성(stable identity)을 가지고 있어서, Effect 의존성에서 생략하는 걸 자주 볼 수 있지만, 포함해도 Effect가 발동되지 않아요. 린터가 에러 없이 의존성을 생략할 수 있게 해준다면, 그렇게 해도 안전해요. Effect 의존성 제거하기에 대해 더 알아보세요.dispatch 함수 {/dispatch/}useReducer가 반환하는 dispatch 함수를 사용하면 state를 다른 값으로 업데이트하고 리렌더링을 트리거할 수 있어요. dispatch 함수에 유일한 인자로 action을 전달해야 해요:
const [state, dispatch] = useReducer(reducer, { age: 42 });
function handleClick() {
dispatch({ type: 'incremented_age' });
// ...
React는 현재 state와 dispatch에 전달한 action을 가지고 여러분이 제공한 reducer 함수를 호출한 결과를 다음 state로 설정할 거예요.
action: 사용자가 수행한 액션이에요. 어떤 타입의 값이든 될 수 있어요. 관례적으로, action은 보통 그것을 식별하는 type 프로퍼티가 있는 객체이고, 선택적으로 추가 정보를 담은 다른 프로퍼티들을 가져요.dispatch 함수는 반환값이 없어요.
dispatch 함수는 다음 렌더링을 위한 state 변수만 업데이트해요. dispatch 함수를 호출한 후에 state 변수를 읽으면, 호출 전에 화면에 있던 이전 값을 여전히 얻게 돼요.
여러분이 제공한 새 값이 현재 state와 동일하다면 (Object.is 비교로 판단), React는 컴포넌트와 그 자식들의 리렌더링을 건너뛸 거예요. 이건 최적화예요. React가 결과를 무시하기 전에 컴포넌트를 호출해야 할 수도 있지만, 여러분의 코드에는 영향을 주지 않아야 해요.
React는 state 업데이트를 일괄 처리(batch)해요. 모든 이벤트 핸들러가 실행되고 set 함수들을 호출한 후에 화면을 업데이트해요. 이렇게 하면 단일 이벤트 중에 여러 번 리렌더링되는 걸 방지해요. 드문 경우에 React가 더 일찍 화면을 업데이트하도록 강제해야 한다면(예: DOM에 접근하기 위해), flushSync를 사용할 수 있어요.
컴포넌트의 최상위 레벨에서 useReducer를 호출해서 리듀서로 state를 관리하세요.
import { useReducer } from 'react';
function reducer(state, action) {
// ...
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...
useReducer는 정확히 두 개의 항목이 있는 배열을 반환해요:
dispatch 함수.화면에 표시되는 것을 업데이트하려면, 사용자가 무엇을 했는지를 나타내는 객체(action이라고 불러요)를 전달하면서 dispatch를 호출하세요:
function handleClick() {
dispatch({ type: 'incremented_age' });
}
React는 현재 state와 action을 여러분의 리듀서 함수에 전달할 거예요. 리듀서가 다음 state를 계산해서 반환하면, React가 그 다음 state를 저장하고, 컴포넌트를 그것으로 렌더링하고, UI를 업데이트해요.
import { useReducer } from 'react';
function reducer(state, action) {
if (action.type === 'incremented_age') {
return {
age: state.age + 1
};
}
throw Error('Unknown action.');
}
export default function Counter() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
return (
<>
<button onClick={() => {
dispatch({ type: 'incremented_age' })
}}>
Increment age
</button>
<p>Hello! You are {state.age}.</p>
</>
);
}
button { display: block; margin-top: 10px; }
useReducer는 useState와 매우 비슷하지만, state 업데이트 로직을 이벤트 핸들러에서 컴포넌트 밖의 단일 함수로 옮길 수 있게 해줘요. useState와 useReducer 중 선택하기에 대해 더 읽어보세요.
리듀서 함수는 이렇게 선언해요:
function reducer(state, action) {
// ...
}
그런 다음 다음 state를 계산하고 반환하는 코드를 채워 넣어야 해요. 관례적으로, switch 문으로 작성하는 게 일반적이에요. switch의 각 case에서 다음 state를 계산하고 반환하세요.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return {
name: state.name,
age: state.age + 1
};
}
case 'changed_name': {
return {
name: action.nextName,
age: state.age
};
}
}
throw Error('Unknown action: ' + action.type);
}
Action은 어떤 형태든 될 수 있어요. 관례적으로, action을 식별하는 type 프로퍼티가 있는 객체를 전달하는 게 일반적이에요. 리듀서가 다음 state를 계산하는 데 필요한 최소한의 정보만 포함해야 해요.
function Form() {
const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 });
function handleButtonClick() {
dispatch({ type: 'incremented_age' });
}
function handleInputChange(e) {
dispatch({
type: 'changed_name',
nextName: e.target.value
});
}
// ...
action 타입 이름은 컴포넌트에 지역적(local)이에요. 각 action은 데이터에 여러 변경을 야기하더라도 단일 상호작용을 설명해요. state의 형태는 임의적이지만, 보통 객체나 배열이 될 거예요.
더 자세한 내용은 state 로직을 리듀서로 추출하기를 읽어보세요.
⚠️ 주의
State는 읽기 전용이에요. state에 있는 객체나 배열을 수정하지 마세요:
function reducer(state, action) { switch (action.type) { case 'incremented_age': { // 🚩 이렇게 state의 객체를 변경하지 마세요: state.age = state.age + 1; return state; }대신, 리듀서에서 항상 새 객체를 반환하세요:
function reducer(state, action) { switch (action.type) { case 'incremented_age': { // ✅ 대신, 새 객체를 반환하세요 return { ...state, age: state.age + 1 }; }더 자세한 내용은 state에서 객체 업데이트하기와 state에서 배열 업데이트하기를 읽어보세요.
이 예제에서 리듀서는 name과 age 두 개의 필드를 가진 state 객체를 관리해요.
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return {
name: state.name,
age: state.age + 1
};
}
case 'changed_name': {
return {
name: action.nextName,
age: state.age
};
}
}
throw Error('Unknown action: ' + action.type);
}
const initialState = { name: 'Taylor', age: 42 };
export default function Form() {
const [state, dispatch] = useReducer(reducer, initialState);
function handleButtonClick() {
dispatch({ type: 'incremented_age' });
}
function handleInputChange(e) {
dispatch({
type: 'changed_name',
nextName: e.target.value
});
}
return (
<>
<input
value={state.name}
onChange={handleInputChange}
/>
<button onClick={handleButtonClick}>
Increment age
</button>
<p>Hello, {state.name}. You are {state.age}.</p>
</>
);
}
button { display: block; margin-top: 10px; }
이 예제에서 리듀서는 할일(task) 배열을 관리해요. 배열은 변경(mutation) 없이 업데이트해야 해요.
// src/App.js
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask
onAddTask={handleAddTask}
/>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{ id: 0, text: 'Visit Kafka Museum', done: true },
{ id: 1, text: 'Watch a puppet show', done: false },
{ id: 2, text: 'Lennon Wall pic', done: false }
];
// src/AddTask.js
import { useState } from 'react';
export default function AddTask({ onAddTask }) {
const [text, setText] = useState('');
return (
<>
<input
placeholder="Add task"
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={() => {
setText('');
onAddTask(text);
}}>Add</button>
</>
)
}
// src/TaskList.js
import { useState } from 'react';
export default function TaskList({
tasks,
onChangeTask,
onDeleteTask
}) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>
</li>
))}
</ul>
);
}
function Task({ task, onChange, onDelete }) {
const [isEditing, setIsEditing] = useState(false);
let taskContent;
if (isEditing) {
taskContent = (
<>
<input
value={task.text}
onChange={e => {
onChange({
...task,
text: e.target.value
});
}} />
<button onClick={() => setIsEditing(false)}>
Save
</button>
</>
);
} else {
taskContent = (
<>
{task.text}
<button onClick={() => setIsEditing(true)}>
Edit
</button>
</>
);
}
return (
<label>
<input
type="checkbox"
checked={task.done}
onChange={e => {
onChange({
...task,
done: e.target.checked
});
}}
/>
{taskContent}
<button onClick={() => onDelete(task.id)}>
Delete
</button>
</label>
);
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
변경(mutation) 없이 배열과 객체를 업데이트하는 게 번거롭게 느껴진다면, Immer 같은 라이브러리를 사용해서 반복적인 코드를 줄일 수 있어요. Immer를 사용하면 마치 객체를 직접 변경하는 것처럼 간결한 코드를 작성할 수 있지만, 내부적으로는 불변 업데이트를 수행해요:
// src/App.js
import { useImmerReducer } from 'use-immer';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
function tasksReducer(draft, action) {
switch (action.type) {
case 'added': {
draft.push({
id: action.id,
text: action.text,
done: false
});
break;
}
case 'changed': {
const index = draft.findIndex(t =>
t.id === action.task.id
);
draft[index] = action.task;
break;
}
case 'deleted': {
return draft.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
export default function TaskApp() {
const [tasks, dispatch] = useImmerReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask
onAddTask={handleAddTask}
/>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{ id: 0, text: 'Visit Kafka Museum', done: true },
{ id: 1, text: 'Watch a puppet show', done: false },
{ id: 2, text: 'Lennon Wall pic', done: false },
];
// src/AddTask.js
import { useState } from 'react';
export default function AddTask({ onAddTask }) {
const [text, setText] = useState('');
return (
<>
<input
placeholder="Add task"
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={() => {
setText('');
onAddTask(text);
}}>Add</button>
</>
)
}
// src/TaskList.js
import { useState } from 'react';
export default function TaskList({
tasks,
onChangeTask,
onDeleteTask
}) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>
</li>
))}
</ul>
);
}
function Task({ task, onChange, onDelete }) {
const [isEditing, setIsEditing] = useState(false);
let taskContent;
if (isEditing) {
taskContent = (
<>
<input
value={task.text}
onChange={e => {
onChange({
...task,
text: e.target.value
});
}} />
<button onClick={() => setIsEditing(false)}>
Save
</button>
</>
);
} else {
taskContent = (
<>
{task.text}
<button onClick={() => setIsEditing(true)}>
Edit
</button>
</>
);
}
return (
<label>
<input
type="checkbox"
checked={task.done}
onChange={e => {
onChange({
...task,
done: e.target.checked
});
}}
/>
{taskContent}
<button onClick={() => onDelete(task.id)}>
Delete
</button>
</label>
);
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
{
"dependencies": {
"immer": "1.7.3",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"use-immer": "0.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
React는 초기 state를 한 번 저장하고 다음 렌더링에서는 무시해요.
function createInitialState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, createInitialState(username));
// ...
createInitialState(username)의 결과는 초기 렌더링에서만 사용되지만, 여전히 매 렌더링마다 이 함수를 호출하고 있어요. 큰 배열을 생성하거나 비싼 계산을 수행하는 경우 낭비가 될 수 있어요.
이걸 해결하려면, useReducer의 세 번째 인자로 초기화(initializer) 함수를 전달할 수 있어요:
function createInitialState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialState);
// ...
createInitialState()가 아니라, 함수 자체인 createInitialState를 전달하고 있다는 걸 주목하세요. 이렇게 하면 초기화 이후에 초기 state가 다시 생성되지 않아요.
위의 예제에서 createInitialState는 username 인자를 받아요. 초기화 함수가 초기 state를 계산하는 데 아무 정보도 필요 없다면, useReducer의 두 번째 인자로 null을 전달할 수 있어요.
이 예제는 초기화 함수를 전달하므로, createInitialState 함수는 초기화 중에만 실행돼요. input에 타이핑하는 것처럼 컴포넌트가 리렌더링될 때는 실행되지 않아요.
// src/App.js
import TodoList from './TodoList.js';
export default function App() {
return <TodoList username="Taylor" />;
}
// src/TodoList.js (active)
import { useReducer } from 'react';
function createInitialState(username) {
const initialTodos = [];
for (let i = 0; i < 50; i++) {
initialTodos.push({
id: i,
text: username + "'s task #" + (i + 1)
});
}
return {
draft: '',
todos: initialTodos,
};
}
function reducer(state, action) {
switch (action.type) {
case 'changed_draft': {
return {
draft: action.nextDraft,
todos: state.todos,
};
};
case 'added_todo': {
return {
draft: '',
todos: [{
id: state.todos.length,
text: state.draft
}, ...state.todos]
}
}
}
throw Error('Unknown action: ' + action.type);
}
export default function TodoList({ username }) {
const [state, dispatch] = useReducer(
reducer,
username,
createInitialState
);
return (
<>
<input
value={state.draft}
onChange={e => {
dispatch({
type: 'changed_draft',
nextDraft: e.target.value
})
}}
/>
<button onClick={() => {
dispatch({ type: 'added_todo' });
}}>Add</button>
<ul>
{state.todos.map(item => (
<li key={item.id}>
{item.text}
</li>
))}
</ul>
</>
);
}
이 예제는 초기화 함수를 전달하지 않으므로, input에 타이핑하는 것처럼 매 렌더링마다 createInitialState 함수가 실행돼요. 동작에서 관찰 가능한 차이는 없지만, 이 코드는 덜 효율적이에요.
// src/App.js
import TodoList from './TodoList.js';
export default function App() {
return <TodoList username="Taylor" />;
}
// src/TodoList.js (active)
import { useReducer } from 'react';
function createInitialState(username) {
const initialTodos = [];
for (let i = 0; i < 50; i++) {
initialTodos.push({
id: i,
text: username + "'s task #" + (i + 1)
});
}
return {
draft: '',
todos: initialTodos,
};
}
function reducer(state, action) {
switch (action.type) {
case 'changed_draft': {
return {
draft: action.nextDraft,
todos: state.todos,
};
};
case 'added_todo': {
return {
draft: '',
todos: [{
id: state.todos.length,
text: state.draft
}, ...state.todos]
}
}
}
throw Error('Unknown action: ' + action.type);
}
export default function TodoList({ username }) {
const [state, dispatch] = useReducer(
reducer,
createInitialState(username)
);
return (
<>
<input
value={state.draft}
onChange={e => {
dispatch({
type: 'changed_draft',
nextDraft: e.target.value
})
}}
/>
<button onClick={() => {
dispatch({ type: 'added_todo' });
}}>Add</button>
<ul>
{state.todos.map(item => (
<li key={item.id}>
{item.text}
</li>
))}
</ul>
</>
);
}
dispatch 함수를 호출해도 실행 중인 코드에서는 state가 변경되지 않아요:
function handleClick() {
console.log(state.age); // 42
dispatch({ type: 'incremented_age' }); // 43으로 리렌더링 요청
console.log(state.age); // 여전히 42!
setTimeout(() => {
console.log(state.age); // 이것도 42!
}, 5000);
}
이건 state가 스냅샷처럼 동작하기 때문이에요. state를 업데이트하면 새 state 값으로 다른 렌더링을 요청하지만, 이미 실행 중인 이벤트 핸들러의 state JavaScript 변수에는 영향을 주지 않아요.
다음 state 값을 추측해야 한다면, 리듀서를 직접 호출해서 수동으로 계산할 수 있어요:
const action = { type: 'incremented_age' };
dispatch(action);
const nextState = reducer(state, action);
console.log(state); // { age: 42 }
console.log(nextState); // { age: 43 }
React는 Object.is 비교로 판단했을 때 다음 state가 이전 state와 동일하면 업데이트를 무시해요. 이건 보통 state의 객체나 배열을 직접 변경할 때 발생해요:
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// 🚩 잘못됨: 기존 객체를 변경함
state.age++;
return state;
}
case 'changed_name': {
// 🚩 잘못됨: 기존 객체를 변경함
state.name = action.nextName;
return state;
}
// ...
}
}
기존 state 객체를 변경하고 반환했기 때문에, React가 업데이트를 무시한 거예요. 이걸 고치려면, 변경(mutating)하는 대신 항상 state에서 객체 업데이트하기와 state에서 배열 업데이트하기를 해야 해요:
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// ✅ 올바름: 새 객체 생성
return {
...state,
age: state.age + 1
};
}
case 'changed_name': {
// ✅ 올바름: 새 객체 생성
return {
...state,
name: action.nextName
};
}
// ...
}
}
새 state를 반환할 때 모든 case 분기에서 기존의 모든 필드를 복사하고 있는지 확인하세요:
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
return {
...state, // 이걸 잊지 마세요!
age: state.age + 1
};
}
// ...
위의 ...state 없이는, 반환된 다음 state에 age 필드만 포함되고 다른 건 아무것도 없을 거예요.
state가 예기치 않게 undefined가 된다면, case 중 하나에서 state를 return하는 걸 잊었거나, action 타입이 어떤 case 문과도 매치되지 않는 것일 가능성이 높아요. 원인을 찾으려면, switch 밖에서 에러를 throw하세요:
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// ...
}
case 'edited_name': {
// ...
}
}
throw Error('Unknown action: ' + action.type);
}
TypeScript 같은 정적 타입 검사기를 사용해서 이런 실수를 잡을 수도 있어요.
Too many re-renders. React limits the number of renders to prevent an infinite loop.라는 에러를 볼 수 있어요. 일반적으로 이건 렌더링 중에 무조건적으로 action을 dispatch하고 있어서, 컴포넌트가 루프에 빠지는 걸 의미해요: 렌더링, dispatch (렌더링을 유발), 렌더링, dispatch (렌더링을 유발), 이런 식으로요. 매우 흔하게, 이벤트 핸들러를 지정하는 데 실수가 있어서 발생해요:
// 🚩 잘못됨: 렌더링 중에 핸들러를 호출함
return <button onClick={handleClick()}>Click me</button>
// ✅ 올바름: 이벤트 핸들러를 전달함
return <button onClick={handleClick}>Click me</button>
// ✅ 올바름: 인라인 함수를 전달함
return <button onClick={(e) => handleClick(e)}>Click me</button>
이 에러의 원인을 찾을 수 없다면, 콘솔에서 에러 옆의 화살표를 클릭해서 JavaScript 스택을 살펴보면 에러를 유발하는 특정 dispatch 함수 호출을 찾을 수 있어요.
Strict Mode에서는 React가 리듀서와 초기화 함수를 두 번 호출해요. 이게 코드를 깨뜨리면 안 돼요.
이건 개발 환경에서만 일어나는 동작이고, 컴포넌트를 순수하게 유지하는 데 도움을 줘요. React는 두 번의 호출 중 하나의 결과를 사용하고, 다른 호출의 결과는 무시해요. 컴포넌트, 초기화 함수, 리듀서 함수가 순수하다면 이게 로직에 영향을 주지 않아야 해요. 하지만 실수로 불순한(impure) 경우에는 실수를 알아차리는 데 도움이 돼요.
예를 들어, 이 불순한 리듀서 함수는 state의 배열을 직접 변경해요:
function reducer(state, action) {
switch (action.type) {
case 'added_todo': {
// 🚩 실수: state를 변경함
state.todos.push({ id: nextId++, text: action.text });
return state;
}
// ...
}
}
React가 리듀서 함수를 두 번 호출하기 때문에, todo가 두 번 추가된 걸 볼 수 있고, 실수가 있다는 걸 알 수 있어요. 이 예제에서는 배열을 변경하는 대신 교체해서 실수를 고칠 수 있어요:
function reducer(state, action) {
switch (action.type) {
case 'added_todo': {
// ✅ 올바름: 새 state로 교체
return {
...state,
todos: [
...state.todos,
{ id: nextId++, text: action.text }
]
};
}
// ...
}
}
이제 이 리듀서 함수가 순수하므로, 한 번 더 호출해도 동작에 차이가 없어요. 이것이 React가 두 번 호출해서 실수를 찾는 데 도움을 주는 이유예요. 오직 컴포넌트, 초기화 함수, 리듀서 함수만 순수해야 해요. 이벤트 핸들러는 순수할 필요가 없으므로, React는 이벤트 핸들러를 두 번 호출하지 않아요.
더 자세한 내용은 컴포넌트를 순수하게 유지하기를 읽어보세요.