공식문서를 읽으면서 정리중이다! 오늘은 2일차. 아무래도 당분간은 ReactJS 와 NextJS의 공식문서읽기 시리즈가 업로드될 것 같다 ㅎㅎ
position.x = e.clientX;
position.y = e.clientY;
위와 같은 코드는 state 설정 함수가 없기 때문에 객체가 변경되었는지 인지하지 못하여, 리렌더링이 일어나지 않는다.
상태를 직접 변경할 경우 최적화 과정이 이루어지지 않아 불필요한 렌더링이 발생할 수 있기 때문이다.
setPerson({
...person,
artwork: {
...person.artwork,
title: e.target.value
}
});
중첩된 객체도 마찬가지로 직접 설정해서는 안된다. setState 를 사용해야하고, 동일한 부분은 '...' (전개연산자) 를 써야한다.
Immer 를 사용하면 된다. Immer 는 불변성을 유지하면서 가변 상태를 다루는 것처럼 직관적으로 코드를 쓸 수 있다.
✔️ Immer 를 쓰기 전 코드
const newState = {
...state,
artwork: {
...state.artwork,
title: 'Red Nana',
}
};
✔️ Immer 를 쓰고난 후 코드
import produce from "immer";
const newState = produce(state, draft => {
draft.artwork.title = 'Red Nana';
});
Immer 를 사용하려면 npm install use-immer 를 하면 된다.
Immer 는 기본 상태를 Proxy 로 감싸고, 상태에 변경이 일어나면 이를 추적하고 원본 상태를 변경하지 않은 채 복사본을 생성한다.
1. 초기 상태를 Proxy 로 감싸기
import produce from "immer";
const baseState = { name: 'Alice', age: 25 };
// Proxy 로 감싸기
// 내부 상태 변경은 draft 로 이루어짐
const nextState = produce(baseState, draft => {
draft.age = 26;
});
2. 변경 사항 기록
Proxy 는 draft 객체에 모든 변경사항을 기록한다. 이 때 원본 상태인 baseState 는 수정되지 않고, 모든 변경은 draft 에서만 일어난다.
draft.age = 26;
3. 변경된 상태 복사 및 반환
const nextState = produce(baseState, draft => {
draft.age = 26;
});
console.log(baseState.age); // 25, 원본 상태는 그대로
console.log(nextState.age); // 26, 복사된 상태가 변경됨
produce 함수가 끝나면 Immer는 얕은 복사를 수행한다. 그래서 원본 상태는 그대로 유지되며, 변경된 부분만 복사된 객체에 반영한다.
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
위와 같은 코드가 있을 때, color state 변수는 messageColor prop으로 초기화된다. 하지만 부모 컴포넌트에서 나중에 다른 값의 messageColor 를 전달하면 color state 변수는 업데이트되지 않는다.
즉, 부모 컴포넌트에서 prop으로 전달된 값이 이후에 변경되더라도 자식 컴포넌트의 state는 그 변경된 값을 자동으로 반영하지 않는다.
useState 는 컴포넌트가 처음 렌더링될 때만 상태의 초기값을 설정한다. 이후에는 setState 같은 함수를 호출하는 경우에만 상태 업데이트가 일어나기 때문에 부모의 prop이 변경되더라도 자식 컴포넌트의 state는 변경되지 않는다.
할 수 있다. useEffect 를 사용하여 상태 동기화를 하면 된다.
import { useState, useEffect } from "react";
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
// messageColor prop이 변경될 때 color state를 업데이트
useEffect(() => {
setColor(messageColor);
}, [messageColor]); // messageColor가 변경될 때만 실행
return (
<div style={{ color }}>
This is a message.
<button onClick={() => setColor('red')}>Change to Red</button>
</div>
);
}
import { useState } from 'react';
export default function MyComponent() {
const [counter, setCounter] = useState(0);
function MyTextField() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={e => setText(e.target.value)}
/>
);
}
return (
<>
<MyTextField />
<button onClick={() => {
setCounter(counter + 1)
}}>Clicked {counter} times</button>
</>
);
}
위와 같은 코드가 있을 때 MyTextField 에 값을 입력한 후, button 을 클릭하게 되면 MyTextField 내에 값이 초기화된다.
button 을 클릭하면 setCounter가 실행되면서 리렌더링이 이뤄지는데, 이 때 MyComponent 가 다시 생성되고 MyTextField 내에 text state 변수도 초기화 되기 때문이다.
import { useState } from 'react';
// MyTextField를 컴포넌트 바깥으로 이동
function MyTextField() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={e => setText(e.target.value)}
/>
);
}
export default function MyComponent() {
const [counter, setCounter] = useState(0);
return (
<>
<MyTextField />
<button onClick={() => {
setCounter(counter + 1)
}}>Clicked {counter} times</button>
</>
);
}
import { useState, useMemo } from 'react';
export default function MyComponent() {
const [counter, setCounter] = useState(0);
const MyTextField = useMemo(() => {
return function MyTextField() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={e => setText(e.target.value)}
/>
);
};
}, []); // 의존성 배열이 빈 상태이므로 최초 렌더링 시에만 생성됨
return (
<>
<MyTextField />
<button onClick={() => {
setCounter(counter + 1);
}}>Clicked {counter} times</button>
</>
);
}
reducer 는 state를 다루는 다른 방법이다. useState 에서 useReducer 로 바꾸는 방법은 다음과 같다.
- state를 설정하는 것에서 action을 dispatch 함수로 전달하는 것으로 바꾸기
- reducer 함수 작성하기
- 컴포넌트에서 reducer 사용하기
예를 들어 state를 추가, 수정, 삭제하는 기능이 있다고 해보자. 그럼 다음과 같이 정의할 수 있다.
function handleAddTask(text) {
setTasks([...tasks, {
id: nextId++,
text: text,
done: false
}]);
}
function handleChangeTask(task) {
setTasks(tasks.map(t => {
if (t.id === task.id) {
return task;
} else {
return t;
}
}));
}
function handleDeleteTask(taskId) {
setTasks(
tasks.filter(t => t.id !== taskId)
);
}
위와 같은 코드를 reducer 로 사용한다면 다음과 같이 변경할 수 있다.
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
});
}
dispatch 함수에 type(역할)과 action 객체를 넣어서 하나의 reducer 함수에서 처리한다. (to be continued...)
type 별로 동작을 정의하여 함수를 구성한다. 예시는 다음과 같다.
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);
}
}
}
reducer 함수는 state를 인자로 받고 있기 때문에, 이를 컴포넌트 외부에서 선언할 수 있다!
import { useReducer } from 'react';
// reducer 함수와 초기 state 값을 인자로 넘겨받으면 state를 담을 수 있는 값과 dispatch 함수를 반환한다.
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
| 항목 | useState | useReducer |
|---|---|---|
| 사용 용도 | 단순한 상태 업데이트 (예: 값 변경, 토글 등) | 복잡한 상태 로직이나 여러 상태 값이 관련된 경우 |
| 상태 업데이트 방식 | 상태를 직접 업데이트 (setState) | dispatch로 액션을 보내고, 리듀서가 상태 변경 처리 |
| 코드 가독성 | 코드가 직관적이고 간결함 | 리듀서 함수로 상태 변화가 더 명확해짐 |
| 적합한 상황 | 단순한 상태 (예: 숫자 증가, 폼 입력) | 복잡한 상태 (예: 여러 단계의 상태 업데이트) |
| 성능 | 성능 차이는 거의 없으나 단순한 상태에서는 더 효율적 | 복잡한 상태 로직에서 상태 관리가 더욱 예측 가능하고 최적화됨 |
Context 는 전역적인 상태를 관리하고 여러 컴포넌트 사이에서 데이터를 쉽게 전달할 수 있게 해주는 기능이다. 특히 Props drilling 없이 데이터나 상태를 전달하고 공유하기 위해 사용한다.
하지만 상태 업데이트가 자주 발생하거나, 상태 관리가 복잡해지면 Context 대신 상태 관리 라이브러리(예: Redux, Recoil)를 사용하는 것이 좋다.
- Context 생성
- Provider 사용
- Consumer 컴포넌트 혹은 useContext 훅 사용
- Context 업데이트
React.createContext를 호출하여 새로운 Context를 생성한다.
const MyContext = React.createContext(defaultValue);
Provider는 Context의 값을 자식 컴포넌트에 전달하며, Context에 구독한 하위 컴포넌트에서 사용할 수 있다.
<MyContext.Provider value={sharedValue}>
<MyComponent />
</MyContext.Provider>
Context에 값을 제공받은 하위 컴포넌트는 Consumer 컴포넌트나 useContext 훅을 사용하여 그 값을 읽을 수 있다.
import { useContext } from 'react';
function MyComponent() {
const value = useContext(MyContext); // Context 값 가져오기
return <div>{value}</div>;
}
<MyContext.Consumer>
{value => <div>{value}</div>}
</MyContext.Consumer>
Context의 값이 업데이트되면 해당 Context에 연결된 모든 하위 컴포넌트는 자동으로 재렌더링되어 업데이트된 값을 반영한다.
| 장점 | 단점 |
|---|---|
| Prop Drilling 방지: 중간 컴포넌트를 거치지 않고, 부모에서 자식으로 직접 데이터를 전달할 수 있음. | 성능 문제: Context 값이 변경되면 해당 Context를 사용하는 모든 컴포넌트가 다시 렌더링됨. |
| 전역 상태 관리: 여러 컴포넌트에서 공통된 상태를 공유할 때 유용함. | 복잡한 상태 관리에 비효율적: 복잡한 상태 관리가 필요할 때는 다른 상태 관리 도구가 더 적합함. |
| 간편한 구현: 기본적인 전역 상태 관리 기능을 간단하게 구현할 수 있음. | 테스트 어려움: 컴포넌트 간의 의존성이 강해질 수 있어 테스트 작성이 어려울 수 있음. |
| 명시적인 상태 전달: 상태를 관리하는 계층이 명확해짐. | 유연성 부족: 상태 로직이 복잡해질수록, 상태 변경 패턴을 관리하기 어려워질 수 있음. |