[ReactJS] state 에 관하여 (2)

eunniverse·2024년 9월 11일

글쓰게된 계기

공식문서를 읽으면서 정리중이다! 오늘은 2일차. 아무래도 당분간은 ReactJS 와 NextJS의 공식문서읽기 시리즈가 업로드될 것 같다 ㅎㅎ

state 는 읽기전용으로 다뤄야한다.

 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 은 어떻게 동작할까

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는 얕은 복사를 수행한다. 그래서 원본 상태는 그대로 유지되며, 변경된 부분만 복사된 객체에 반영한다.


Props와 state의 미러링

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 변수도 초기화 되기 때문이다.

해결책은??

  1. MyTextField 를 컴포넌트 밖으로 분리
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>
    </>
  );
}
  1. useMemo 를 사용하여 컴포넌트 재생성 방지
    useMemo를 사용해 MyTextField 컴포넌트를 메모이제이션한다. 이 방법을 사용하면 상태가 변경되지 않을 때 컴포넌트를 다시 생성하지 않고 이전에 생성된 컴포넌트를 재사용할 수 있다.
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 로 관리하기

reducer 는 state를 다루는 다른 방법이다. useState 에서 useReducer 로 바꾸는 방법은 다음과 같다.

  1. state를 설정하는 것에서 action을 dispatch 함수로 전달하는 것으로 바꾸기
  2. reducer 함수 작성하기
  3. 컴포넌트에서 reducer 사용하기

1. state를 설정하는 것에서 action을 dispatch 함수로 전달하는 것으로 바꾸기

예를 들어 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...)

2. reducer 함수 작성하기

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를 인자로 받고 있기 때문에, 이를 컴포넌트 외부에서 선언할 수 있다!

3. 컴포넌트에서 reducer 사용하기

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 vs useReducer

  1. 요약
    간단한 상태 관리는 useState를 사용하는 것이 더 효율적이고 직관적이고, 복잡한 상태 관리나 상태 전환 로직이 명확히 필요할 때는 useReducer가 더 적합하다.
항목useStateuseReducer
사용 용도단순한 상태 업데이트 (예: 값 변경, 토글 등)복잡한 상태 로직이나 여러 상태 값이 관련된 경우
상태 업데이트 방식상태를 직접 업데이트 (setState)dispatch로 액션을 보내고, 리듀서가 상태 변경 처리
코드 가독성코드가 직관적이고 간결함리듀서 함수로 상태 변화가 더 명확해짐
적합한 상황단순한 상태 (예: 숫자 증가, 폼 입력)복잡한 상태 (예: 여러 단계의 상태 업데이트)
성능성능 차이는 거의 없으나 단순한 상태에서는 더 효율적복잡한 상태 로직에서 상태 관리가 더욱 예측 가능하고 최적화됨

Context 로 Prop 전달하기

Context 는 전역적인 상태를 관리하고 여러 컴포넌트 사이에서 데이터를 쉽게 전달할 수 있게 해주는 기능이다. 특히 Props drilling 없이 데이터나 상태를 전달하고 공유하기 위해 사용한다.
하지만 상태 업데이트가 자주 발생하거나, 상태 관리가 복잡해지면 Context 대신 상태 관리 라이브러리(예: Redux, Recoil)를 사용하는 것이 좋다.

Context 사용하기

  1. Context 생성
  2. Provider 사용
  3. Consumer 컴포넌트 혹은 useContext 훅 사용
  4. Context 업데이트

1. Context 생성

React.createContext를 호출하여 새로운 Context를 생성한다.

const MyContext = React.createContext(defaultValue);

2. Provider 사용

Provider는 Context의 값을 자식 컴포넌트에 전달하며, Context에 구독한 하위 컴포넌트에서 사용할 수 있다.

<MyContext.Provider value={sharedValue}>
  <MyComponent />
</MyContext.Provider>

3. Consumer 컴포넌트 혹은 useContext 훅 사용

Context에 값을 제공받은 하위 컴포넌트는 Consumer 컴포넌트나 useContext 훅을 사용하여 그 값을 읽을 수 있다.

useContext 예시
import { useContext } from 'react';

function MyComponent() {
  const value = useContext(MyContext);  // Context 값 가져오기
  return <div>{value}</div>;
}
Consumer 컴포넌트 예시
<MyContext.Consumer>
  {value => <div>{value}</div>}
</MyContext.Consumer>

4. Context 업데이트

Context의 값이 업데이트되면 해당 Context에 연결된 모든 하위 컴포넌트는 자동으로 재렌더링되어 업데이트된 값을 반영한다.

Context 장단점

장점단점
Prop Drilling 방지: 중간 컴포넌트를 거치지 않고, 부모에서 자식으로 직접 데이터를 전달할 수 있음.성능 문제: Context 값이 변경되면 해당 Context를 사용하는 모든 컴포넌트가 다시 렌더링됨.
전역 상태 관리: 여러 컴포넌트에서 공통된 상태를 공유할 때 유용함.복잡한 상태 관리에 비효율적: 복잡한 상태 관리가 필요할 때는 다른 상태 관리 도구가 더 적합함.
간편한 구현: 기본적인 전역 상태 관리 기능을 간단하게 구현할 수 있음.테스트 어려움: 컴포넌트 간의 의존성이 강해질 수 있어 테스트 작성이 어려울 수 있음.
명시적인 상태 전달: 상태를 관리하는 계층이 명확해짐.유연성 부족: 상태 로직이 복잡해질수록, 상태 변경 패턴을 관리하기 어려워질 수 있음.
profile
능력이 없는 것을 두려워 말고, 끈기 없는 것을 두려워하라

0개의 댓글