useState는 단순한 상태에, useReducer는 복잡한 상태 로직에 적합합니다. 둘의 선택 기준을 알면 코드 구조가 깔끔해집니다.

React Native의 상태관리는 React와 동일합니다. 하지만 모바일 특유의 패턴(로딩, 에러, 폼 상태 등)이 자주 등장하기 때문에 실전 예제를 중심으로 정리합니다.


useState 기본

TSX
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>
  );
}

객체 상태 업데이트

TSX
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>
  );
}

배열 상태 업데이트

TSX
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]을 사용하세요.


비동기 데이터 로딩 패턴

TSX
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가 적합합니다.

TSX
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 선택 기준

기준useStateuseReducer
상태 구조단순 (원시값, 작은 객체)복잡 (중첩 객체, 관련 상태 묶음)
업데이트 로직단순조건 분기가 많음
상태 전환독립적한 액션이 여러 상태를 변경
테스트직접 호출리듀서 함수 단위 테스트 용이
추천 상황토글, 카운터, 단일 입력폼, 비동기 로딩, 복잡한 UI

커스텀 Hook으로 패턴 추출

TSX
// 비동기 데이터 로딩을 위한 커스텀 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 => ...) 패턴을 습관화하세요
댓글 로딩 중...