useState와 useEffect 사용 시 주니어 개발자가 하기 쉬운 실수

Donggu(oo)·2023년 9월 18일
0

React

목록 보기
30/30
post-thumbnail

1. State updates aren't immediate


  • 아래의 코드는 버튼을 클릭 할 때 마다 count state에 1씩 더해 count 값을 증가시킬 수 있다.
"use client";

import { useState } from "react";

export default function Home() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <div className='m-20'>
      <button onClick={handleClick} className='bg-blue-500 px-4 py-2 text-white rounded mb-2'>
        Click me
      </button>
      <p>Count is: {count}</p>
    </div>
  );
}
  • 그런데 버튼을 한 번 클릭 할 때 마다 4씩 한번에 증가시키기 위해 handleClick을 아래와 같이 수정하면 원하는 대로 동작할까? 기대와는 다르게 여전히 1씩 증가할 것이다.

  • 이는 이전 상태가 고려되지 않았기 때문이다. setState는 비동기적 처리 과정이므로 setState의 인자로 state를 사용하면 아직 state가 갱신되기 전의 값이 계속 들어가기 때문에 count에는 계속 0이 들어가게 된다.

const handleClick = () => {
  setCount(count + 1);  // setCount(0 + 1);
  setCount(count + 1);  // setCount(0 + 1);
  setCount(count + 1);  // setCount(0 + 1);
  setCount(count + 1);  // setCount(0 + 1);
};

  • 여기서 처음 의도한 대로 한번에 4씩 증가시키려면 함수를 인자로 사용하면 된다. 함수를 사용하여 이전 값을 인자로 전달받으면 이제 count 대신 이전 값을 사용할 수 있다.

  • setState의 인자로 사용된 함수는 이전 state값을 전달받으며 그 값을 이용한 함수들은 큐에 저장되어 순서대로 실행된다. 따라서 큐에서 차례로 prev(이전) 값을 받아 수행할 수 있으니 모든 setState 구문이 동작하는 것이다.

  const handleClick = () => {
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
  };

2. Conditional rendering


  • 아래 코드는 제품의 id를 props로 받아 해당 id에 대한 관련 제품 카드를 레더링하는 일을 담당하고 있다. id가 전달된다면 제품 카드를 보여주고 id가 전달되지 않는다면 "No id provided"라는 문구를 바로 return 한다.
export default function ProductCard({ id }) {
  if (!id) {
    return "No id provided";
  }

  return (
    <section>
      {
        // Product card...
      }
    </section>
  );
}
  • 그런데 여기서 if문 아래에서 hook을 사용하려고 한다면 아래와 같은 문구가 발생한다.

React Hook "useState"가 조건부로 호출됩니다. 모든 구성 요소 렌더에서 React Hook을 정확히 같은 순서로 호출해야 합니다. 조기 반환 후에 실수로 React Hook을 호출했습니까?

"use client";

import { useState, useEffect } from "react";

export default function ProductCard({ id }) {
  if (!id) {
    return "No id provided";
  }

  const [something, setSomething] = useState("test");

  useEffect(() => {}, [something]);

  return (
    <section>
      {
        // Product card...
      }
    </section>
  );
}
  • 여기서 문제는 때때로 id가 전달되지 않을 수도 있다는 것이다. id가 존재하지 않는다면 "No id provided"를 바로 return 하지만 id가 전달되는 대부분의 경우에는 Hook이 호출되어야 한다. 그런데 Hook은 모든 렌더링에서 항상 동일하게 호출되어야 하므로 아래와 같이 수정해야 한다.
"use client";

import { useState, useEffect } from "react";

export default function ProductCard({ id }) {
  const [something, setSomething] = useState("test");

  useEffect(() => {}, [something]);

  if (!id) {
    return "No id provided";
  }

  return (
    <section>
      {
        /* Product card... */
      }
    </section>
  );
}
  • 추가로 조건부 렌더링을 활용하여 컴포넌트에서 2번 return 하지 않고 1번만 return 하도록 리팩토링 할 수 있다. 아래 코드는 처음 return 문을 2번 작성한 코드와 동일하게 작동한다.
"use client";

import { useState, useEffect } from "react";

export default function ProductCard({ id }) {
  const [something, setSomething] = useState("test");

  useEffect(() => {}, [something]);

  return (
    <section>
      {!id
        ? "No id provided"
        : {
            /* Product card... */
          }}
    </section>
  );
}

3. Updating object state


  • 아래 코드는 user라는 state가 name, city, age를 프로퍼티로 가지고 있다. 그리고 3개의 프로퍼티 중 input에는 name만 사용할 것 이므로 name만 change event로 등록해준다. 그러면 input에 입력 할 수 있지만 name만 있고 city와 age는 업데이트 되지 않는 것을 볼 수 있다.
"use client";

import { useState } from "react";

export default function User() {
  const [user, setUser] = useState({ name: "", city: "", age: 50 });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUser({ name: e.target.value });
  };

  console.log(user);

  return (
    <form className='m-20'>
      <input
        type='text'
        className='border-solid border-2 border-sky-500 p-1'
        placeholder='Your name'
        onChange={handleChange}
      />
    </form>
  );
}

  • 위의 handleChange는 city와 age를 복사하지 않았기 때문에 spread operator를 이용해 user의 값을 복사해야 한다. 이렇게 수정하면 객체의 모든 프로퍼티가 업데이트 되는 것을 확인할 수 있다. 이것이 객체의 한 속성을 업데이트하는 올바른 방법이다.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setUser({
    ...user,
    name: e.target.value,
  });
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setUser((prev) => {
    return {
      ...prev,
      name: e.target.value,
    };
  });
};

4. 1 Object state instead of multiple smaller ones


  • 아래는 4개의 입력 양식을 가지고 있는 form이다. 실제로는 이보다 더 많은 input이 포함된 form이 있을 수 있다. 각 input 입력 값을 state로 관리하기 위해 input의 개수만큼 state를 만들어야 할까? 그렇다면 매우 비효율적일 것 이다. 그렇기 때문에 객체를 상태로 가질 수 있도록 하는 것이 더 깔끔할 것이다.
"use client";

import { useState } from "react";

export default function Form() {
  const [form, setForm] = useState({
    firstName: "",
    lastName: "",
    email: "",
    password: "",
  });

  return (
    <form className='flex flex-col gap-y-2 m-4'>
      <input
        type='text'
        name='firstName'
        placeholder='first name'
        className='px-4 py-2 border-solid border-2 border-sky-500 p-1'
      />
      <input
        type='text'
        name='lastName'
        placeholder='last name'
        className='px-4 py-2 border-solid border-2 border-sky-500 p-1'
      />
      <input
        type='text'
        name='email'
        placeholder='email'
        className='px-4 py-2 border-solid border-2 border-sky-500 p-1'
      />
      <input
        type='text'
        name='password'
        placeholder='password'
        className='px-4 py-2 border-solid border-2 border-sky-500 p-1'
      />
    </form>
  );
}
  • 그럼 input의 개수만큼 change event를 만들지 않고 하나의 함수로 관리 할 수 없을까? 위 코드에서 input의 name 속성은 form state의 프로퍼티 이름과 동일하다. 그러므로 name을 key로 받아 하나의 함수로 change event를 관리할 수 있다.
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm({
      ...form,
      [e.target.name]: e.target.value,
    });
  };

5. Information can be derived from state / props


  • 아래 코드는 버튼을 클릭하여 장바구니에 물건을 추가할 때 마다 전체 가격을 보여주고 있다. 수량(quantity)과 전체 가격(totalPrice)을 state로 관리하고 있고 useEffect를 이용해 quantity의 변경이 감지 될 때 마다 totalPrice를 업데이트 하고 있는 구조다.
"use client";

import { useState, useEffect } from "react";

const PRICE_PER_ITEM = 500;

export default function Cart() {
  const [quantity, setQuantity] = useState(1);
  const [totalPrice, setTotalPrice] = useState(0);

  const handleClick = () => {
    setQuantity(quantity + 1);
  };

  useEffect(() => {
    setTotalPrice(quantity * PRICE_PER_ITEM);
  }, [quantity]);

  return (
    <div className='m-4'>
      <button onClick={handleClick} className='bg-blue-500 px-4 py-2 text-white rounded'>
        Add {quantity} item
      </button>
      <p>Total price: {totalPrice}</p>
    </div>
  );
}
  • 하지만 totalPrice state와 useEffect를 굳이 사용할 필요 없이 동일하게 동작하도록 코드를 간소화 할 수 있다.

  • 처음 컴포넌트가 렌더링 될 때 PRICE_PER_ITEM을 500으로 설정했으므로 totalPrice는 500이 된다. 버튼을 클릭하면 handleClick 함수가 실행되며 quantity를 업데이트하므로 리렌더링 되며 totalPrice line도 다시 실행되며 totalPrice 값이 업데이트 된다.

  • 이렇게 이미 존재하는 state에서 파생하거나 계산할 수 있다면 매번 위와 같이 새로운 state를 생성하고 useEffect를 사용할 필요가 없다.

"use client";

import { useState } from "react";

const PRICE_PER_ITEM = 500;

export default function Cart() {
  const [quantity, setQuantity] = useState(1);
  const totalPrice = quantity * PRICE_PER_ITEM;

  const handleClick = () => {
    setQuantity(quantity + 1);
  };

  return (
    <div className='m-4'>
      <button onClick={handleClick} className='bg-blue-500 px-4 py-2 text-white rounded'>
        Add {quantity} item
      </button>
      <p>Total price: {totalPrice}</p>
    </div>
  );
}

6. Primitives vs non-primitives


  • 아래의 코드는 버튼을 클릭하면 price의 값을 0으로 초기화 시켜주는 코드다. 그리고 동시에 컴포넌트가 렌더링 될 때 마다 console을 찍고 있다. 이 상태에서 새로고침하면 컴포넌트가 처음 마운트 될 때 Price 컴포넌트의 모든 명령문들이 실행된다.

  • 그런데 버튼을 몇번을 다시 클릭해봐도 처음 렌더링 된 이후로는 console이 찍히지 않는 것을 확인할 수 있다. 그 이유는 현재 price의 값과 버튼을 클릭해서 변경한 price의 값이 같기 때문에(true) state가 변경되지 않아 리렌더링이 발생하고 있지 않기 때문이다. state를 동일한 문자열로 변경하고 버튼을 클릭해도 리렌더링 되지 않는다.

"use client";

import { useState } from "react";

export default function Price() {
  const [price, setPrice] = useState(0);

  const handleClick = () => {
    setPrice(0);
  };

  console.log("Component rendering");

  return (
    <div className='m-10'>
      <button onClick={handleClick} className='bg-blue-500 px-4 py-2 text-white rounded'>
        Click me
      </button>
    </div>
  );
}

  • 그러나 state의 값이 객체라면 어떻게 될까? state 값을 객체로 바꾸고 setState도 똑같은 객체로 변경해서 다시 버튼을 클릭해보면 이전과는 다르게 버튼을 클릭할 때 마다 리렌더링 되는 것을 확인할 수 있다. 버튼을 클릭할 때 마다 똑같은 값으로 변경하는데 왜 리렌더링이 매번 발생할까? 그 이유는 string이나 number는 값에 의한 전달(Pass-by-value), 객체는 참조에 의한 전달(Pass-by-reference)이기 때문이다.

  • 객체를 가리키는 변수를 다른 변수에 할당하면 원본의 참조 값이 복사되어 전달되는데 이것을 참조에 의한 전달이라 한다. 그렇기 때문에 객체는 설령 값이 같다고 하더라도 두 객체의 주소값은 다르기 때문에 javascript는 서로 다른 객체라고 판단하며, 리액트도 두 객체는 서로 다른 값이므로 버튼을 클릭할 때 마다 매번 리렌더링을 시키는 것이다.

"use client";

import { useState } from "react";

export default function Price() {
  const [price, setPrice] = useState({
    number: 100,
    totalPrice: true,
  });

  const handleClick = () => {
    setPrice({
      number: 100,
      totalPrice: true,
    });
  };

  console.log("Component rendering");

  return (
    <div className='m-10'>
      <button onClick={handleClick} className='bg-blue-500 px-4 py-2 text-white rounded'>
        Click me
      </button>
    </div>
  );
}

7. Initializing state with object


  • 아래 코드는 서버로 부터 데이터를 불러와 post state를 업데이트하고 있다. 그런데 아래의 코드를 실행해보면 title이라는 프로퍼티를 찾을 수 없다는 에러를 볼 수 있을 것이다.

  • useEffect는 컴포넌트가 렌더링 된 후에 실행된다. tsx(jsx) 부분은 이미 렌더링이 완료 됐는데 fetch 요청은 아직 완료되지 않았으므로 post 값을 아직 불러오지 못한 상태에서 title과 body에 접근을 시도하려고 하기 때문에 아래와 같은 에러가 발생하는 것이다.

"use client";

import { useState, useEffect } from "react";

export default function BlogPostExample() {
  const [post, setPost] = useState(null);

  useEffect(() => {
    fetch("https://dummyjson.com/posts/1")
      .then((res) => res.json())
      .then((data) => {
        setPost(data);
      });
  }, []);

  return (
    <article className='m-10'>
      <h1>Title: {post.title}</h1>
      <br />
      <p>Body: {post.body}</p>
    </article>
  );
}

  • 이러한 문제를 해결하기 위해 옵셔널 체이닝(?., optional chaining)을 사용할 수 있다. 옵셔널 체이닝은 값이 null 또는 undefined인 경우 undefined를 반환하고, 그렇지 않으면 프로퍼티 참조를 이어간다. 옵셔널 체이닝을 사용하면 정상적으로 title과 body를 불러오는 것을 확인할 수 있다.
"use client";

import { useState, useEffect } from "react";

export default function BlogPostExample() {
  const [post, setPost] = useState(null);

  useEffect(() => {
    fetch("https://dummyjson.com/posts/1")
      .then((res) => res.json())
      .then((data) => {
        setPost(data);
      });
  }, []);

  return (
    <article className='m-10'>
      <h1>Title: {post?.title}</h1>
      <br />
      <p>Body: {post?.body}</p>
    </article>
  );
}

  • 옵셔널 체이닝으로도 문제를 해결할 수는 있지만 만약 데이터가 너무 많아 호출에 시간이 걸린다면 그동안 사용자는 아무것도 없는 빈 화면을 보고 있어야 한다. 이는 사용자 경험에 좋지 못한 영향을 미치게 된다. 이때 우리는 데이터를 받아 올 때까지 loading spinner 같이 대체해서 보여줄 수 있는 요소를 추가해 주는 것이 좋다.

  • 아래의 코드는 loading state를 추가하여 loading이 true 이면 "Loading..."이란 문구를, 데이터 호출이 완료되면 loading을 false로 변경하고 title과 body를 조건부 렌더링으로 보여주고 있다. 이렇게 loading state를 추가해 줌으로써 사용자에게 현재 데이터를 불러오고 있음을 인지시켜 줄 수 있게 되었다.

"use client";

import { useState, useEffect } from "react";

export default function BlogPostExample() {
  const [post, setPost] = useState();
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("https://dummyjson.com/posts/1")
      .then((res) => res.json())
      .then((data) => {
        setPost(data);
        setLoading(false);
      });
  }, []);

  return (
    <article className='m-10'>
      {loading ? (
        "Loading..."
      ) : (
        <>
          <h1>Title: {post.title}</h1>
          <br />
          <p>Body: {post.body}</p>
        </>
      )}
    </article>
  );
}

8. TypeScript mistakes


  • 타입스크립트는 아래와 같이 state의 타입을 일일히 지정해주지 않아도 자동으로 추론한다.
  const [loading, setLoading] = useState<boolean>(true);

  • 그러나 JavaScript 환경에서는 위의 fetch 코드 동작에 문제가 없었지만 TypeScript 환경에서는 post의 타입을 null로 추론하고 있기 때문에 아래와 같은 에러가 발생하게 된다. post는 처음에는 null 값이었지만 데이터를 호출하게 되면 더 이상 null이 아닌 객체를 값으로 가지게 된다.

  • 이러한 문제를 해결하기 위해서는 아래와 같이 null과 더불어 객체의 타입도 지정해 주어야 한다.
type Post = {
  title: string;
  body: string;
};

export default function Price() {
  const [post, setPost] = useState<Post | null>(null);
  // ...

  return (
    <article className='m-10'>
    // ...
    </article>
  );
}

9. Not using custom hooks


  • 아래의 코드에서는 현재 윈도우의 너비를 불러오는 resize 이벤트를 컴포넌트가 마운트(DOM 객체가 생성되고 브라우저에 나타나는 것) 될 때 등록하고 있다. 그리고 너비가 변경될 때 마다 windowSize state를 업데이트하게 된다.

  • 하지만 clean up function이 없다면 컴포넌트가 언마운트(컴포넌트가 DOM에서 제거되는 것) 되어 더 이상 보여지고 있지 않지만 여전히 이벤트 리스너는 연결되어 있기 때문에 clean up function을 통해 컴포넌트가 언마운트 될 때 이벤트 리스너를 제거해준다.

"use client";

import { useState, useEffect } from "react";

export function ExampleComponent1() {
  const [windowSize, setWindowSize] = useState(1920);

  useEffect(() => {
    const handleWindowsSizeChange = () => {
      setWindowSize(window.innerWidth);
    };
    window.addEventListener("resize", handleWindowsSizeChange);

    return () => {
      window.removeEventListener("resize", handleWindowsSizeChange);
    };
  }, []);

  return <div>Component 1</div>;
}
  • 그런데 만약 윈도우 너비를 구하는 동작이 다른 여러 개의 컴포넌트에서도 동일하게 필요한 상황이라면 어떻게 해야 할까? 아래와 같이 모든 컴포넌트 마다 필요한 로직을 복사/붙여넣기 하면 될까? 만약 필요한 컴포넌트가 100개 1000개 라면..? 이렇게 한다면 당장 코드 가독성도 떨어지지만 이후의 유지보수도 매우 힘들 것이다.
"use client";

import { useState, useEffect } from "react";

export function ExampleComponent1() {
  const [windowSize, setWindowSize] = useState(1920);

  useEffect(() => {
    const handleWindowsSizeChange = () => {
      setWindowSize(window.innerWidth);
    };
    window.addEventListener("resize", handleWindowsSizeChange);

    return () => {
      window.removeEventListener("resize", handleWindowsSizeChange);
    };
  }, []);

  return <div>Component 1</div>;
}

export function ExampleComponent2() {
  const [windowSize, setWindowSize] = useState(1920);

  useEffect(() => {
    const handleWindowsSizeChange = () => {
      setWindowSize(window.innerWidth);
    };
    window.addEventListener("resize", handleWindowsSizeChange);

    return () => {
      window.removeEventListener("resize", handleWindowsSizeChange);
    };
  }, []);

  return <div>Component 2</div>;
}

// ExampleComponent3
// ExampleComponent4
// ExampleComponent5
// ...
  • 이런 경우 재사용할 수 있는 custom hook을 만들어 문제를 해결할 수 있다. custom hook을 만들어 필요한 컴포넌트마다 import 해 사용함으로써 중복되는 코드를 줄이고 변경사항이 생기면 custom hook만 수정하면 모든 컴포넌트에 수정사항이 동일하게 적용되기 때문에 유지보수 측면에서도 훨씬 이점을 가지게 된다.
"use client";

import { useState, useEffect } from "react";

export const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState(1920);

  useEffect(() => {
    const handleWindowsSizeChange = () => {
      setWindowSize(window.innerWidth);
    };
    window.addEventListener("resize", handleWindowsSizeChange);

    return () => {
      window.removeEventListener("resize", handleWindowsSizeChange);
    };
  }, []);

  return windowSize;
};
import { useWindowSize } from "@/hooks/useWindowSize";

export function ExampleComponent1() {
  const windowSize = useWindowSize();

  return <div>Component 1</div>;
}

export function ExampleComponent2() {
  const windowSize = useWindowSize();

  return <div>Component 2</div>;
}

0개의 댓글