본문 바로가기

FrontEnd

Refactoring React(리팩토링 리액트) : useState

반응형

react logo

복잡한 상태는 유지보수를 어렵게 하는 원인입니다.

상태를 리팩토링하여 애플리케이션의 확장성을 향상시켜 봅니다.

해당 게시물의 목표는 다음과 같습니다.

  • 코드를 더 쉽게 읽고 유지보수 쉽게 하기
  • 버그에 덜 취약한 코드 만들기
  • 코드의 복잡성 제거하기
  • 애플리케이션 성능 향상시키기

1. 너무 많은 상태(Redundant State)

fullName은 firstName과 lastName으로부터 파생됩니다.

  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

해당 파생 상태는 의존성을 개발자가 직접 추적하여 업데이트를 같이 수행해야 한다는 점입니다.

firstName과 lastName을 변경하는 곳에서는 항상 setFullName이 따라다녀야 합니다.

불필요한 코드 수가 중가하며 유지보수하기도 어려워집니다.

  const onChangeFirstName = (event) => {
    setFirstName(event.target.value);
    setFullName(`${event.target.value} ${lastName}`);
  };
  const onChangeLastName = (event) => {
    setLastName(event.target.value);
    setFullName(`${firstName} ${event.target.value}`);
  };
주 : 위와 같이 이벤트 핸들러 내에서 다른 상태 업데이트를 호출하면,
리액트 18 이전 버전에서는 렌더링이 두번 된다 합니다.
18 이후는 배치 처리됩니다.

해결 방법 : 파생 상태 (derived state)

아래와 같이 파생 상태를 사용합니다.

파생 상태를 만드는 연산이 비싸면 메모합니다.

  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const fullName = `${firstName} ${lastName}`;
  
  // or
  
  const useFullName = ()=>{
    const [firstName, setFirstName] = useState("");
	const [lastName, setLastName] = useState("");
  	return React.useMemo(()=>{firstName,lastName,fullName:`${firstName} ${lastName}`},[firstName,lastName]);
  }

좀 더 실용적인 예제는 아래의 선택 상자입니다.

토글 상자

아래와 같이 상태를 설계해서 앱에 복잡성을 추가하는 경향이 있습니다.

  const [checkedIds, setCheckedIds] = useState([]);
  const [isEveryItemSelected, setIsEveryItemSelected] = useState(false);
  const [numSelected, setNumSelected] = useState(0);

실제론 선택된 아이디만 있으면 어떤 아이디가 선택되었는지, 전부 선택되었는지 즉시 계산해서 구할 수 있습니다.

여전히 해당 연산이 비싸다면 메모하면 됩니다.

  const [checkedIds, setCheckedIds] = useState([]);

각 아이디가 체크되었는지는 다음과 같이 판단합니다.

개별 선택상자를 클릭하면 전체 목록에 아이디가 있는지 없는지에 따라 filter ,concat을 적용하면 됩니다.

   checked={checkedIds.includes(id)}

전체 상자 선택 여부는 아래와 같이 즉시 계산합니다.

event.target.checked를 통해 전체 상자가 체크되어있으면 토글 로직을 수행하면 됩니다.

checked={checkedIds.length === items.length}

2. 중복된 상태(Duplicate State)

어떤 상태를 복사해서 다른 상태를 만드는 경우, 개발자는 양방향으로 변경 사항을 반영하는 로직을 작성해야 합니다.

1번 불필요한 상태에서도 유사한 문제가 발생했습니다.

사실 fullName은 firstName과 lastName의 중복 상태나 마찬가지입니다.

이는 단일 출처 원칙을 위반합니다.

 

아래와 같은 모달이 있다고 가정해보죠.

모달

선택된 데이터를 상태에 담아두었습니다. 어떤 문제가 발생할까요?

아래와 같이 상태에 선택한 객체를 통째로 담아두면, prop인 items가 변경되었을 때 selectedItem이 변경되지 않습니다.

import { useState } from "react";

// const items = [
//   {
//     id: "item-1",
//     text: "Item 1",
//   },
//   ...
// ]

function DuplicateState({ items }) {
  const [selectedItem, setSelectedItem] = useState();

  const onClickItem = (item) => {
    setSelectedItem(item);
  };

  return (
    <>
      {selectedItem && <Modal item={selectedItem} />}
      <ul>
        {items.map((row) => (
          <li key={row.id}>
            {row.text}
            <button onClick={() => onClickItem(row)}>Open</button>
          </li>
        ))}
      </ul>
    </>
  );
}

이와 같이 여러 곳에서 변경을 동기화해야 한다면 어떤 접근법을 취하는 것이 좋을까요?

해결 방법 : ID만 추적

어떤 가변 객체가 존재한다면, 해당 아이디의 식별자만 상태에 담아둡니다.

모달로 ID만 전달하세요.

그러면 selectedItemId가 바뀌지 않더라도 seletedItem은 변경됩니다.

// const items = [
//   {
//     id: "item-1",
//     text: "Item 1",
//   },
//   ...
// ]

function DuplicateState({ items }) {
  const [selectedItemId, setSelectedItemId] = useState();
  const selectedItem = items.find(({ id }) => id === selectedItemId);

  const onClickItem = (itemId) => {
    setSelectedItemId(itemId);
  };

  return (
    <>
      {selectedItem && <Modal item={selectedItem} />}
      <ul>
        {items.map((row) => (
          <li key={row.id}>
            {row.text}
            <button onClick={() => onClickItem(row.id)}>Open</button>
          </li>
        ))}
      </ul>
    </>
  );
}

 

다른 예제 보기 : https://codesandbox.io/s/xgnc8g?file=%2FSolution.jsx&utm_medium=sandpack 


3. useEffect로 상태 업데이트

2번의 모달 예제를 다음과 같이 고칠 수 있습니다. 무슨 문제가 있을까요?

import { useEffect, useState } from "react";

// const items = [
//   {
//     id: "item-1",
//     text: "Item 1",
//   },
//   ...
// ]

function DuplicateState({ items }) {
  const [selectedItem, setSelectedItem] = useState();

  useEffect(() => {
    if (selectedItem) {
      setSelectedItem(items.find(({ id }) => id === selectedItem.id));
    }
  }, [items]);

  const onClickItem = (item) => {
    setSelectedItem(item);
  };

  return (
    <>
      {selectedItem && <Modal item={selectedItem} />}
      <ul>
        {items.map((row) => (
          <li key={row.id}>
            {row.text}
            <button onClick={() => onClickItem(row)}>Open</button>
          </li>
        ))}
      </ul>
    </>
  );
}
  1. useEffect는 이해하기 쉽지 않습니다. 적을수록 좋습니다.
  2.  useEffect 내 상태 업데이트는 리렌더링을 유발합니다. 불필요한 리렌더링이 발생할 수 있습니다.
    1. 일반적으로 성능 측면에서 큰 문제는 아니지만 고려해야 할 사항입니다.
  3. 상태와 prop 사이에 숨은 관계를 도입합니다.
  4. 원할 때 useEffect 내부의 코드를 트리거하기 어려울 수 있습니다.

4번과 같은 문제 때문에 나오는 해킹의 예시입니다.

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }
    setSelectedItem(items.find(({ id }) => id === selectedItem.id));
  }, [items]);

useEffect는 정말 필요할 때만 사용해야 하며, 캡슐화 되어야 합니다.

해결 방안

2번과 동일합니다.


4. useEffect로 상태 업데이트 리스닝

아래와 같이 isDetailsVisible 토글 때마다 화면을 숨기고 이벤트를 보고하는 코드가 있습니다.

import { useEffect, useState } from "react";

function ProductView({ name, details }) {
  const [isDetailsVisible, setIsDetailsVisible] = useState(false);

  useEffect(() => {
    trackEvent({ event: "Toggle Product Details", value: isDetailsVisible });
  }, [isDetailsVisible]);

  const toggleDetails = () => {
    setIsDetailsVisible(!isDetailsVisible);
  };

  return (
    <div>
      {name}
      <button onClick={toggleDetails}>Show details</button>
      {isDetailsVisible && <ProductDetails {...details} />}
    </div>
  );
}

문제가 뭘까요?

  1. useEffect는 이해하기 어렵습니다.
  2. useEffect 내 상태 업데이트는 리렌더링을 유발합니다. 불필요한 리렌더링이 발생할 수 있습니다.
  3. 렌더링 생명 주기와 관련된 버그를 도입하기 쉽습니다.
    •  초기 렌더링 중에 trackEvent를 실행하는 버그가 있습니다.
  4. 함수 실행 원인이 의도와 다릅니다.
    • isDetailsVisible이 변경되기 때문에 trackEvent가 실행됩니다.
    • 우리가 진짜 원하는 시점은 사용자가 "세부정보 보기" 버튼을 눌렀을 떄입니다.

해결 방안 : 효과와 상태 나란히 배치

상태를 업데이트하는 코드와 그 상태와 관련된 효과를 같이 배치하세요.

아래는 사용자의 클릭이라는 효과가 관련 상태 업데이트와 관련 효과 트리거를 동시에 수행하고 있습니다.

  const toggleDetails = () => {
    setIsDetailsVisible(!isDetailsVisible);
    trackEvent({ event: "Toggle Product Details", value: !isDetailsVisible });
  };

유사한 실수는 다음과 같습니다.

비동기 데이터와 렌더링 대상 데이터를 분리되어있고, 비동기 데이터의 변경에 따라 렌더링 대상 데이터를 설정하고 있네요.

즉 상태 업데이트(비동기 데이터 상태 업데이트)가 상태 업데이트 효과(렌더링 대상 상태 업데이트)를 유발하고 있습니다.

const [data, setData] = useState(null);
const [posts, setPosts] = useState([]);

useEffect(() => {
fetch("https://www.reddit.com/r/javascript/top.json?t=day&limit=10")
  .then((response) => response.json())
  .then(({ data }) => setData(data));
}, []);

useEffect(() => {
if (!data) {
  return;
}
const mappedPosts = data.children.map((post) => post.data);
setPosts(mappedPosts);
}, [data]);

우리의 목표는 비동기로 받아온 데이터를 렌더링하는 것입니다.

즉 비동기 효과와 렌더링 대상 상태를 같이 배치해야 합니다.

비동기 데이터 가져오기 효과가 즉시 렌더링 대상 데이터 업데이트를 유발하도록 나란히 배치합니다.

  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch("https://www.reddit.com/r/javascript/top.json?t=day&limit=10")
      .then((response) => response.json())
      .then(({ data }) => {
        // transform the data directly after fetching it
        const mappedPosts = data.children.map((post) => post.data);
        setPosts(mappedPosts);
      });
  }, []);

https://codesandbox.io/s/8fm07i?file=%2FChallenge.jsx&utm_medium=sandpack


5. 모순적인 상태(Contradicting State)

쉽게 볼 수 있는 코드입니다. 뭐가 문제일까요?

export function ContradictingState() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    setError(null);

    fetchData()
      .then((data) => {
        setData(data);
        setIsLoading(false);
      })
      .catch((error) => {
        setIsLoading(false);
        setData(null);
        setError(error);
      });
  }, []);

논리적으로 에러가 있는데 로딩중이거나 데이터가 있으면 안됩니다.

위 코드는 해당 상태가 불가능함을 증명할 수 없습니다.

그리고 총 가능한 상태는 2 *2 *2 = 8가지 입니다.

해결 방안 : reducer + 관련 있는 상태 하나로 합치기

make illigal state unrepresentable 입니다.

아래 코드는 가능한 상태의 조합이 3개 뿐임이 확실하게 드러납니다.

const initialState = {
  data: [],
  error: null,
  isLoading: false
};

function reducer(state, action) {
  switch (action.type) {
    case "FETCH":
      return {
        ...state,
        error: null,
        isLoading: true
      };
    case "SUCCESS":
      return {
        ...state,
        error: null,
        isLoading: false,
        data: action.data
      };
    case "ERROR":
      return {
        ...state,
        isLoading: false,
        error: action.error
      };
    default:
      throw new Error(`action "${action.type}" not implemented`);
  }
}

export function NonContradictingState() {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    dispatch({ type: "FETCH" });
    fetchData()
      .then((data) => {
        dispatch({ type: "SUCCESS", data });
      })
      .catch((error) => {
        dispatch({ type: "ERROR", error });
      });
  }, []);

유사한 예제 : https://codesandbox.io/s/1nbt4l?file=%2FSolution.jsx&utm_medium=sandpack 

 

gallant-orla-1nbt4l - CodeSandbox

gallant-orla-1nbt4l using react, react-dom, react-scripts, react-tabs

codesandbox.io

6. 깊게 중첩된 상태(Deeply Nested State)

아래와 같이 중첩된 상태가 있다 가정해 봅시다. 뭐가 문제일까요?

  const [comments, setComments] = useState([
    {
      id: "1",
      text: "Comment 1",
      children: [
        {
          id: "11",
          text: "Comment 1 1"
        },
        {
          id: "12",
          text: "Comment 1 2"
        }
      ]
    },
    {
      id: "2",
      text: "Comment 2"
    },
    {
      id: "3",
      text: "Comment 3",
      children: [
        {
          id: "31",
          text: "Comment 3 1",
          children: [
            {
              id: "311",
              text: "Comment 3 1 1"
            }
          ]
        }
      ]
    }
  ]);

개발 좀 해보셨으면 아시겠지만, 저걸 렌더링 하는건 전혀 어렵지 않습니다.

재귀적으로 동일 컴포넌트 렌더링 하는것도 딱히 어렵지 않습니다.

그런데 진짜 문제는 저 상태를 업데이트할 필요가 있을 경우입니다.

중첩된 객체를 업데이트 하는것도 문제거니와, 중첩 객체를 효율적으로 불변 업데이트하는것도 쉽지 않습니다.

const updateComment = (id, text) => {
  setComments([
    ...comments.slice(0, 2),
    {
      ...comments[2],
      children: [
        {
          ...comments[2].children[0],
          children: [
            {
              ...comments[2].children[0].children[0],
              text: "New comment 311"
            }
          ]
        }
      ]
    }
  ]);
};

해결 방안 : 데이터 정규화

아래와 같이 children 배열에서 id로 서로를 참조할 수 있도록 합니다.

2, 3번과 유사한 맥락이 되었네요

function FlatCommentsRoot() {
  const [comments, setComments] = useState([
    {
      id: "1",
      text: "Comment 1",
      children: ["11", "12"],
    },
    {
      id: "11",
      text: "Comment 1 1"
    },
    {
      id: "12",
      text: "Comment 1 2"
    },
    {
      id: "2",
      text: "Comment 2",
    },
    {
      id: "3",
      text: "Comment 3",
      children: ["31"],
    },
    {
      id: "31",
      text: "Comment 3 1",
      children: ["311"]
    },
    {
      id: "311",
      text: "Comment 3 1 1"
    }
  ]);

  const updateComment = (id, text) => {
    const updatedComments = comments.map((comment) => {
      if (comment.id !== id) {
        return comment;
      }
      return {
        ...comment,
        text
      };
    });
    setComments(updatedComments);
  };

업데이트 로직은 이제 배열에서 찾아 변경하는 일반적인 논리가 되었습니다.

유사한 예제 : https://codesandbox.io/s/3gw1o1?file=%2FSolution.jsx&utm_medium=sandpack 


참고

https://profy.dev/article/react-usestate-pitfalls#updating-state-via-useeffect

 

Avoid These Common Pitfalls Of React useState

useState is the easiest and most common React hook. But so are some of its problems. Become aware of them and use the exercises on this page to improve your own code.

profy.dev

https://itchallenger.tistory.com/673

 

[3분 리액트] ref prop의 콜백 타입을 사용해 useEffect 사용 대체하기

원문 : https://tkdodo.eu/blog/avoiding-use-effect-with-callback-refs Avoiding useEffect with callback refs Interacting with DOM nodes doesn't necessarily need useEffect tkdodo.eu 8월 14일에 올라온 글인데 누가 velog에 번역해서 올려뒀네

itchallenger.tistory.com

https://itchallenger.tistory.com/897

 

React에서 자료구조 잘 사용하기

프론트엔드 개발자가 자료 구조를 공부해야 하는 이유 1. 면접 대비를 위해서 2. 적절한 자료구조를 사용하면 코드를 깔끔하게 짤 수 있습니다. 이번 게시물에서는 2번에 초점을 두어 자료구조를

itchallenger.tistory.com

 

반응형