본 아티클은, 아래 원문을 번역한 번역글임을 서두에 밝힙니다. 오역이 있다면 댓글로 알려주시면 감사하겠습니다 :)
원문 링크
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 을 만들지 않고 코드 복사, 붙여넣기 하기
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>
);
}
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 를 사용했을 때보다 훨씬 더 편리하고 분명하다.
이 실수는 코드 실행 중 에러를 유발할 수 있다. 아마, 이런 타입의 에러를 본 사람이라면, "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>
);
}
리액트의 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>
);
}
모든 리액트 훅들은 "구성 가능"한데, 이는 곧 리액트 훅들이 어떤 특정한 로직을 캡슐화하기 위해 서로 결합될 수 있음을 의미한다. 이를 통해 사용자는 커스텀 훅을 만들어 어플리케이션에 사용할 수 있게 된다.
아래 예제를 살펴보자. 간단한 로직에 비해 코드가 조금 장황해 보이지 않은가?
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 로직을 하나의 목적만을 수행하는 훅으로 분리했으며, 이는 훨씬 더 편한 사용성을 불러왔다. 리액트 훅들은 매우 강력한 도구이니, 사용하는 것을 잊지 않도록 하자.