[번역] useState를 사용할 때 절대 해선 안 되는 실수 5가지

0

본 아티클은, 아래 원문을 번역한 번역글임을 서두에 밝힙니다. 오역이 있다면 댓글로 알려주시면 감사하겠습니다 :)
원문 링크
https://medium.com/javascript-in-plain-english/5-react-usestate-mistakes-that-will-get-you-fired-b342289debfe

useState 는 사용하기 쉽지만 여전히 많은 개발자들이 잘못 사용하곤 한다. 심지어 경험 많은 개발자들도 몇몇 실수들을 저지르는 걸 코드 리뷰를 하다보면 종종 볼 수 있다.

이 아티클에서는, 어떻게 하면 실수를 하지 않고 useState 를 제대로 사용할 수 있을지 간단하고 실전적인 예제들을 통해 다뤄보고자 한다.

목차
1. 이전 값을 올바르지 못하게 가져오기
2. useState 에 전역 상태 저장하기
3. 상태 초기화하는 것 까먹기
4. 새로운 state 를 반환하는 대신 기존의 state 를 바꾸기
5. hook 을 만들지 않고 코드 복사, 붙여넣기 하기

1. 이전 값을 올바르지 못하게 가져오기

setState 를 사용할 때, 콜백 함수의 인자로 이전의 state 에 접근할 수 있다. 만약 이전 state 를 제대로 사용하지 않는다면 예상치 못한 state 업데이트가 발생할 수 있다. 전형적인 Counter 예제를 통해 해당 실수에 대해 알아보자.

import { useCallback, useState } from "react";

export default function App() {
  const [counter, setCounter] = useState(0);

  const handleIncrement = useCallback(() => {
    setCounter(counter + 1);
  }, [counter]);

  const handleDelayedIncrement = useCallback(() => {
    // counter + 1 이 문제가 된다.
    // callback 이 호출되었을 때 counter 는 이미 달라졌을 수 있기 때문에
    setTimeout(() => setCounter(counter + 1), 1000);
  }, [counter]);

  return (
    <div>
      <h1>{`Counter is ${counter}`}</h1>
      {/* 이 핸들러는 정상적으로 작동한다. */}
      <button onClick={handleIncrement}>Instant increment</button>
      {/* 이 핸들러를 여러 번 클릭하면 에러가 발생할 수 있다. */}
      <button onClick={handleDelayedIncrement}>Delayed increment</button>
    </div>
  );
}

이제 state 를 설정할 때 콜백 함수를 사용해 보자. 이는 useCallback 으로부터 불필요한 의존성을 지워주는 데에도 도움이 된다. 부디 아래 솔루션을 잘 기억해 두자! 이는 면접에서도 종종 물어보는 주제이다.

import { useCallback, useState } from "react";

export default function App() {
  const [counter, setCounter] = useState(0);

  const handleIncrement = useCallback(() => {
    setCounter((prev) => prev + 1);
    // 의존성이 제거되었다!
  }, []);

  const handleDelayedIncrement = useCallback(() => {
    // 이전의 state 를 이용함으로써 예상치 못한 state 업데이트를 피할 수 있다.
    setTimeout(() => setCounter((prev) => prev + 1), 1000);
    // 의존성이 제거되었다!
  }, []);

  return (
    <div>
      <h1>{`Counter is ${counter}`}</h1>

      <button onClick={handleIncrement}>Instant increment</button>
      <button onClick={handleDelayedIncrement}>Delayed increment</button>
    </div>
  );
}

2. useState 에 전역 상태 저장하기

useState 는 오직 컴포넌트의 지역 state 를 저장할 때에만 적합하다. 컴포넌트의 지역 state 로는 input 값이나 토글 flag 등이 있을 수 있다. 전역 state 는 전체 앱에 속해 있으며, 어느 특정한 하나의 컴포넌트와만 관련이 있지 않다. 만약 어떤 데이터가 여러 페이지나 위젯에서 쓰인다면, 이를 전역 state 에 넣는 것을 고려해봐야 한다. (React Context, Redux, MobX 등등)

예제를 통해 살펴보자. 지금은 매우 간단하지만, 곧 훨씬 더 복잡한 앱을 가지게 된다고 가정해 보자. 그래서 컴포넌트 계층은 매우 깊어질 것이고 유저 state 는 곧 전체 앱에서 쓰이게 될 것이다. 이런 경우, 우리는 이 state 를 전역 스코프로 분리해서 앱의 어느 지점에서든지 쉽게 접근할 수 있도록 만들어야 한다. (그럼으로써 props 를 20-40 단계까지 내려서 전달할 필요가 없게 만들어야 한다.)

import React, { useState } from "react";

// props 전달하기
function PageFirst(user) {
  return user.name;
}

// props 전달하기
function PageSecond(user) {
  return user.surname;
}

export default function App() {
  // User state 는 앱 전체에서 쓰이게 될 것이다. 따라서 useState 를 전역 state 로 대체할 필요가 있다. 
  const [user] = useState({ name: "Pavel", surname: "Pogosov" });

  return (
    <>
      <PageFirst user={user} />
      <PageSecond user={user} />
    </>
  );
}

위 예시에서처럼 지역 state 를 사용하는 것 대신, 전역 state 를 사용하는 것을 지향해야 한다. React Context 를 사용해 이 예제를 다시 작성해 보자.

import React, { createContext, useContext, useMemo, useState } from "react";

// context 생성하기
const UserContext = createContext();

// 이 컴포넌트는 user context 를 앱으로부터 분리한다. 따라서 user context 가 오염되지 않도록 한다.  
function UserContextProvider({ children }) {
  const [name, setName] = useState("Pavel");
  const [surname, setSurname] = useState("Pogosov");

  // useMemo 를 통해 참조값을 기억함으로써, 불필요한 리렌더링을 막을 수 있다. 
  const value = useMemo(() => {
    return {
      name,
      surname,
      setName,
      setSurname
    };
  }, [name, surname]);

  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

function PageFirst() {
  const { name } = useContext(UserContext);

  return name;
}

function PageSecond() {
  const { surname } = useContext(UserContext);

  return surname;
}

export default function App() {
  return (
    <UserContextProvider>
      <PageFirst />
      <PageSecond />
    </UserContextProvider>
  );
}

이제 앱의 모든 부분에서 쉽게 전역 state 에 접근할 수 있게 되었다. 이는 순수한 useState 를 사용했을 때보다 훨씬 더 편리하고 분명하다.

3. 상태 초기화하는 것 까먹기

이 실수는 코드 실행 중 에러를 유발할 수 있다. 아마, 이런 타입의 에러를 본 사람이라면, "Can't read properties of undefined"라는 메시지를 보았을 것이다.

import React, { useEffect, useState } from "react";

// user 를 fetch 하는 함수. 여기에선 에러가 발생하지 않지만, 상태 초기화는 늘 해주어야 한다. 
async function fetchUsers() {
  const usersResponse = await fetch(
    `https://jsonplaceholder.typicode.com/users`
  );
  const users = await usersResponse.json();

  return users;
}

export default function App() {
  // 초기 state 가 없으므로, user 는 setUser 를 통해 설정되기까지 undefined 상태이다.
  const [users, setUsers] = useState();

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  return (
    <div>
      {/* 에러가 발생한다, can't read properties of undefined */}}
      {users.map(({id, name, email}) => (
        <div key={id}>
          <h4>{name}</h4>
          <h6>{email}</h6>
        </div>
      ))}
    </div>
  );
}

이 실수는, 저지르기 쉬운 만큼이나 고치기도 쉽다! 그저 state 를 빈 배열로 설정해 주면 된다. 만약 적당한 초기 state 가 생각나지 않는다면, null 을 넣어주면 된다.

import React, { useEffect, useState } from "react";

async function fetchUsers() {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users`
  );
  const users = await response.json();

  return users;
}

export default function App() {
  // 에러가 발생하지 않더라도, 초기값을 설정해 두는 것은 좋은 습관이다. (null값이라도)
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  // 아래와 같은 코드를 추가함으로써 한 번 더 확인할 수 있다.
  // if (users.length === 0) return <Loading />

  return (
    <div>
      {users.map(({id, name, email}) => (
        <div key={id}>
          <h4>{name}</h4>
          <h6>{email}</h6>
        </div>
      ))}
    </div>
  );
}

4. 새로운 state 를 반환하는 대신 기존의 state 를 바꾸기

리액트의 state 를 절대로 임의로 변경해선 안 된다. 리액트는 state 가 변화할 때 많은 중요한 일들을 하게 되는데, 이는 모두 얕은 비교(값이 아닌, 참조에 따른 비교)에 따라서 진행되기 때문이다.

import { useCallback, useState } from "react";

export default function App() {
  // 초기 state 설정
  const [userInfo, setUserInfo] = useState({
    name: "Pavel",
    surname: "Pogosov"
  });

  // field is either name or surname
  const handleChangeInfo = useCallback((field) => {
    // e is input onChange event
    return (e) => {
      setUserInfo((prev) => {
        // prev state 를 변화시키고 있다.
        // 아래 코드는 리액트가 변화를 인지하지 못하므로, 정상작동하지 않을 것이다.
        prev[field] = e.target.value;

        return prev;
      });
    };
  }, []);

  return (
    <div>
      <h2>{`Name = ${userInfo.name}`}</h2>
      <h2>{`Surname = ${userInfo.surname}`}</h2>

      <input value={userInfo.name} onChange={handleChangeInfo("name")} />
      <input value={userInfo.surname} onChange={handleChangeInfo("surname")} />
    </div>
  );
}

해결 방법은 꽤나 직관적이다. 기존의 값을 변화시키는 것을 지양하고, 새로운 state 를 return 시키면 된다.

import { useCallback, useState } from "react";

export default function App() {
  const [userInfo, setUserInfo] = useState({
    name: "Pavel",
    surname: "Pogosov"
  });

  const handleChangeInfo = useCallback((field) => {
    return (e) => {
      // 이제 정상 작동한다!
      setUserInfo((prev) => ({
        // 따라서, 우리가 이름을 update 하려고 한다면, surname 은 기존의 state 에 남아 있으며 반대의 경우에도 그러하다.
        ...prev,
        [field]: e.target.value
      }));
    };
  }, []);

  return (
    <div>
      <h2>{`Name = ${userInfo.name}`}</h2>
      <h2>{`Surname = ${userInfo.surname}`}</h2>

      <input value={userInfo.name} onChange={handleChangeInfo("name")} />
      <input value={userInfo.surname} onChange={handleChangeInfo("surname")} />
    </div>
  );
}

5. 훅 로직을 다듬지 않고 복사, 붙여넣기 하기

모든 리액트 훅들은 "구성 가능"한데, 이는 곧 리액트 훅들이 어떤 특정한 로직을 캡슐화하기 위해 서로 결합될 수 있음을 의미한다. 이를 통해 사용자는 커스텀 훅을 만들어 어플리케이션에 사용할 수 있게 된다.

아래 예제를 살펴보자. 간단한 로직에 비해 코드가 조금 장황해 보이지 않은가?

import React, { useCallback, useState } from "react";

export default function App() {
  const [name, setName] = useState("");
  const [surname, setSurname] = useState("");

  const handleNameChange = useCallback((e) => {
    setName(e.target.value);
  }, []);

  const handleSurnameChange = useCallback((e) => {
    setSurname(e.target.value);
  }, []);

  return (
    <div>
      <input value={name} onChange={handleNameChange} />
      <input value={surname} onChange={handleSurnameChange} />
    </div>
  );
}

어떻게 이 코드를 간단하게 만들 수 있을까? 기본적으로, 우리는 같은 것을 두 번 반복하고 있다. 지역 state 를 선언하고 onChange 이벤트를 핸들링하는 것. 이는 쉽게 별도의 커스텀 훅으로 분리될 수 있다. 이제 이것을 useInput 으로 선언하자.

import React, { useCallback, useState } from "react";

function useInput(defaultValue = "") {
  // 이 state 를 한 번만 선언했다!
  const [value, setValue] = useState(defaultValue);

  // 이 핸들러를 한 번만 선언했다!
  const handleChange = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  // Cases when we need setValue are also possible
  return [value, handleChange, setValue];
}

export default function App() {
  const [name, onChangeName] = useInput("Pavel");
  const [surname, onChangeSurname] = useInput("Pogosov");

  return (
    <div>
      <input value={name} onChange={onChangeName} />
      <input value={surname} onChange={onChangeSurname} />
    </div>
  );
}

이로써 input 로직을 하나의 목적만을 수행하는 훅으로 분리했으며, 이는 훨씬 더 편한 사용성을 불러왔다. 리액트 훅들은 매우 강력한 도구이니, 사용하는 것을 잊지 않도록 하자.

profile
매일 1%씩 성장하는 개발자 보리몽입니다 :D

0개의 댓글