카테고리 없음

[React] state의 관리

불곰자리 2024. 8. 30. 02:46

애플리케이션이 성장함에 따라, state의 구성 방법이나 컴포넌트 간 데이터의 흐름에 대해 더욱 더 계산적으로 생각해야 한다. 버그의 일반적인 원리는, 설명하려면 길지만, 어쩌면 중복된 state이다. 이 글에선, state를 적절하게 구조화하는 방법, state 갱신 로직을 유지보수가 쉽도록 작성하는 방법, 그리고 분리된 컴포넌트 사이에 state를 공유하는 방법에 대해 알아볼 것이다.

이 글에서 배울 수 있는 것
1. UI의 변화를 state의 변화로 인식하는 방법
2. state를 적절히 구조화하는 방법
3. 컴포넌트 간 state를 공유하기 위해 상태를 "리프트 업(끌어올리기)"하는 방법
4. state가 유지될지 초기화될지 제어하는 방법
5. 복잡한 state 로직을 함수로 정리하는 방법
6. "props의 메우기 작업" 없이 정보를 전달하는 방법
7. 애플리케이션의 성장에 맞춰서 state 관리를 확장하는 방법

 

state를 사용해 사용자 입력에 반응하다

React를 사용할 경우, 코드에서 직접 UI를 변경하는 것은 불가능하다. 예를 들어 '버튼을 비활성화한다', '버튼을 활성화한다', '성공 메시지를 표시한다' 와 같은 명령어를 작성할 수는 없다. 대신, 컴포넌트의 여러 시각적인 상태(예를 들어 '초기 상태', '입력 중인 상태', '성공 후의 상태')에 대해 보여주고 싶은 UI를 기술해, 사용자의 입력에 응해 state를 변경을 일으킨다. 이것은, 디자이너가 UI를 생각하는 방법과도 비슷하다.

 

아래엔, React를 사용해 만든 퀴즈 폼이다. status라는 state 변수를 사용해 송신 버튼을 활성화할지 비활성화할지, 성공 메시지를 보여줄지 말지 결정하는 로직에 주목하자.

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Submit
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  // 네트워크 상으로 통신한 것 같은 결과를 반환 
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima'
      if (shouldError) {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

 

위 코드의 실행 결과물

 

한 글자 이상 입력 시 송신 버튼이 활성화된다
제출한 답이 정답일 때 다음과 같이 화면이 변한다
제출한 답이 오답일 때 메시지가 생성된다

 

state 구조의 선택

쾌적한 변경이나 디버깅을 수행하기 위해선 컴포넌트와, 일반적인 버그의 원인이 되는 컴포넌트의 차이는 state를 잘 구조화시킬 수 있을지이다. 제일 중요한 원칙은, state에 쓸데없는 정보나 중복된 정보를 쥐어주면 안된다. 불필요한 state가 있다면, 그걸 변경하는 걸 잊어 버그가 발생할 수 있다.

예를 들어, 이 폼은 쓸모가 없는 fullName이라는 state변수가 존재한다.

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
    setFullName(e.target.value + ' ' + lastName);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
    setFullName(firstName + ' ' + e.target.value);
  }

  return (
    <>
      <h2>Let’s check you in</h2>
      <label>
        First name:{' '}
        <input
          value={firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:{' '}
        <input
          value={lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

위의 코드 실행 결과물

컴포넌트가 렌더링 될 때 fullName을 계산함으로써, 쓸모없는 state를 삭제하여 코드를 구조화할 수 있다. 이건 작은 변화일지도 모르지만, React 애플리케이션의 수많은 버그들은 이런 식으로 수정될 수 있다.

 

컴포넌트 간 state 공유하기

두 개의 컴포넌트 state를 일반적으로 동시에 변경하고 싶은 경우가 있다. 이를 위해선, 양쪽의 컴포넌트에서 state를 삭제하고 두 컴포넌트가 공유하는 제일 가까운 거리의 부모 컴포넌트로 이동해, 거기서 state를 props로 경유하여 각각의 컴포넌트에 전달한다. 이건 state의 리프트 업(lifting state up)으로써 알려지기도 했으며, React 코드를 작성할 때 제일 일반적인 작업의 하나다.

 

이 예시에선, 한 번에 활성화되어야 하는 박스는 하나 뿐이다. 이걸 실현하기 위해선, 각 상자의 내부에 활성화 여부를 결정하는 state를 가지는 대신 부모 컴포넌트가 state를 가지고, 자식 컴포넌트의 props를 지정한다.

import { useState } from 'react';

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0); // 해당 state로 활성화할 박스의 인덱스(순서)를 관리
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel
        title="About"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel
        title="Etymology"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
      </Panel>
    </>
  );
}

function Panel({
  title,
  children,
  isActive,
  onShow
}) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>
          Show
        </button>
      )}
    </section>
  );
}

위 코드 처음 실행 결과물
밑의 박스의 버튼을 눌렀을 때 결과물

 

state의 유지와 초기화

컴포넌트를 재렌더링할 때, React는 트리의 어떤 부분을 보존(또는 변경)하고, 어떤 부분을 없앨지 또는 처음부터 재생성할지 결정할 필요가 있다. 대부분은 React가 자동으로 state의 변화에 따라 트리를 변경한다. 기본적으로, React는 이전에 렌더링된 컴포넌트 트리와 일치하는 부분의 트치를 보존한다.

 

다만, 경우에 따라 이것을 원하지 않을 경우도 있다. 이 채팅 애플리케이션에선 메시지를 입력한 후 전송 버튼을 눌러도, 입력 필드는 초기화되지 않는다. 이것에 대해, 사용자가 실수로 잘못해 상대에게 메시지를 전송하는 경우가 있을지도 모른다.

// App.js
import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat contact={to} />
    </div>
  )
}

const contacts = [
  { name: 'Taylor', email: 'taylor@mail.com' },
  { name: 'Alice', email: 'alice@mail.com' },
  { name: 'Bob', email: 'bob@mail.com' }
];
// Contact.js
export default function ContactList({
  selectedContact,
  contacts,
  onSelect
}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map(contact =>
          <li key={contact.email}>
            <button onClick={() => {
              onSelect(contact);
            }}>
              {contact.name}
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
// Chat.js
import { useState } from 'react';

export default function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={'Chat to ' + contact.name}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}

위 코드의 실행 결과물
전송 버튼 클릭 후 Alice를 클릭해도 메시지는 그대로다

예를 들어 <Chat key={email} />와 같이 서로 다른 key를 전달해 기본 동작을 대체하고, 컴포넌트의 state를 강제적으로 초기화하는 것이 가능하다. 이걸로, 전송 버튼이 각각 다른 경우 각각 다른 Chat 컴포넌트가 존재하고, 새로운 데이터(또는 입력 필드와 같은 것)을 처음부터 재생성할 필요가 있다면 React에 전달할 수도 있다. 이걸로, 모두 같은 컴포넌트를 렌더링해도, 전송 버튼을 변경하면 입력 필드가 초기화된다. 

// App.js
import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat key={to.email} contact={to} />
      {/* to가 변경되면 to.email도 변경된다. key가 변경되면 컴포넌트가 새로 만들어진다. */}
    </div>
  )
}

const contacts = [
  { name: 'Taylor', email: 'taylor@mail.com' },
  { name: 'Alice', email: 'alice@mail.com' },
  { name: 'Bob', email: 'bob@mail.com' }
];

 

state 로직을 리듀서로 추출하기

많은 이벤트 핸들러가 걸쳐있는 state의 변경 코드가 포함된 컴포넌트는, 이해하기 힘들 수 있다. 이런 경우, 컴포넌트 외부에 리듀서(reducer)라고 불리는 단일 함수를 작성하고, 모든 state 변경 로직을 집약할 수 있다. 이벤트 핸들러는, 사용자의 행동을 지정하는 역할만을 맡기 위해, 간결해진다. 아래의 예시에선 파일의 마지막에 존재하는 리듀서 함수가, 각 행동에 대한 state의 변경 방법을 지정하고 있다.

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

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}
      />
    </>
  );
}

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);
    }
  }
}

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 }
];

 

컨텍스트로 아래에 있는 데이터를 받아 전달하기

보통, 부모 컴포넌트에서 자식 컴포넌트에 props를 통해 정보를 전달한다. 다만, props를 다수의 중간 컴포넌트에 계속해서 전달하지 않으면 안되는 경우나, 애플리케이션 내의 많은 컴포넌트가 같은 정보를 필요로하는 경우, props를 받아 전달하는 것은 쓸모없고 불편할 수 있다. 컨텍스트(Context)를 사용함으로써, 부모 컴포넌트에서 props를 명시적으로 넘겨주지 않아도, 아래의 트리 내 임의 컴포넌트(깊이에 상관없이)가 정보를 받을 수 있다.

 

여기선, Heading 컴포넌트는, 제일 가까운 Section에 자신의 네스팅 레벨을 차례차례 탐색함으로써, 자신의 레벨을 결정한다. 각 Section, 부모의 Section에 레벨을 탐색해 그것에 1을 추가하는 것으로, 자기 자신의 레벨을 파악한다. 모든 Section은, props를 건네줄 필요 없이, 아래에 있는 모든 컴포넌트에 컨텍스트를 통해 정보를 제공한다.

// App.js
import Heading from './Heading.js';
import Section from './Section.js';

export default function Page() {
  return (
    <Section>
      <Heading>Title</Heading>
      <Section>
        <Heading>Heading</Heading>
        <Heading>Heading</Heading>
        <Heading>Heading</Heading>
        <Section>
          <Heading>Sub-heading</Heading>
          <Heading>Sub-heading</Heading>
          <Heading>Sub-heading</Heading>
          <Section>
            <Heading>Sub-sub-heading</Heading>
            <Heading>Sub-sub-heading</Heading>
            <Heading>Sub-sub-heading</Heading>
          </Section>
        </Section>
      </Section>
    </Section>
  );
}
// Section.js
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Section({ children }) {
  const level = useContext(LevelContext);
  return (
    <section className="section">
      <LevelContext.Provider value={level + 1}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}
// Heading.js
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Heading({ children }) {
  const level = useContext(LevelContext);
  switch (level) {
    case 0:
      throw Error('Heading must be inside a Section!');
    case 1:
      return <h1>{children}</h1>;
    case 2:
      return <h2>{children}</h2>;
    case 3:
      return <h3>{children}</h3>;
    case 4:
      return <h4>{children}</h4>;
    case 5:
      return <h5>{children}</h5>;
    case 6:
      return <h6>{children}</h6>;
    default:
      throw Error('Unknown level: ' + level);
  }
}
// LevelContext.js
import { createContext } from 'react';

export const LevelContext = createContext(0);

위 코드 실행 결과물

 

리듀서와 컨텍스트로 스케일 업

리듀서를 사용하면, 컴포넌트의 state 변경 로직을 코드 길이를 줄일 수 있다. 컨텍스트를 사용하면, 다른 컴포넌트의 깊이에 상관없이 정보를 전달할 수 있다. 그리고 리듀서와 컨텍스트를 조합해 복잡한 화면의 state 관리가 가능하게 할 수 있다. 

 

이런 접근 방법으로, 복잡한 state를 가진 부모 컴포넌트가, 리듀서를 사용해 state를 관리한다. 트리 내의 임의의 깊이에 존재하는 다른 컴포넌트는, 컨텍스트를 매개체로 state를 읽어낼 수 있다. 또, 이 state를 변경하기 위해선 액션(행위)을 디스패치(포착)할 수 있다. 

// App.js
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}
// TasksContext.js
import { createContext, useContext, useReducer } from 'react';

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

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

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

export function useTasks() {
  return useContext(TasksContext);
}

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

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);
    }
  }
}

const initialTasks = [
  { id: 0, text: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];
// AddTask.js
import { useState, useContext } from 'react';
import { useTasksDispatch } from './TasksContext.js';

export default function AddTask({ onAddTask }) {
  const [text, setText] = useState('');
  const dispatch = useTasksDispatch();
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        dispatch({
          type: 'added',
          id: nextId++,
          text: text,
        });
      }}>Add</button>
    </>
  );
}
// TaskList.js
import { useState, useContext } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...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 => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Delete
      </button>
    </label>
  );
}

위 코드 실행 결과

 

참고글

React의 원리 - React 공식 문서