리액트는 사용자 인터페이스를 위한 라이브러리입니다. 리액트의 장점으로는 너무 많지만 대표적으로 두 가지 정도 언급하자면, 1) 코드를 컴포넌트 기반으로 작성하고 분리할 수 있어서 코드 재사용성이 좋습니다. 그리고 2) 화면을 State를 통해 쉽게 업데이트할 수 있습니다.
여기서 말하는 State란 한 컴포넌트 내에서 생명주기 동안 변경할 수 있는 자바스크립트 객체를 의미합니다.
예를 들어, 좋아요 버튼을 클릭하였을 때 색이 진해지고, 좋아요 데이터를 변경하는 걸 보고 상태를 바뀌는 걸 확인할 수 있습니다.
웹 사이트가 커질수록 많은 상태 데이터로 인해 생기는 복잡함을 관리하기 위한 방법이 필요합니다. 그래서 이 포스트에서는 상태를 만드는 방법, 상태를 관리하는 방법 등을 정리하도록 하겠습니다.
상태는 범위를 기준으로 지역 상태, 컴포넌트 간 상태, 전역 상태 등이 있습니다.
지역 상태는 다른 컴포넌트와 공유되지 않고, 특정 컴포넌트에 국한된 데이터를 관리하는 상태입니다. 주로 사용자 입력, 모달 열기/닫기, 버튼 클릭 여부 등 UI와 관련된 간단한 동작을 처리하는 데 매우 유용합니다.
useState
는 상태를 사용하는 리액트 훅입니다. 사용 방법으로는 배열 구조 분해를 통해 상태를 가리키는 변수와 상태를 변경하는 함수를 할당하고, 초깃값을 인자를 주면 됩니다.
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 상태 업데이트
};
return (
<div>
<p>버튼이 {count}번 클릭되었습니다.</p>
<button onClick={handleClick}>클릭</button>
</div>
);
}
export default Counter;
기본적인 개념은 위와 같고 심화 개념으로 알고 싶다면 아래와 같은 개념을 찾아보면 좋습니다.
간단한 상태를 관리하기 위해서는 useState
를 사용하면 됩니다. 하지만 상태가 여러 개로 늘어나고, 상태끼리의 연관성이 생기다 보면 상태를 관리하기 복잡합니다. 이를 해결하기 위해 복잡한 상태 로직을 분리하고 관리할 수 있는 useReducer
가 등장합니다.
useReducer의 문법을 간략하게 살펴보면 아래와 같습니다.
import React, { useReducer } from 'react';
// 1. Reducer 함수 정의
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
throw new Error('Unknown action type');
}
}
function Counter() {
// 2. useReducer
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
export default Counter;
값을 변경시키도록 유발하는 함수를 dispatch, 변경 로직을 담긴 게 reducer 라고 구분짓고 생각하면 개인적으로 이해하기 편하다고 생각합니다.
useState | useReducer |
---|---|
간단한 상태 관리에 적합 | 복잡한 상태 관리에 적합 |
상태 업데이트 함수 호출 | 상태 변경 로직이 reducer에 포함 |
상태와 업데이트 함수 제공 | 상태와 디스패치 함수 제공 |
지역 상태로만 애플리케이션을 관리할 수 없습니다. 상태를 전달하고 상태를 받아야 합니다. 이처럼 여러 컴포넌트에서 관리되는 상태를 컴포넌트 간 상태라고 합니다.
그 전에 저희가 알아야 할 내용은 리액트의 단방향 데이터 흐름(One-way Data Flow)입니다. 리액트에서 데이터는 기본적으로 부모 컴포넌트에서 자식 컴포넌트로 전달되고, 전달된 데이터는 읽기 전용이라는 특성을 가지고 있습니다. 단방향 데이터 흐름으로 데이터 흐름을 파악하기 쉬워 디버깅하기 용이하다는 장점을 가지고 있습니다.
상태를 전달하기 위해서 props
라는 개념이 필요합니다. props는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 보내기 위해 사용되는 객체입니다. 컴포넌트로 데이터 전달 혹은 컴포넌트를 커스텀하기 위해 사용됩니다.
예시는 아래와 같습니다.
단방향적인 데이터 전달이 수없이 진행되어 어디가 시초인지도 알 수 없을 정도로 깊어지면 개발 생산성과 디버깅에 어려움을 갖게 됩니다. 이 같은 현상을 Props Drilling
이라고 표현합니다. 이를 해결하기 위해 전역적으로 접근할 수 있는 상태인 전역 상태
를 알아야 합니다.
전역 상태를 사용하기 위해 리액트의 내장된 훅에서 useContext
가 있습니다.
useContext는 단계적으로 방법을 이해하는 게 도움이 많이 됩니다.
우선적으로 해야 할 작업은 Context와 Context에서 전달된 데이터를 만듭니다. 그러고 전달하게 도와주는 Provider에 데이터를 담습니다.
import React, { createContext, useContext, useState } from 'react';
// 1. Context 생성
const ThemeContext = createContext();
function App() {
const [theme, setTheme] = useState('light');
return (
// 2. Provider로 데이터 제공
<ThemeContext.Provider value={{ theme, setTheme }}>
<Toolbar />
</ThemeContext.Provider>
);
}
그러고 전달된 데이터를 불러오도록 useContext를 사용하면 됩니다.
import React, { useContext } from 'react';
export function Toolbar() {
return (
<div>
<ThemeToggleButton />
<ThemeDisplay />
</div>
);
}
function ThemeToggleButton() {
// 3. useContext 사용
const { setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}>
Toggle Theme
</button>
);
}
function ThemeDisplay() {
const { theme } = useContext(ThemeContext);
return <p>Current Theme: {theme}</p>;
}
export default App;
이렇게 단계별로 정리하고 살펴보면 사용법이 간편하고 내장 훅이라서 별도의 라이브러리를 설치하지 않아서 번들 사이즈를 줄일 수 있습니다. 다만 간단한 데이터가 아닌 상호연관성이 많거나 연산이 많은 복잡한 데이터가 들어간다면 useContext는 적절한 수단이 아닐 수 있습니다.
⭐️ 요약
사용 방식)
- 1️⃣ Context 생성 (createContext)
- 2️⃣ 전달하고 싶은 전역 상태 데이터 담기 (Provider)
- 3️⃣ 전역 상태를 활용 (useContext)
장점)
- 내장 기능이므로 별도의 설정이나 라이브러리 설치가 필요없으므로 번들 사이즈를 줄일 수 있다.
단점)
- 컨텍스트 트리 구조가 깊어질수록 성능 저하 가능
- 상태가 복잡하거나 규모가 커질수록 코드 관리가 어려워질 수 있음
Context API의 단점인 구조 설정, 복잡한 전역 데이터 관리, 깊은 컨텍스트 트리 구조로 인한 성능 저하를 막기 위해 저는 주로 Zustand
라는 리액트 상태 관리 라이브러리를 사용합니다.
create는 전역 상태 저장소를 만들고,set은 Zustand에서 상태를 안전하고 효율적으로 업데이트하는 함수입니다. 이름에서 알 수 있듯이 기능을 직관적으로 알 수 있습니다.
참고로 useStore라는 네이밍 대신에 다르게 이름을 지어도 됩니다. (useUserStore, useThemeStore)
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
간단하게 import해서 사용하면 됩니다.
function Counter() {
const { count, increment, decrement } = useStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
⭐️ 요약
사용 방식)
- 1️⃣ 상태 스토어 생성 (create, set)
- 2️⃣ 스토어에서 꺼내서 사용 (use땡땡Store)
장점)
- 간결한 API로 쉬운 사용법
- 리렌더링 최소화: 필요한 상태만 변경
- 리액트 외부에서도 상태 관리 가능
단점)
- 외부 라이브러리 설치가 필요
- 간단한 애플리케이션에는 오히려 과도할 수 있다
더 나아가 서버 상태에 대해 알아보겠습니다. 서버 상태란 서버로부터 비동기적으로 불러온 데이터의 상태를 의미합니다. 예를 들어, 데이터베이스에서 불러온 사용자 목록이나, API를 통해 받아온 글 목록 등이 서버 상태에 해당합니다.
서버 상태의 특징
- 어플리케이션 내에 속하지 않고, 그러므로 제어하지도 못한다. 보통은 원격에 위치한 곳에 저장되어 있다.
- 데이터를 가져오거나 업데이트를 하기 위해서는 비동기 API가 필요하다.
- 나만 사용하는 것이 아니라, 다른 사람들과 함께 사용하기 때문에 언제 어떻게 Update 될 지 모른다.
리액트 쿼리는 서버 상태 관리를 위한 라이브러리입니다. 여기서는 리액트 쿼리에 대해 간단히 알아보고 다른 포스트에서 심도있게 다뤄보겠습니다.
리액트 쿼리를 사용하면서 얻는 장점)
"하나의 컴포넌트는 하나의 책임만을 가져야 한다"
컴포넌트의 역할을 분리를 잘하고, 그에 따른 상태를 최소한으로 유지하는 게 좋습니다.
function UserProfile({ user }) {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
function ErrorMessage({ message }) {
return <p>{message}</p>;
}
function useUserData() {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUserData = async () => {
try {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data);
} catch (err) {
setError('Failed to load user data');
}
};
fetchUserData();
}, []);
return { user, error };
}
function Profile() {
const { user, error } = useUserData();
return (
<div>
{error && <ErrorMessage message={error} />}
{user ? <UserProfile user={user} /> : <p>Loading...</p>}
</div>
);
}
상태를 직접 변경하는 것이 아니라, 상태를 업데이트할 때 새로운 객체나 배열을 반환하는 방식으로 불변성을 유지하는 것이 중요합니다. 이는 리액트가 상태 변경을 추적하고, 필요할 때만 컴포넌트를 재렌더링하게 해줍니다.
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Build an App' },
]);
// 스프레드 연산자 잘 사용하기
const addTodo = (text) => {
setTodos((prevTodos) => [...prevTodos, { id: Date.now(), text }]);
};
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<button onClick={() => addTodo('New Todo')}>Add Todo</button>
</div>
);
}
UI 레이어와 비즈니스 레이어를 구분짓다보면 자연스럽게 상태 또한 분리가 됩니다. 그래서 상태를 분리하기 위해 레이어를 고려하면 좋습니다. 비즈니스 레이어는 커스텀 훅으로 만들면 가독성과 유지보수성이 향상합니다.
function useUserData(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUserData = async () => {
const response = await fetch(`/api/user/${userId}`);
const data = await response.json();
setUser(data);
setLoading(false);
};
fetchUserData();
}, [userId]);
return { user, loading };
}
function UserProfile({ userId }) {
const { user, loading } = useUserData(userId);
if (loading) return <p>Loading...</p>;
return <div>{user ? <h1>{user.name}</h1> : <p>No user found</p>}</div>;
}
지금까지 여러 가지 종류의 상태와 상태를 만들고 관리하는 방법에 대해 알아보았습니다. 주니어 개발자로서 쓰는 글이라 어색한 점이 많을 것 같고, 상태를 목적에 맞게 사용하고 전략적으로 관리하기 위해서는 알아야 하는 게 많은 것 같습니다. 무작정 개발하기보다는 요구사항을 먼저 분석하고 그에 맞는 전략을 사용하도록 해보겠습니다.