<Demopeu/>

react 학습하기 - state 관리하기

react 학습하기 - state 관리하기

referencereactstateuseReducercontext공식 문서

1️⃣ state를 사용해 Input 다루기

react는 선언적인 방식으로 UI를 조작한다. 개별적인 UI를 직접 조작 보다는 여러 state를 묘사하고 변경하는 방식을 추천한다.

0. 선언형 UI와 명령형 UI

명령형 UI : UI를 조작하기 위해 발생한 상황에 따라 정확한 지침을 작성하는 방식 - 복잡한 시스템에서 새로운 UI 요소나 새로운 상호작용을 추가하려면 버그 발생을 막기 위해 모든 코드를 봐야만 함 선언형 UI : 상태에 따라 무엇을 보여주고 싶은지 선언하는 방식 - 컴포넌트의 다양한 시각적 state 확인 - state 변화 트리거 체크 - useState 사용하여 메모리의 state를 표현 - 불필요한 state 제거 - state 설정을 위한 이벤트 핸들러 연결

1. 컴포넌트의 다양한 시각적 state 확인하기

먼저 사용자가 볼 수 있는 UI의 모든 state를 시각화 한다.

  • Empty: 폼은 비활성화된 "제출" 버튼을 가지고 있다.
  • Typing: 폼은 활성화된 "제출" 버튼을 가지고 있다.
  • Submitting: 폼은 완전히 비활성화되고 스피너가 보인다.
  • Success: 폼 대신에 "감사합니다" 메시지가 보인다.
  • Error: "Typing" state와 동일하지만, 오류 메시지가 보인다.

많은 시각적 state 한 번에 보여주기

컴포넌트가 많은 시각적 state를 가지고 있다면 한 페이지에서 모두 보여주는 것도 방법 중 하나 보통 "살아있는 스타일 가이드(Live Style Guides)" 또는 "스토리북(Storybook)"이라 부름.

2. 무엇이 state 변화를 트리거하는지 알아내기

여기서는 두 종류의 인풋 유형이 있다.

  1. 버튼을 누르거나, 필드를 입력하거나, 링크를 이동하는 것 등의 휴먼 인풋
  2. 네트워크 응답이 오거나, 타임아웃이 되거나, 이미지를 로딩하거나 하는 등의 컴퓨터 인풋

두 가지 경우 모두 UI 업데이트를 위한 state가 필요.

  • 텍스트 인풋을 변경하면(휴먼) 텍스트 상자 여부에 따라 Empty, Typing state가 변경
  • 제출 버튼을 클릭(휴먼) Submitting state가 변경
  • 네트워크 응답이 성공적으로 오면(컴퓨터) Success state가 변경
  • 네트워크 응답이 실패하면(컴퓨터) Error state가 변경

휴먼 인풋은 종종 이벤트 핸들러가 필요하다!

3. 메모리의 state를 useState로 표현하기

이 과정은 단순함이 핵심. 단순할수록 버그가 없음.

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

일단 다 적어본다.

4. 불필요한 state 변수 제거하기

리팩토링의 목표는 state가 사용자에게 유효한 UI를 보여주지 않는 경우를 방지하는 것.

여기에 state 변수에 관한 몇 가지 질문이 있다.

- 1. state가 역설을 일으키지는 않나요? 예시로 `isSubmitting`과 `isTyping`이 동시에 true일 수는 없음. 따라서 불가능한 조합을 생각해서 state를 제거할 수 있음. 
- 2. 다른 state 변수에 이미 같은 정보가 담겨있지는 않나요? 예시로 `isEmpty`와 `isTyping`은 동시에 true일 수는 없음. 싱크가 맞지 않거나 버그가 발생할 위험이 있음.
- 3. 다른 변수를 뒤집었을 때 같은 정보를 얻을 수 있지는 않나요? `isError`는 `error != null`로도 대신할 수 있기 때문.

이런 정리과정을 거치면

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

어느 하나를 지웠을 때 정상적으로 작동하지 않는다는 점에서 이것들이 모두 필수라는 것을 알 수 있다.

reducer를 사용하여 "불가능한" state 제거하기

하지만 여전히 말이 안되는 state를 가지고 있음. 예를 들면 error가 null이 아닌데 state가 success인 것은 말이 안됨. 따라서 더 정확하게 모델링하기 위해서는 reducer를 사용할 수 있음.

5. state 설정을 위해 이벤트 핸들러를 연결하기

마지막으로 state 변수를 설정하기 위해 이벤트 핸들러를 연결하면 됨. 이러한 코드는 명령형 프로그래밍보다 길 수 있지만, 조금 더 견고함.

2️⃣ state 구조 선택하기

state 구조화 원칙

  1. 연관된 state 그룹화하기
  2. state 모순 피하기
  3. 불필요한 state 피하기
  4. state 중복 피하기
  5. 깊게 중첩된 state 피하기

1. 연관된 state 그룹화하기

const [x, setX] = useState(0);
const [y, setY] = useState(0);

const [position, setPosition] = useState({ x: 0, y: 0 });

두 가지 모두 옳은 방법이지만, 두 개의 state가 항상 함께 변경된다면 단일로 통합하는 것이 좋음.

이런식으로 그룹화 하는 다른 경우는 state 조각 수를 모를 때임. 특히 커스텀 필드를 추가 할 때 유용하다.

state 변수가 객체인 경우 하나의 필드만 따로 업데이트는 불가능. 모든 속성을 복사하여서 변경해야 함

2. state 모순 피하기

불가능한 state 허용 할 수 있는 것보다는 유효한 상태 중 하나를 가질 수 있는 state state 변수로 대체하는 것이 좋음.

3. 불필요한 state 피하기

props를 state에 미러링 하면 안된다. 다음 코드를 보자.

function Message({messageColor}){
    const [color, setColor] = useState(messageColor);
}

여기서 color state 변수는 messageColor prop로 초기화 되고 있다. 문제는 부모 컴포넌트가 나중에 다른 값을 내려보내 준다면 state 변수는 업데이트 되지 않는다. 따라서 직접 사용해야 한다.

function Message({messageColor}){
    const color = messageColor;
}

// 무시 되길 원한다면 새로운 값이 무시됨을 명시하자.(ex. initial, default)
function Message({initialColor}){
    const [color, setColor] = useState(initialColor);
}

4. state 중복 피하기

중복된 데이터를 가지는 것보다는 최소한의 데이터만 가질 수 있도록 중복을 피한다.

5. 깊게 중첩된 state 피하기

너무 중첩되어 있다면 평탄하게 만드는 것을 고려해라.

메모리 사용량 개선하기

이상적으로 메모리 사용량을 개선하기 위해서는 삭제된 항목(그리고 그들의 자식들)을 "테이블" 객체에서 제거해야 함. 그래서 업데이트 로직을 간결하게 쓰기 위하여 Immer를 사용할 수 있음.

3️⃣ 컴포넌트 간 state 공유하기

때때로 두 컴포넌트의 state가 항상 함께 변경되기를 원한다. 그럴 때는 state 끌어올리기라는 가장 가까운 공통 부모 컴포넌트로 이동해야 한다.

  1. 자식 컴포넌트에서 state 제거
  2. 하드 코딩된 데이터를 부모 컴포넌트로 전달
  3. 공통 부모에 state 추가

제어와 비제어 컴포넌트

"제어되지 않은" 몇몇 지역 state를 갖는 컴포넌트를 사용하는 것은 흔한 일. 제어 컴포넌트: props에 의해 값이 결정됨 비제어 컴포넌트: 내부 state에 의해 값이 결정됨

비제어 컴포넌트는 부모가 쓰기 쉬움 하지만 덜 유연함.

📌 각 state의 단일 진리의 원천(Single Source of Truth)

  1. state는 한 곳에서만 진짜 값이 존재해야 한다 -> 그래야 불일치성이 줄고 버그가 줄어듦
  2. 그 state를 어디 둘지는 개발자의 선택 -> 공통 부모로 끌어올리는 선택 등
  3. 단일 진리의 원천은 한 곳에 다 넣으라는 뜻이 아님 -> 같은 데이터를 두 군데에 들고 있지 말라는 뜻. 책임자는 단 하나!
  4. state 위치가 계속 바뀌는 것은 정상적인 행동 -> 개발 하다보면 더 위로 올라감. 당연한 과정이고 이게 리팩터링

4️⃣ state를 보존하고 초기화하기

각 컴포넌트는 독립된 state를 가지고 있어, 리렌더링마다 언제 state를 보존하고 또 state를 초기화하는지 결정한다.

📌 state 는 렌더 트리의 위치에 연결

사실 state는 react 안에 있음. react가 알맞은 컴포넌트와 연결하는 거지 컴포넌트 안에 있지는 않음.

react는 트리의 동일한 컴포넌트를 동일한 위치에 렌더링하는 동안 상태를 유지. 트리에서 컴포넌트를 제거할 때 state도 같이 제거.

1. 같은 자리의 같은 컴포넌트는 state를 보존

  • react는 JSX 마크업에서가 아닌 UI 트리에서의 위치에만 관심이 있음
  • react는 함수 안에 조건문이 있는지도 모름
  • 따라서 반환 하는 트리만 보기 때문에, 이런 현상이 생기는 것

2. 같은 자리의 다른 컴포넌트는 state를 초기화

  • 같은 위치에 다른 컴포넌트를 렌더링할 때 컴포넌트는 그의 전체 서브 트리의 state를 초기화

컴포넌트 함수를 중첩해서 정의하면 안되는 이유

리렌더링 할 때마다 안의 함수들은 다시 만들어지기 때문에 모든 state를 초기화 함.

3. key를 이용해 state를 초기화

  • react가 컴포넌트를 구별할 수 있도록 key를 사용
  • key는 컴포넌트가 그 트리에 있는 모든 state를 포함해서 처음부터 다시 생성하는 것을 보장
  • 특히 폼을 다룰 때 유용

제거된 컴포넌트의 state를 보존하기

  • CSS로 숨기는 방법 : 대신 DOM 노드가 많은 경우 매우 느려짐
  • state를 상위로 올리기 : 일반적인 해법
  • 다른 저장소 이용 : localStorage 같은 곳을 이용함

5️⃣ state 로직을 reducer로 작성하기

state 업데이트가 여러 이벤트 핸들러로 분산 될 경우, 관리가 어려워진다. 이걸 해결하기 위하여 reducer를 사용해 통합 관리 할 수 있다.

📌 reducer를 사용하여 state 로직 통합하기

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

  1. state 설정 관련 로직을 지우기
  2. 이벤트 핸들러를 통해 'action'을 전달하여 '사용자가 방금 한 일'을 지정
function handleDeleteTask(taskId) {
  setTasks(
    tasks.filter(t => t.id !== taskId)
  );
}

function handleDeleteTask(taskId) {
  dispatch(
    // "action" 객체:
    {
      type: 'deleted',
      id: taskId
    }
  );
}

action 객체는 어떤 형태든 될 수 있다.

하지만 발생한 일을 설명하는 문자열 type을 넘겨주고 이외의 정보는 다른 필드에 담아서 전달하도록 작성하는 게 일반적이다. type은 컴포넌트에 따라 값이 다르지만, 무슨 일이 일어나는지를 설명할 수 있으면 됨.

2. reducer 함수 작성하기

reducer는 첫 번째 인자에 현재 state(tasks) 선언, 두 번째 인자 action 선언, 마지막 state 반환.

여기서, reducer 함수 안에서는 switch문을 사용하는 게 규칙.

왜 reducer이라고 부르게 되었을까?

reducer를 사용하면 코드 양을 줄일 수 있지만, 실제로는 reduce() 연산의 이름에서 명명됨 실제로 react에 구현되어 있는 것 또한 reduce()랑 비슷하게 구현되어 있음

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

const [tasks, setTasks] = useState(initialTasks);
// 변경
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

reducer 함수와 초기 state 값을 받고, state를 담을 수 있는 값과 dispatch 함수를 반환한다.

📌 useState와 useReducer 비교하기

특징 useState useReducer
코드 크기 미리 작성해야 하는 코드가 줄어듦 여러 이벤트 핸들러에서 비슷하면 코드 양이 줄어듦
가독성 간단한 코드에 좋음 복잡한 코드에 좋음
디버깅 어려움 편하지만, 단계별 실행 필요
테스팅 보통 쉬움
개인적인 취향 많은 사람들이 알고 있음 싫어하는 사람이 좀 있음

📌 reducer 잘 작성하기

  • 반드시 순수해야 함
  • 각 action은 여러 변경들이 있더라도 하나의 상호작용을 해야 함
  • Immer 사용 시 간결한 reducer 작성 가능

6️⃣ context를 사용해 데이터를 깊게 전달하기

📌 prop 전달하기의 문제점

명시적으로 데이터를 전달하는 훌륭한 방법이긴 하나, 너무 장황하고 불편할 수 있음. 높게까지 state를 끌어올리는 것을 props drilling이라 부름.

바로 이때 순간이동 시킬 수 있는 방법이 있는데 바로 context이다.

📌 context: props 전달하기의 대안

  • context의 작동 방식은 css 속성 상속을 연상시킴
  • context는 서로 다른 context에 영향을 주지 않음

이 예시에서는 하위 컴포넌트가 context를 오버라이드할 수 있는 방법을 시각적으로 보여주기 때문에 제목 레벨을 사용. 하지만, context는 다른 많은 상황에서도 유용하다.

1. context 생성하기

import {createContext} from 'react';

export const LevelContext = createContext(1);

2. 데이터가 필요한 컴포넌트에서 context 사용하기

import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

function Heading({ children }) {
  const level = useContext(LevelContext);
  // ...
}

3. 데이터를 지정하는 컴포넌트에서 context 제공하기

import { LevelContext } from './LevelContext.js';

export default function Section({ children }) {
  return (
  <section className="section">
  <LevelContext value={level}>
    {children}
  </LevelContext>
  </section>
  );
}

이때 자동으로 하려면,

import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Section({ children }) {
  const level = useContext(LevelContext);
  return (
  <section className="section">
  <LevelContext value={level+1}>
    {children}
  </LevelContext>
  </section>
  );
}

📌 context를 사용하기 전에 고려할 것

context는 사용하기에도 쉽고 꽤 유혹적. 그러나 이는 남용하기도 쉽다는 뜻. 어떤 props를 여러 레벨 깊이로 전달해야 한다고 해서 해당 정보를 context에 넣어야 하는 것은 아님.

  • 대안

    1. prop로 전달하기
    2. 컴포넌트를 추출하고 JSX를 children으로 전달하기
  • context 사용 예시

    1. 테마 지정
    2. 현재 계정
    3. 라우팅
    4. 상태 관리

7️⃣ reducer와 context로 앱 확장하기

1. context 생성

//Context.js

import {createContext} from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

2. state와 dispatch 함수를 context에 넣기

// App.jsx
import { TasksContext, TasksDispatchContext } from './Context.js';

export default function App() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        ...
      </TasksDispatchContext>
    </TasksContext>
  );
}

3. 트리 안에서 context 사용하기

export default function AddTask(){
    const dispatch = useContext(TasksDispatchContext);
}

4. 하나의 파일로 합치기

reducer와 context를 하나의 파일로 합치는 것도 좋은 선택이 될 수 있다.

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        {children}
      </TasksDispatchContext>
    </TasksContext>
  );
}
export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

또한, context를 사용하기 위한 use 훅도 함께 내보낼 수 있다. 이런 함수들을 사용자 정의 Hook이라고 한다.