React 25. Scaling Up with Reducer and Context

뚜루미·2024년 4월 3일

React

목록 보기
25/39
post-thumbnail

Reducer를 사용하면 컴포넌트의 상태 업데이트 로직을 통합할 수 있습니다. Context를 사용하면 다른 컴포넌트들에 정보를 전달할 수 있습니다. Reducer와 context를 함께 사용하여 복잡한 화면의 상태를 관리할 수 있습니다.

Combining a reducer with context

Reducer의 개요에서 reducer로 상태를 관리하는 방법에 대해 알아보았습니다. 해당 예시에서 상태 업데이트 로직을 모두 포함하는 reducer 함수를 App.js 파일의 맨 아래에 선언했습니다.

// App.js
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>Day off in Kyoto</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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

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

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

Reducer를 사용하면 이벤트 핸들러를 간결하고 명확하게 만들 수 있습니다. 그러나 앱이 커질수록 다른 어려움에 부딪힐 수 있습니다. 현재 tasks 상태와 dispatch 함수는 최상위 컴포넌트인 TaskBoard 에서만 사용할 수 있습니다. 다른 컴포넌트들에서 tasks의 리스트를 읽고 변경하려면 prop을 통해 현재 상태와 상태를 변경할 수 있는 이벤트 핸들러를 명시적으로 전달해야 합니다.

예를 들어, 아래 TaskApp 컴포넌트에서 TaskList 컴포넌트로 task 리스트와 이벤트 핸들러를 전달합니다

<TaskList
  tasks={tasks}
  onChangeTask={handleChangeTask}
  onDeleteTask={handleDeleteTask}
/>

그리고 TaskList 컴포넌트에서 Task 컴포넌트로 이벤트 핸들러를 전달합니다

<Task
  task={task}
  onChange={onChangeTask}
  onDelete={onDeleteTask}
/>

지금처런 간단한 예시에서는 잘 동작하지만, 수십 수백개의 컴포넌트를 거쳐 상태나 함수를 전달하기는 쉽지 않습니다.

그래서 tasks 상태와 dispatch 함수를 props를 통해 전달하는 대신 context에 넣어서 사용하고 싶을 겁니다. 그러면 반본적인 prop drilling 없이 TaskBoard 아래의 모든 컴포넌트 트리에서 tasks를 읽고 dispatch 함수를 실행할 수 있습니다.

Reducer와 context를 결합하는 방법은 아래와 같습니다

  1. Context를 생성한다
  2. 상태와 dispatch 함수를 context에 넣는다
  3. 트리 안에서 context를 사용한다

Step 1: Create the context

useReducer 훅은 현재 tasks 와 업데이트할 수 있는 dispatch 함수를 반환합니다.

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

트리를 통해 전달하려면, 두 개의 별도의 context를 생성해야 합니다.

  • TasksContext 는 현재 tasks 리스트를 제공합니다.
  • TasksDispatchContext 는 컴포넌트에서 action 을 dispatch 하는 함수를 제공합니다.

두 context는 나중에 다른 파일에서 가져올 수 있도록 별도의 파일에서 내보냅니다.

// TasksContext.js
import { createContext } from 'react';

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

두 개의 context는 모두 기본값을 null 로 전달하고 있습니다. 실제 값은 TaskBoard 컴포넌트에서 제공합니다.

Step 2: Put state and dispatch into context

이제 TaskBoard 컴포넌트에서 두 context를 모두 불러올 수 있습니다. useReducer() 를 통해 반환된 tasksdispatch 를 받고 아래 트리 전체에 전달합니다 :

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

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  // ...
  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        ...
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

지금은 props와 context를 모두 이용하여 정보를 전달하고 있습니다.

다음 단계에서 이제 prop을 통한 전달을 제거합니다.

Step 3: Use context anywhere in the tree

이제 tasks 리스트나 이벤트 핸들러를 트리 아래로 전달할 필요가 없습니다:

<TasksContext.Provider value={tasks}>
  <TasksDispatchContext.Provider value={dispatch}>
    <h1>Day off in Kyoto</h1>
    <AddTask />
    <TaskList />
  </TasksDispatchContext.Provider>
</TasksContext.Provider>

대신 필요한 컴포넌트에서 TaskContext 에서 task 리스트를 읽어올 수 있습니다

export default function TaskList() {
  const tasks = useContext(TasksContext);
  // ...

task 리스트를 업데이트하기 위해서 컴포넌트에서 context의 dispatch 함수를 읽고 호출합니다.

export default function AddTask() {
  const [text, setText] = useState('');
  const dispatch = useContext(TasksDispatchContext);
  // ...
  return (
    // ...
    <button onClick={() => {
      setText('');
      dispatch({
        type: 'added',
        id: nextId++,
        text: text,
      });
    }}>Add</button>
    // ...

TaskBoard 컴포넌트는 자식 컴포넌트에, TaskListTask 컴포넌트에 이벤트 핸들러를 전달하지 않습니다. 각 컴포넌트에서 필요한 context를 읽을 수 있습니다.

상태와 상태를 관리하는 useReducer 는 여전히 최상위 컴포넌트인 TaskBoard 에 있습니다. 그러나 tasksdispatch 는 하위 트리 컴포넌트 어디서나 context를 불러와서 사용할 수 있습니다.

Moving all wiring into a single file

반드시 이런 방식으로 작성하지 않아도 되지만, reduce와 context를 모두 하나의 파일에 작성하면 컴포넌트들을 조금 더 정리할 수 있습니다. 현재, TasksContext.js 는 두 개의 context만을 선언하고 있습니다

import { createContext } from 'react';

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

이제 이 파일이 좀 더 복잡해질 예정입니다. Reducer를 같은 파일로 옮기고 TasksProvider 컴포넌트를 새로 선언합니다. 이 컴포넌트는 모든 것을 하나로 묶는 역할을 하게 됩니다.

  1. Reducer로 상태를 관리합니다.
  2. 두 context를 모두 자식 컴포넌트에 제공합니다.
  3. children을 prop으로 받기 때문에 JSX를 전달할 수 있습니다.
export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

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

이렇게 하면 TaskBoard 컴포넌트에서 복잡하게 얽혀있던 부분을 깔끔하게 정리할 수 있습니다.

TasksContext.js 에서 context를 사용하기 위한 use 함수들도 내보낼 수 있고 그렇게 만들어진 함수를 사용하여 컴포넌트에서 context를 읽을 수 있습니다.

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

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

const tasks = useTasks();
const dispatch = useTasksDispatch();

이렇게 하면 동작이 바뀌는 건 아니지만, 다음에 context를 더 분리하거나 함수들에 로직을 추가하기 쉬워집니다. 이제 모든 context와 reducer는 TasksContext.js 에 있습니다. 이렇게 컴포넌트들이 데이터를 어디서 가져오는지 아닌 무엇을 보여줄 것인지에 집중할 수 있도록 깨끗하게 정리할 수 있습니다.

// 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 } from 'react';
import { useTasksDispatch } from './TasksContext.js';

export default function AddTask() {
  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>
    </>
  );
}

let nextId = 3;

// TaskList.js
import { useState } 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>
  );
}

TasksProvider 는 tasks를 화면의 한 부분으로 tasks를 관리합니다. useTasks 로 tasks를 읽을 수 있고, useTaskDispatch 로 컴포넌트들에서 tasks를 업데이트할 수 있습니다.

useTasksuseTasksDispatch 같은 함수들을 커스텀 훅이라고 합니다. 이름이 use 로 시작되는 함수들은 커스텀 훅입니다. 커스텀 훅 안에서도 useContext 등 다른 훅을 사용할 수 있습니다.

앱이 커질수록 context-reducer 조합이 더 많아질 겁니다. 앱을 확장하고 큰 노력없이 트리 아래에서 데이터에 접근할 수 있도록 상태를 끌어올리기 위한 강력한 방법이기 때문입니다.

0개의 댓글