나만보는개발공부블로그

useState의 과다사용방지를 위한 정리글 본문

Web Development/Front

useState의 과다사용방지를 위한 정리글

alexrider94 2025. 4. 17. 21:55

리액트 애플리케이션의 성능을 최적화하려면 상태 관리에 대한 깊은 이해가 필요하다. 이번 글에서 useState를 효과적으로 사용하기 위한 다양한 방법과 정리한 내용들을 기록해볼려한다.

1. useState와 객체 식별성: Set() 객체 다루기

React의 useState는 객체의 내용이 아닌 객체 식별성(reference identity)을 기반으로 리렌더링을 트리거합니다. 이로 인해 Set, Map과 같은 컬렉션 객체를 사용할 때 문제가 발생할 수 있다.

const [set, setSet] = useState(new Set());

return (
  <button onClick={() => setSet(set.add('set'))}>
    아이템 추가 (작동 안 함)
  </button>
)

위 코드에서 set.add('set')은 동일한 Set 객체를 반환하기 때문에 리액트는 상태 변화를 감지하지 못한다.

해결책: 새 객체 참조 생성하기

// 방법 1: 배열로 래핑하기
const [[set], setSet] = useState([new Set()]);

return (
  <button onClick={() => setSet([set.add('set')])}>
    아이템 추가 (작동함)
  </button>
)

// 방법 2: 새 Set 객체 생성하기
const [set, setSet] = useState(new Set());

return (
  <button onClick={() => {
    const newSet = new Set(set);
    newSet.add('set');
    setSet(newSet);
  }}>
    아이템 추가 (작동함)
  </button>
)

2. useState 대신 useRef의 올바른 사용법

컴포넌트 내에서 변경은 필요하지만 렌더링을 트리거하지 않아야 하는 값에는 useRef를 사용하는 것이 적합하다.

const count = useRef(0);

const onClick = () => {
  count.current += 1;
  console.log(`클릭 횟수: ${count.current}`);
}

return (
  <div onClick={onClick}>클릭해보세요</div>
)

 

  • useState: UI에 직접 표시되어야 하는 상태나 렌더링에 영향을 주는 데이터
  • useRef:
    • 내부 계산용 값
    • DOM 요소 참조
    • 타이머 ID
    • 이전 렌더링의 값 저장
    • 컴포넌트 리렌더링이 필요 없는 값

3. URL 매개변수를 통한 상태 관리

정렬이나 필터링과 같은 UI 상태를 URL에 저장할 경우 State값으로 리렌더링이 일어나지않고 URL을 통해 데이터를 관리할 수 있다.

1. 페이지 새로고침 후에도 상태 유지

2. 북마크 및 공유 가능한 URL

3.뒤로가기 버튼과 연동
위의 이점들을 가져갈 수 있다.

일반적인 useState를 사용한 방식

 
// 기존 방식: 컴포넌트 내부 상태
const [sort, setSort] = useState<string | null>(null);

return (
  <>
    <button onClick={() => setSort('asc')}>오름차순</button>
    <button onClick={() => setSort('desc')}>내림차순</button>
  </>
)

URL 매개변수를 사용한 개선된 방식

const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const sort = searchParams.get('sort');

const createQueryString = useCallback((name: string, value: string) => {
  const params = new URLSearchParams(searchParams.toString());
  params.set(name, value);
  
  return params.toString();
}, [searchParams]);

return (
  <>
    <button onClick={() => 
      router.push(`${pathname}?${createQueryString('sort', 'asc')}`)
    }>
      오름차순
    </button>
    <button onClick={() => 
      router.push(`${pathname}?${createQueryString('sort', 'desc')}`)
    }>
      내림차순
    </button>
  </>
)

nuqs 라이브러리 활용

복잡한 쿼리 파라미터 관리를 위해 nuqs 라이브러리를 사용할 수 있다.

import { useQueryState } from 'nuqs';

function SortComponent() {
  const [sort, setSort] = useQueryState('sort');
  
  return (
    <>
      <button onClick={() => setSort('asc')}>오름차순</button>
      <button onClick={() => setSort('desc')}>내림차순</button>
    </>
  );
}

 

4. 데이터 페칭 최적화

데이터 페칭을 useState로 관리할 때 발생하는 일반적인 문제점:

  • 로딩/에러 상태 관리를 위한 중복 코드
  • 비동기 로직 처리 복잡성
  • 캐싱 및 재시도 기능 부재

해결책 1: 서버 컴포넌트 (RSC) 활용

// 서버 컴포넌트
async function ProductDetailsServer({productId, searchParams}) {
  const product = await fetchProduct(productId);
  const activeTab = searchParams.tab ?? 'description';
  
  return (
    <div>
      <h1>{product.name}</h1>
      <Tabs activeTab={activeTab} product={product} />
    </div>
  );
}

해결책 2: useTransition 활용

const [isPending, startTransition] = useTransition();

return (
  <button 
    disabled={isPending}
    onClick={() => {
      startTransition(async () => {
        await processCafeData();
      });
    }}
  >
    {isPending ? '처리 중...' : '카페 데이터 처리'}
  </button>
);

해결책 3: 쿼리 라이브러리 활용 (TanStack Query)

import { useQuery } from '@tanstack/react-query';

function Products() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생: {error.message}</div>;

  return (
    <ul>
      {data.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

 

5. 폼 상태 관리 개선

폼 필드마다 useState를 사용하는 방식으로 폼을 구현하면 아래와 같이 많은 state가 발생하는 경우가 발생한다.

const [firstName, setFirstName] = useState('');
const [username, setUsername] = useState('');
const [bio, setBio] = useState('');

const handleSubmit = (e) => {
  e.preventDefault();
  // 폼 제출 로직
}

return (
  <form onSubmit={handleSubmit}>
    <input value={firstName} onChange={e => setFirstName(e.target.value)} />
    {/* 다른 필드들 */}
  </form>
);

해결책 1: FormData API 활용

import { z } from 'zod';

const UserSchema = z.object({
  firstName: z.string().min(1, "이름을 입력하세요"),
  username: z.string().min(3, "사용자명은 3글자 이상이어야 합니다"),
  bio: z.string().optional(),
});

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  event.preventDefault();
  const formData = new FormData(event.currentTarget);
  
  try {
    const data = UserSchema.parse(Object.fromEntries(formData));
    // 유효성 검사 통과, 데이터 처리
    console.log(data);
  } catch (error) {
    // 유효성 검사 실패
    console.error(error);
  }
}

return (
  <form onSubmit={handleSubmit}>
    <input name="firstName" />
    <input name="username" />
    <textarea name="bio" />
    <button type="submit">제출</button>
  </form>
);

해결책 2: useActionState 활용 (Next.js)

'use client';

import { useActionState } from 'next/client';
import { createUser } from '@/actions/user';

export default function RegisterForm() {
  const initialState = { success: false, errors: {} };
  const [state, formAction, pending] = useActionState(createUser, initialState);

  return (
    <form action={formAction}>
      <input name="firstName" />
      {state.errors?.firstName && <p>{state.errors.firstName}</p>}
      
      {/* 다른 필드들 */}
      
      <button type="submit" disabled={pending}>
        {pending ? '제출 중...' : '회원가입'}
      </button>
    </form>
  );
}

해결책 3: 폼 라이브러리 활용

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

export default function RegisterForm() {
  const { 
    register, 
    handleSubmit, 
    formState: { errors, isSubmitting } 
  } = useForm({
    resolver: zodResolver(UserSchema)
  });

  const onSubmit = async (data) => {
    // 폼 제출 로직
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} />
      {errors.firstName && <p>{errors.firstName.message}</p>}
      
      {/* 다른 필드들 */}
      
      <button type="submit" disabled={isSubmitting}>
        회원가입
      </button>
    </form>
  );
}

 

6. 상태 과다 사용 방지하기

많은 관련 상태를 개별적으로 관리하면 코드가 복잡해지고 동기화 문제가 발생할 수 있다.

해결책 1: 객체로 상태 그룹화

// 개별 상태
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');

// 객체로 그룹화
const [userProfile, setUserProfile] = useState({
  firstName: '',
  lastName: '',
  email: '',
  phone: ''
});

// 업데이트 예시
const handleFirstNameChange = (e) => {
  setUserProfile(prev => ({
    ...prev,
    firstName: e.target.value
  }));
};

해결책 2: Immer 활용

중첩된 객체 상태 업데이트를 간소화하기 위해 Immer를 사용할 수 있다.

import { produce } from 'immer';

const [userProfile, setUserProfile] = useState({
  personal: {
    firstName: '',
    lastName: ''
  },
  contact: {
    email: '',
    phone: ''
  },
  preferences: {
    theme: 'light',
    notifications: true
  }
});

// Immer를 사용한 깔끔한 업데이트
const updateFirstName = (newName) => {
  setUserProfile(produce(draft => {
    draft.personal.firstName = newName;
  }));
};

// 여러 필드 업데이트도 간단하게
const updateContactInfo = (email, phone) => {
  setUserProfile(produce(draft => {
    draft.contact.email = email;
    draft.contact.phone = phone;
  }));
};