useState에서 useReducer로 가는 길

sucream·2023년 2월 4일
1

react

목록 보기
5/9

useState의 단점

우리가 작성하는 컴포넌트에 복잡한 로직이 들어갈 수록, 컴포넌트 내의 state를 처리하는 로직에 대한 관리가 어려워진다. 아래 예제를 보자.

// AddLanguage 컴포넌트
import { useState } from "react";

export default function AddLanguage({ onAddLanguage }) {
  const [text, setText] = useState("");
  return (
    <>
      <input
        placeholder="추가할 언어를 입력하세요."
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button
        onClick={() => {
          setText("");
          onAddLanguage(text);
        }}
      >
        추가
      </button>
    </>
  );
}
// Language, LanguageList 컴포넌트
import { useState } from "react";

function Language({ language, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let languageContent;
  if (isEditing) {
    languageContent = (
      <>
        <input
          value={language.text}
          onChange={(e) => {
            onChange({
              ...language,
              text: e.target.value
            });
          }}
        />
        <button onClick={() => setIsEditing(false)}>저장</button>
      </>
    );
  } else {
    languageContent = (
      <>
        {language.text}
        <button onClick={() => setIsEditing(true)}>편집</button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={language.done}
        onChange={(e) => {
          onChange({
            ...language,
            done: e.target.checked
          });
        }}
      />
      {languageContent}
      <button onClick={() => onDelete(language.id)}>삭제</button>
    </label>
  );
}

export default function LanguageList({
  languages,
  onChangeLanguage,
  onDeleteLanguage
}) {
  return (
    <ul>
      {languages.map((language) => (
        <li key={language.id}>
          <Language
            language={language}
            onChange={onChangeLanguage}
            onDelete={onDeleteLanguage}
          />
        </li>
      ))}
    </ul>
  );
}
// App 컴포넌트
import { useState } from "react";
import AddLanguage from "./AddLanguage";
import LanguageList from "./LanguageList";

let nextId = 3;
const initialLanguages = [
  { id: 0, text: "react", done: true },
  { id: 1, text: "vue", done: false },
  { id: 2, text: "angular", done: false }
];

export default function App() {
  const [languages, setLanguages] = useState(initialLanguages);

  /**
   * 새로운 언어를 추가할때 사용하는 핸들러
   * @param {string} text - 새로 추가할 텍스트
   */
  function addLanguageHandler(text) {
    setLanguages([
      ...languages,
      {
        id: nextId++,
        text: text,
        done: false
      }
    ]);
  }

  /**
   * 리스트 내의 특정 항목을 변경할때 사용하는 핸들러
   * @param {{id: number, text: string, done: boolean}} language - 변경하고자 하는 language 객체 정보
   */
  function changeLanguageHandler(language) {
    // map으로 순회하며 변경이 필요한 데이터만 인자로 받은 값으로 치환
    setLanguages(
      languages.map((l) => {
        if (l.id === language.id) {
          return language;
        } else {
          return l;
        }
      })
    );
  }

  /**
   *
   * @param {int} languageId - 삭제하고자 하는 language id
   */
  function deleteLanguageHandler(languageId) {
    // filter를 사용하여 id가 languageId와 다른 데이터만 남김
    setLanguages(languages.filter((l) => l.id !== languageId));
  }

  return (
    <>
      <h1>언어 목록</h1>
      <AddLanguage onAddLanguage={addLanguageHandler} />
      <LanguageList
        languages={languages}
        onChangeLanguage={changeLanguageHandler}
        onDeleteLanguage={deleteLanguageHandler}
      />
    </>
  );
}

위 코드는 새로운 입력을 받아 핸들러를 호출하는 AddLanguage 컴포넌트, 출력할 languageList를 받아 출력하고 관리할 수 있는 LanguageLanguageList 컴포넌트, 이들을 감싸고 실제 데이터 및 핸들러를 가지고 있는 App컴포넌트로 이루어져 있다.

그렇게 복잡한 코드는 아니지만, 우리는 languages라는 state를 관리하기 위해 각기 다른 핸들러를 만들고, 이들을 호출하고 있다. 만약 각각의 핸들러들이 하는 역할이 더 많아지고 핸들러의 갯수가 늘어나면 어떻게 될까? 아마 관리가 힘들 것이다. 이럴때 useReducer를 사용하는 것이 하나의 해결책이 될 수 있다.

useReducer

useReducer를 이용해서 위 문제를 어떻게 해결하는 것일까? 우선 useReducer를 쓴다는 것이 useState를 사용하지 말아야 한다는 얘기는 아니다. 분명 useState를 사용하는 것이 더 현명한 선택일 때가 있다. 일반적으로 복잡한 생태관리가 필요하지 않다면 useState로도 충분할 수도 있다. 하지만 우리 컴포넌트들이 점점 복잡해지고 상태를 관리하는 로직이 복잡해지면 자연스럽게 useReducer를 찾게 될 것이다. useReducer의 핵심 개념은 state를 처리하는 로직을 컴포넌트 외부로 빼는 것이다. 이를 통해 복잡한 state 관리 로직 부분과 컴포넌트를 구성하는 부분을 분리하여 효율적인 코드 관리가 가능해 진다.

useReducer를 이루는 개념

useReducer는 크게dispatch, action, reducer, state로 구성된다. 아래에서 useState와 useReducer의 차이를 확인해 보자.

// useState 예시
const [state, setState] = useState(initialState);

// useReducer 예시
const [state, dispatch] = useReducer(reducer, initialState);

우리가 지금까지 사용하던 useState는 useState함수에 해당 state가 가져야 할 초기 상태값을 지정하였다. 이후 setState 함수를 통해 우리가 원하는 값/함수를 전달하여 state가 업데이트되도록 했다. useReducer도 state의 초깃값을 받는 부분은 동일하다. 하지만 reducer라고 하는 함수를 같이 받는다. 그리고 statedispatch를 반환한다.

Action

위 코드에서 우리는 addLanguageHandler, changeLanguageHandler, deleteLanguageHandler에서 state를 관리했다. useReducer를 효율적으로 사용하기 위해서는 적절한 action을 정의하는 것이 중요하다.
action이란 state를 관리하는 로직에 요구사항 명 및 로직 처리에 필요한 데이터를 정의하는 것이다. 이러한 action은 향후 reducer가 받아 처리할 때 중요한 정보가 된다.
Action을 작성할 때 몇가지 규칙이 있다.

  • 가능하면 객체로 작성하기
  • 객체에는 type으로 각 액션을 구분하기
  • 각 액션은 대문자와 _(언더바)로 구분하기

ADD_LANGUAGE
우리는 새로운 언어를 등록하기 위한 액션을 정의할 수 있다. 새로운 언어를 목록에 추가하기 위해서는 id, text가 필요할 것이다. 이를 관리하기 편하게 객체로 나타내면 다음과 같을 것이다.

{
  type: 'ADD_LANGUAGE',
  id: nextId++,
  text: text,
}

CHANGE_LANGUAGE
우리는 특정 언어의 정보를 변경하는 액션을 정의할 수 있다. 특정 언어의 정보를 변경하기 위해서는 해당 언어의 대한 정보가 필요할 것이다. 이를 관리하기 편하게 객체로 나타내면 다음과 같을 것이다.

{
  type: 'CHANGE_LANGUAGE',
  language: language,
}

DELETE_LANGUAGE
우리는 특정 언어를 목록에서 제거하는 액션을 정의할 수 있다. 목록에서 특정 언어를 지우기 위해서는 해당 언어의 id가 필요할 것이다. 이를 관리하기 편하게 객체로 나타내면 다음과 같을 것이다.

{
  type: 'DELETE_LANGUAGE',
  id: languageId
}

dispatch

위에서 useReducer 함수를 통해 반환되는 변수는 state와 dispatch 두가지다. 여기서 dispatch는 useState의 setState와 동일한 역할을 하는데, 이름 그대로 무언가를 전달하는 역할을 할 것이다. 그렇다면 dispatch가 전달한다는 것은 '누구'에게 '무엇'을 전달하는 지가 중요할 것이다. 이는 다음과 같다.

  • 누구에게? reducer 함수에게
  • 무엇을? action을
const addLanguageHandler = (text) => {
  // 생성을 위해 필요한 action을 전달하며 dispatch 호출
  languageDispatch({
    type: 'ADD_LANGUAGE',
    id: nextId++,
    text: text,
  });
};

const changeLanguageHandler = (language) => {
  // 수정을 위해 필요한 action을 전달하며 dispatch 호출
  languageDispatch({
    type: 'CHANGE_LANGUAGE',
    language: language,
  });
};

const deleteLanguageHandler = (languageId) => {
  // 삭제를 위해 필요한 action을 전달하며 dispatch 호출
  languageDispatch({
    type: 'DELETE_LANGUAGE',
    id: languageId
  });
};

기존의 핸들러들과 다른 점은 state를 처리하는 로직이 아예 사라지고 dispatch가 해당 부분을 대체했다는 것이다. 이를 통해 컴포넌트를 좀 더 작고 유연하게 관리할 수 있다.

reducer

위에서 useReducer를 사용하는 가장 큰 이유 중 하나가 state를 처리하는 로직을 분리하는 것이라고 했다. 이 reducer 함수가 해당 state를 처리하는 로직에 해당한다. 따라서 우리는 특정 규격에 맞는 함수를 작성하기만 하면 state를 자유롭게 관리할 수 있다.

const languageReducer = (languages, action) => {
	switch (action.type) {
      case 'ADD_LANGUAGE': {
      	return [
          ...languages,
          {
            id: action.id,
            text: action.text,
            done: false,
          },
        ];
      }
      case 'CHANGE_LANGUAGE': {
      	return languages.map((l) => {
          if (l.id === action.language.id) {
            return action.language;
          } else {
            return l;
          }
        });
      }
      case 'DELETE_LANGUAGE': {
        return languages.filter((l) => l.id !== action.id);
      }
      default: {
        throw Error('Unknown action:' + action.type);
      }
    }
};

위 코드에서 someReducer라는 이름의 reducer를 생성했다. 이 reducer는 stateaction이라는 두가지를 입력받는다.

  • state: state의 가장 최신 값이다.
  • action: reducer에 전달되는 액션으로, 사용자가 원하는 데이터를 전달할 수 있다.

즉 state의 변경이 필요할 때 react가 우리가 작성한 reducer를 호출하며 최신 상태값과 특정 액션을 전달해 준다.
reducer를 작성할 때 몇가지 주의할 점이 있다.
1. reducer 함수는 순수한 자바스크립트 함수여야 한다.

  • reducer는 setState와 마찬가지로 컴포넌트가 렌더링되는 과정에 실행되기 떄문에 의도하지 않은 결과를 초래할 수 있다. 따라서 서버로 요청을 보내거나 하는 등의 작업을 추가하지 않도록 주의한다.
  1. 변경 없이 객체나 배열을 업데이트해야 한다.
  • reducer는 state와 action을 받는데, state.name = 'newName';이나 state.push({id: 3, name: 'newName'}) 등으로 작성하면 렌더링이 반영되지 않는 것을 확인할 수 있다. 리액트는 실제 돔에 변경 사항을 반영하기 위해 다른 데이터가 들어가 있음을 파악해야 하는데, 참조형 데이터들은 참조하는 값이 변경된 것이 아니기 때문에 변화가 없는 모습을 볼 수 있다. 따라서 새로운 값을 반환하는 형태로 작성해야 한다.

useReducer로 전환

// App 컴포넌트
import { useReducer } from "react";
import AddLanguage from "./AddLanguage";
import LanguageList from "./LanguageList";

let nextId = 3;
const initialLanguages = [
  { id: 0, text: "react", done: true },
  { id: 1, text: "vue", done: false },
  { id: 2, text: "angular", done: false }
];

const languageReducer = (languages, action) => {
  switch (action.type) {
    case "ADD_LANGUAGE": {
      return [
        ...languages,
        {
          id: action.id,
          text: action.text,
          done: false
        }
      ];
    }
    case "CHANGE_LANGUAGE": {
      return languages.map((l) => {
        if (l.id === action.language.id) {
          return action.language;
        } else {
          return l;
        }
      });
    }
    case "DELETE_LANGUAGE": {
      return languages.filter((l) => l.id !== action.id);
    }
    default: {
      throw Error("Unknown action:" + action.type);
    }
  }
};

export default function App() {
  // useState -> useReducer
  const [languages, languageDispatch] = useReducer(
    languageReducer,
    initialLanguages
  );

  // state를 관리하는 로직을 다 컴포넌트 외부로 뺄 수 있음
  // reducer가 순수 자바스크립트 함수이기 때문에 가능
  const addLanguageHandler = (text) => {
    // 생성을 위해 필요한 action을 전달하며 dispatch 호출
    languageDispatch({
      type: "ADD_LANGUAGE",
      id: nextId++,
      text: text
    });
  };

  const changeLanguageHandler = (language) => {
    // 수정을 위해 필요한 action을 전달하며 dispatch 호출
    languageDispatch({
      type: "CHANGE_LANGUAGE",
      language: language
    });
  };

  const deleteLanguageHandler = (languageId) => {
    // 삭제를 위해 필요한 action을 전달하며 dispatch 호출
    languageDispatch({
      type: "DELETE_LANGUAGE",
      id: languageId
    });
  };

  return (
    <>
      <h1>언어 목록</h1>
      <AddLanguage onAddLanguage={addLanguageHandler} />
      <LanguageList
        languages={languages}
        onChangeLanguage={changeLanguageHandler}
        onDeleteLanguage={deleteLanguageHandler}
      />
    </>
  );
}

위에서 설명한 내용들을 한번에 정리한 것이다. 여기서 재밌는 점은 languageReducer 함수는 순수 자바스크립트 함수기 때문에, 컴포넌트의 외부에 존재할 수 있고, 심지어 다른 경로의 다른 파일에 위치해도 아무런 문제가 없다.

정리

정리를 해보면,
1. 상태를 관리할 action들을 정의
2. 각 action에 따라 state를 관리하는 방법을 정의한 reducer 함수 정의
3. reducer와 initState를 이용해 useReducer로 새로운 state와 dispatch를 생성
4. dispatch에 적절한 action을 넘겨주어 reducer로 전달
5. reducer가 action에 따라 state 관련 로직을 처리

Reference

profile
작은 오븐의 작은 빵

0개의 댓글