상태관리 기초 — useState와 useReducer 활용법
useState는 단순한 상태에, useReducer는 복잡한 상태 로직에 적합합니다. 둘의 선택 기준을 알면 코드 구조가 깔끔해집니다.
React Native의 상태관리는 React와 동일합니다. 하지만 모바일 특유의 패턴(로딩, 에러, 폼 상태 등)이 자주 등장하기 때문에 실전 예제를 중심으로 정리합니다.
useState 기본
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
function Counter() {
const [count, setCount] = useState(0);
return (
<View style={styles.container}>
<Text style={styles.count}>{count}</Text>
<View style={styles.row}>
{/* 이전 상태 기반 업데이트 — 함수형 업데이트 권장 */}
<Pressable onPress={() => setCount((prev) => prev - 1)}>
<Text style={styles.button}>-</Text>
</Pressable>
<Pressable onPress={() => setCount((prev) => prev + 1)}>
<Text style={styles.button}>+</Text>
</Pressable>
</View>
</View>
);
}
객체 상태 업데이트
interface UserProfile {
name: string;
email: string;
bio: string;
}
function ProfileForm() {
const [profile, setProfile] = useState<UserProfile>({
name: '',
email: '',
bio: '',
});
// 객체 상태는 반드시 스프레드로 새 객체를 만들어야 함
const updateField = (field: keyof UserProfile, value: string) => {
setProfile((prev) => ({ ...prev, [field]: value }));
};
return (
<View>
<TextInput
value={profile.name}
onChangeText={(text) => updateField('name', text)}
placeholder="이름"
/>
<TextInput
value={profile.email}
onChangeText={(text) => updateField('email', text)}
placeholder="이메일"
/>
</View>
);
}
배열 상태 업데이트
function TodoList() {
const [todos, setTodos] = useState<string[]>([]);
const [input, setInput] = useState('');
// 추가
const addTodo = () => {
if (!input.trim()) return;
setTodos((prev) => [...prev, input.trim()]);
setInput('');
};
// 삭제
const removeTodo = (index: number) => {
setTodos((prev) => prev.filter((_, i) => i !== index));
};
// 수정
const updateTodo = (index: number, newText: string) => {
setTodos((prev) => prev.map((item, i) => (i === index ? newText : item)));
};
return (
<View>
<TextInput value={input} onChangeText={setInput} />
<Pressable onPress={addTodo}><Text>추가</Text></Pressable>
{todos.map((todo, i) => (
<View key={i} style={styles.todoItem}>
<Text>{todo}</Text>
<Pressable onPress={() => removeTodo(i)}>
<Text>삭제</Text>
</Pressable>
</View>
))}
</View>
);
}
상태 업데이트 시 **직접 변경(mutation)하면 리렌더링이 일어나지 않습니다 **. 항상 새 객체/배열을 만들어야 합니다.
prev.push(item)이 아니라[...prev, item]을 사용하세요.
비동기 데이터 로딩 패턴
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function UserList() {
const [state, setState] = useState<AsyncState<User[]>>({
data: null,
loading: false,
error: null,
});
const fetchUsers = async () => {
setState({ data: null, loading: true, error: null });
try {
const response = await fetch('https://api.example.com/users');
const users = await response.json();
setState({ data: users, loading: false, error: null });
} catch (err) {
setState({ data: null, loading: false, error: '데이터를 불러올 수 없습니다' });
}
};
if (state.loading) return <ActivityIndicator />;
if (state.error) return <Text>{state.error}</Text>;
if (!state.data) return <Text>데이터 없음</Text>;
return (
<FlatList
data={state.data}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>
);
}
useReducer — 복잡한 상태 관리
상태 업데이트 로직이 복잡하거나, 여러 관련 상태를 함께 관리할 때 useReducer가 적합합니다.
import { useReducer } from 'react';
// 상태 타입
interface FormState {
values: { email: string; password: string };
errors: { email?: string; password?: string };
touched: { email: boolean; password: boolean };
isSubmitting: boolean;
}
// 액션 타입
type FormAction =
| { type: 'SET_VALUE'; field: string; value: string }
| { type: 'SET_TOUCHED'; field: string }
| { type: 'SET_ERROR'; field: string; error: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_FAILURE'; errors: Record<string, string> }
| { type: 'RESET' };
// 리듀서 함수
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_VALUE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined },
};
case 'SET_TOUCHED':
return {
...state,
touched: { ...state.touched, [action.field]: true },
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_SUCCESS':
return { ...initialState };
case 'SUBMIT_FAILURE':
return { ...state, isSubmitting: false, errors: action.errors };
case 'RESET':
return initialState;
default:
return state;
}
}
const initialState: FormState = {
values: { email: '', password: '' },
errors: {},
touched: { email: false, password: false },
isSubmitting: false,
};
function LoginForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleSubmit = async () => {
dispatch({ type: 'SUBMIT_START' });
try {
await loginAPI(state.values);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({
type: 'SUBMIT_FAILURE',
errors: { email: '로그인에 실패했습니다' },
});
}
};
return (
<View style={styles.container}>
<TextInput
value={state.values.email}
onChangeText={(text) =>
dispatch({ type: 'SET_VALUE', field: 'email', value: text })
}
onBlur={() => dispatch({ type: 'SET_TOUCHED', field: 'email' })}
placeholder="이메일"
/>
{state.touched.email && state.errors.email && (
<Text style={styles.error}>{state.errors.email}</Text>
)}
<TextInput
value={state.values.password}
onChangeText={(text) =>
dispatch({ type: 'SET_VALUE', field: 'password', value: text })
}
secureTextEntry
placeholder="비밀번호"
/>
<Pressable
onPress={handleSubmit}
disabled={state.isSubmitting}
>
<Text>{state.isSubmitting ? '로그인 중...' : '로그인'}</Text>
</Pressable>
</View>
);
}
useState vs useReducer 선택 기준
| 기준 | useState | useReducer |
|---|---|---|
| 상태 구조 | 단순 (원시값, 작은 객체) | 복잡 (중첩 객체, 관련 상태 묶음) |
| 업데이트 로직 | 단순 | 조건 분기가 많음 |
| 상태 전환 | 독립적 | 한 액션이 여러 상태를 변경 |
| 테스트 | 직접 호출 | 리듀서 함수 단위 테스트 용이 |
| 추천 상황 | 토글, 카운터, 단일 입력 | 폼, 비동기 로딩, 복잡한 UI |
커스텀 Hook으로 패턴 추출
// 비동기 데이터 로딩을 위한 커스텀 Hook
function useAsync<T>(asyncFn: () => Promise<T>) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: Error | null;
}>({
data: null,
loading: false,
error: null,
});
const execute = async () => {
setState({ data: null, loading: true, error: null });
try {
const result = await asyncFn();
setState({ data: result, loading: false, error: null });
} catch (error) {
setState({ data: null, loading: false, error: error as Error });
}
};
return { ...state, execute };
}
// 사용
function UserProfile() {
const { data, loading, error, execute } = useAsync(() =>
fetch('/api/user').then((r) => r.json())
);
useEffect(() => { execute(); }, []);
if (loading) return <ActivityIndicator />;
if (error) return <Text>에러: {error.message}</Text>;
return <Text>{data?.name}</Text>;
}
정리
- useState: 단순한 상태에 사용 (토글, 카운터, 단일 입력값)
- useReducer: 복잡한 상태 로직에 사용 (폼 관리, 비동기 상태, 관련 상태 묶음)
- 상태 업데이트는 ** 항상 불변성을 유지 **하세요 — 직접 변경은 리렌더링을 트리거하지 않습니다
- 반복되는 패턴은 ** 커스텀 Hook으로 추출 **하면 코드 재사용성이 높아집니다
- 함수형 업데이트
setState(prev => ...)패턴을 습관화하세요
댓글 로딩 중...