icon안동민 개발노트

훅과 상태 관리


 React 훅을 TypeScript와 함께 사용하면 상태 관리의 타입 안정성을 크게 향상시킬 수 있습니다.

 이 절에서는 다양한 훅의 타입스크립트 사용법과 Best Practices를 다룹니다.

기본 훅 사용하기

  1. useState
import React, { useState } from 'react';
 
const Counter: React.FC = () => {
  const [count, setCount] = useState<number>(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>Increment</button>
    </div>
  );
};
  1. useEffect
import React, { useState, useEffect } from 'react';
 
const DataFetcher: React.FC = () => {
  const [data, setData] = useState<string | null>(null);
 
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://api.example.com/data');
      const result = await response.text();
      setData(result);
    };
 
    fetchData();
  }, []);
 
  return <div>{data ? data : 'Loading...'}</div>;
};
  1. useContext
import React, { createContext, useContext, useState } from 'react';
 
interface ThemeContextType {
  isDark: boolean;
  toggleTheme: () => void;
}
 
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
const ThemeProvider: React.FC = ({ children }) => {
  const [isDark, setIsDark] = useState(false);
  const toggleTheme = () => setIsDark(!isDark);
 
  return (
    <ThemeContext.Provider value={{ isDark, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};
 
const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};
 
const ThemedComponent: React.FC = () => {
  const { isDark, toggleTheme } = useTheme();
  return (
    <div style={{ background: isDark ? 'black' : 'white', color: isDark ? 'white' : 'black' }}>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
};

커스텀 훅 작성하기

 타입 안전한 커스텀 훅 예시

import { useState, useCallback } from 'react';
 
interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
}
 
const useCounter = (initialValue: number = 0): UseCounterReturn => {
  const [count, setCount] = useState<number>(initialValue);
 
  const increment = useCallback(() => setCount(prev => prev + 1), []);
  const decrement = useCallback(() => setCount(prev => prev - 1), []);
 
  return { count, increment, decrement };
};
 
// 사용
const CounterComponent: React.FC = () => {
  const { count, increment, decrement } = useCounter(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
};

useReducer와 타입스크립트

 복잡한 상태 로직 관리

import React, { useReducer } from 'react';
 
type State = { count: number };
type Action = { type: 'increment' | 'decrement' | 'reset'; payload?: number };
 
const initialState: State = { count: 0 };
 
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: action.payload ?? 0 };
    default:
      return state;
  }
};
 
const Counter: React.FC = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset</button>
    </div>
  );
};

제네릭 훅

 유연하고 재사용 가능한 훅 만들기

import { useState, useCallback } from 'react';
 
const useArray = <T,>(initialArray: T[] = []) => {
  const [array, setArray] = useState<T[]>(initialArray);
 
  const push = useCallback((element: T) => {
    setArray(a => [...a, element]);
  }, []);
 
  const remove = useCallback((index: number) => {
    setArray(a => a.filter((_, i) => i !== index));
  }, []);
 
  return { array, push, remove };
};
 
// 사용
const StringArrayComponent: React.FC = () => {
  const { array, push, remove } = useArray<string>(['a', 'b', 'c']);
 
  return (
    <div>
      {array.map((item, index) => (
        <div key={index}>
          {item} <button onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button onClick={() => push('new item')}>Add Item</button>
    </div>
  );
};

타입 세이프티 전역 상태 관리

import React, { createContext, useContext, useReducer, ReactNode } from 'react';
 
interface State {
  user: string | null;
  isAuthenticated: boolean;
}
 
type Action =
  | { type: 'LOGIN'; payload: string }
  | { type: 'LOGOUT' };
 
const initialState: State = {
  user: null,
  isAuthenticated: false,
};
 
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'LOGIN':
      return { user: action.payload, isAuthenticated: true };
    case 'LOGOUT':
      return { user: null, isAuthenticated: false };
    default:
      return state;
  }
};
 
const AuthContext = createContext<{
  state: State;
  dispatch: React.Dispatch<Action>;
} | undefined>(undefined);
 
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  return (
    <AuthContext.Provider value={{ state, dispatch }}>
      {children}
    </AuthContext.Provider>
  );
};
 
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

비동기 작업 처리

 타입 안전한 비동기 작업 처리

import { useState, useEffect } from 'react';
 
interface User {
  id: number;
  name: string;
}
 
const useUser = (userId: number) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        const data: User = await response.json();
        setUser(data);
      } catch (err) {
        setError(err instanceof Error ? err : new Error('An unknown error occurred'));
      } finally {
        setLoading(false);
      }
    };
 
    fetchUser();
  }, [userId]);
 
  return { user, loading, error };
};

의존성 배열과 타입 추론

 의존성 배열에 대한 타입 추론을 개선하기 위해 useCallbackuseMemo를 사용할 때 명시적 타입을 제공할 수 있습니다.

const memoizedValue = useMemo<number>(() => {
  return expensiveComputation(a, b);
}, [a, b]);
 
const memoizedCallback = useCallback<(value: string) => void>((value) => {
  console.log(value);
}, []);

Best Practices와 가이드라인

  1. 명시적 타입 사용 : 가능한 한 any 타입 사용을 피하고 구체적인 타입을 사용하세요.
  2. 널리 사용되는 타입 정의 : 자주 사용되는 타입은 별도의 파일로 분리하여 재사용성을 높이세요.
  3. 제네릭 활용 : 재사용 가능한 훅을 만들 때 제네릭을 활용하세요.
  4. 타입 가드 사용 : 조건부 렌더링 시 타입 가드를 사용하여 타입 안정성을 높이세요.
  5. 의존성 배열 타입 체크 : useEffect, useMemo, useCallback의 의존성 배열을 주의 깊게 관리하세요.
  6. 에러 처리 : 비동기 작업 시 적절한 에러 처리와 타입 지정을 하세요.
  7. Context 사용 시 타입 안정성 : Context 생성 시 명시적 타입을 제공하고, 사용 시 타입 체크를 수행하세요.
  8. 불변성 유지 : 상태 업데이트 시 불변성을 유지하고, 필요한 경우 readonly 타입을 사용하세요.
  9. 테스트 작성 : 타입스크립트로 작성된 훅에 대해 단위 테스트를 작성하세요.
  10. IDE 지원 활용 : VSCode 등 IDE의 타입스크립트 지원 기능을 최대한 활용하세요.

 React 훅을 TypeScript와 함께 사용하면 런타임 오류를 줄이고 코드의 자기 문서화를 개선할 수 있습니다.