Navigation

8. 상태 소유와 갱신

상태는 값의 수명과 소유자에 맞는 도구로 고르고, 파생값은 저장하지 않고 렌더에서 계산해야 합니다. 여러 화면이 공유하는 판단만 전역 스토어에 두고, 이전 상태에 의존하는 갱신은 함수형으로 씁니다. 이펙트의 반응형 값은 의존성에 남기고, 최신 값을 읽기만 하는 콜백은 useEffectEvent로 분리합니다. URL 상태의 바인딩 이름을 통일해 서버 응답과 구분합니다.

8.1 파생값은 렌더 중에 계산합니다

지금 입력으로 구할 수 있는 값은 상태에 두지 않고 렌더에서 계산합니다.

현재 프롭스, 상태, search 파라미터, 응답으로 계산할 수 있는 값은 렌더 중에 구합니다. useState에 복제해 useEffect로 동기화하면 추가 렌더와 값의 어긋남이 생기기 쉽습니다. 여러 화면이 공유하는 파생 판단을 스토어에 채우는 이펙트만 예외이며, 허용 조건은 규칙 8.3를 따릅니다.

파생값을 이펙트로 다시 상태에 동기화합니다.

const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedCount, setSelectedCount] = useState(0);

useEffect(() => {
	setSelectedCount(selectedIds.length);
}, [selectedIds]);

같은 selectedIds에서 렌더 중에 바로 계산합니다.

const [selectedIds, setSelectedIds] = useState<string[]>([]);

return <UiSelectedCountBadge count={selectedIds.length} />;

함께 보기

8.2 상태 도구는 값의 소유자와 수명으로 고릅니다

로컬, 공유, 서버, URL 상태의 소유자를 구분합니다.

상태 도구는 값의 수명과 소유자로 고릅니다.

도구 고르기

flowchart LR
	q1{"링크를 공유해도<br>같은 화면이 열리는가?"} -- 아니요 --> q2{"서버가 소유하는가?"} -- 아니요 --> q3{"묶음 밖에서도<br>읽는가?"} -- 아니요 --> r4("useState, useReducer")
	q1 -- 예 --> r1("search 파라미터")
	q2 -- 예 --> r2("react-query")
	q3 -- 예 --> r3("Zustand")
상태의 소유자기본 도구
로컬 UIuseState 또는 useReducer
한 컴포넌트 묶음에서 공유하는 UIuseState + Context
전역 클라이언트Zustand
서버@tanstack/react-query
링크를 공유해도 같은 화면이 열려야 하는 값라우트 search 파라미터(nuqs의 useQueryStates)

혼동하기 쉬운 상태

혼동하기 쉬운 상태소유 기준
새로 고침, 뒤로 가기, 링크 공유로 유지할 필터, 정렬, 페이지, 선택 행search 파라미터에 두고 useState로 복제하지 않습니다
열림, 닫힘, 마우스 올림, 입력 중인 임시 값주소에 올리지 않습니다
합성 부품이나 작은 묶음의 두세 단계 아래에서 공유하는 UIuseState가 소유하고 Context로 전달합니다
묶음 밖의 화면, 레이아웃에서도 읽거나 바꾸는 UIContext를 위로 올리지 않고 전역 스토어로 옮깁니다. 파생값이 아닌 탭 selectedId도 같습니다

서버 상태와 search 파라미터는 사용하는 컴포넌트가 같은 key로 직접 읽고 부모 프롭으로 전달하지 않습니다. Context는 전역 상태 도구가 아니라 묶음 안의 전달 수단입니다.

전역 값과 서버 값까지 useState가 소유합니다.

const [isOpen, setIsOpen] = useState(false);
const [theme, setTheme] = useState<Theme>("light");

/**
 * 사용자 상세 조회 API
 */
const responseUserGetItemSuspense = useUserGetItemSuspense();
const [userName, setUserName] = useState(responseUserGetItemSuspense.data.name);

값의 소유자에 맞는 도구를 씁니다.

const [isOpen, setIsOpen] = useState(false);
const themeStore = useThemeStore();

/**
 * 사용자 상세 조회 API
 */
const responseUserGetItemSuspense = useUserGetItemSuspense();

링크 공유로 유지할 목록 필터를 useState에 둡니다.

const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);

주소가 소유한 값은 search 파라미터로 읽고 씁니다.

const [urlParams, setUrlParams] = useQueryStates(productUrlParsers);

묶음 밖의 화면이 읽는 값을 Context로 앱 루트까지 올려 전역 스토어처럼 씁니다.

// 테마는 레이아웃과 모든 화면이 읽는데 Context를 루트에 두고 화면마다 Provider를 찾아 올라감
const ThemeContext = createContext<Theme>("light");

묶음 밖에서도 읽는 값은 전역 스토어가 소유합니다.

const themeStore = useThemeStore();

합성 컴포넌트 안에서 부품끼리 나눠 쓰는 상태는 Context로 내려보냅니다.

component/ui/tabs/_hook/use-tabs.tsts
/**
 * 탭 부품끼리 나눠 쓰는 값
 */
interface UiTabsContextValue {
	/**
	 * 지금 열린 탭 식별자
	 */
	selectedId: string;
	/**
	 * 탭을 고를 때
	 */
	onSelect: (id: string) => void;
}

export const UiTabsContext = createContext<UiTabsContextValue | null>(null);
component/ui/tabs/_ui-tabs-root.tsxtsx
import {UiTabsContext} from "@/component/ui/tabs/_hook/use-tabs";

/**
 * 탭 묶음 루트 입력 계약
 */
export interface UiTabsRootProps {
	/**
	 * 처음 열어 둘 탭 식별자
	 */
	defaultId: string;
	/**
	 * 탭 목록과 패널 부품
	 */
	children: ReactNode;
}

export const UiTabsRoot = (props: UiTabsRootProps) => {
	const [selectedId, setSelectedId] = useState(props.defaultId);

	return <UiTabsContext value={{ selectedId, onSelect: setSelectedId }}>{props.children}</UiTabsContext>;
};

함께 보기

8.3 여러 화면이 함께 쓰는 파생 판단만 스토어로 올립니다

같은 도메인 판별 로직이 여러 화면에 퍼지지 않습니다.

여러 화면, 메뉴, 라우트 가드가 반복해서 쓰는 파생 판단만 스토어로 올립니다. 단일 화면에서 한두 번 읽는 쿼리 필드는 복제하지 않습니다.

작업기준
도메인 판별초기화, 레이아웃 등 한 경계에 모으고 화면은 permissionStore.canEditProduct 같은 결과만 읽습니다
스토어 채우기쿼리에는 onSuccess 같은 성공 콜백이 없으므로 소유자가 분명한 경계의 useEffect에서 처리합니다
이펙트 예외 근거규칙 8.1의 예외이므로 TypeScript 규칙 6.5에 따라 공유 이유를 남깁니다
이펙트의 스토어 접근선택자로 set 함수만 꺼내고 값 의존성은 그대로 적습니다. 스토어 객체 전체에 의존하면 set마다 다시 실행됩니다

개별 화면이 도메인을 판별하고 스토어에 저장합니다.

page/product-detail/pg-product-detail.tsxts
const setCanEditProduct = usePermissionStore((state) => state.setCanEditProduct);
const canEditProduct = responseProductGetItemSuspense.data.ownerId === session.userId;

useEffect(() => {
	setCanEditProduct(canEditProduct);
}, [setCanEditProduct, canEditProduct]);

화면은 스토어에 채워진 결과만 참조합니다.

const permissionStore = usePermissionStore();

if (permissionStore.canEditProduct) {
	// ...
}

스토어 전체에 의존하는 이펙트가 set마다 다시 실행됩니다.

page/_layout/pg-app-layout.tsxts
const permissionStore = usePermissionStore();

/**
 * 부트스트랩 응답의 권한 목록으로 수정 가능 여부 채움
 */
useEffect(() => {
	permissionStore.setCanEditProduct(responseAccessBootstrapSuspense.data.capabilities.includes("product:edit"));
}, [permissionStore, responseAccessBootstrapSuspense.data]);

초기화 경계에서 스토어를 채우고 스토어에서는 set 함수만 선택합니다.

page/_layout/pg-app-layout.tsxts
const setCanEditProduct = usePermissionStore((state) => state.setCanEditProduct);

/**
 * 부트스트랩 응답의 권한 목록으로 수정 가능 여부 채움. 여러 화면과 라우트 가드가 이 결과를 읽음
 */
useEffect(() => {
	// state-calculate-derived-values-during-render 예외: 화면 여럿이 같은 판단을 읽어 경계에서 한 번 채움
	setCanEditProduct(responseAccessBootstrapSuspense.data.capabilities.includes("product:edit"));
}, [setCanEditProduct, responseAccessBootstrapSuspense.data]);

함께 보기

8.4 이전 상태로 계산하는 갱신은 함수형 setState로 씁니다

이전 상태에 의존하는 갱신에서 오래된 값을 사용하는 오류를 막습니다.

다음 상태가 현재 상태 값에 의존하면 바깥 변수를 직접 읽지 않고 함수형 업데이터를 씁니다. 한 이벤트 안에서 두 번 갱신하거나, await 뒤나 오래 사는 클로저 안에서 갱신하면 결과가 갈립니다. 한 번만 부르는 갱신은 두 형태가 같은 결과를 내지만, 형태를 하나로 고정해 자리마다 다시 판단하지 않습니다.

오래 사는 콜백이 등록 시점의 상태를 붙잡습니다.

/**
 * 새 참여자가 들어오면 선택 목록에 추가
 */
useEffect(() => {
	// 콜백은 등록 시점의 selectedUserIds를 붙잡음. 나중에 도착한 참여자가 옛 목록에 더해져 그사이 고른 항목이 지워짐
	return subscribeToUserJoined((joinedUserId) => {
		setSelectedUserIds([...selectedUserIds, joinedUserId]);
	});
}, []);

함수형 업데이터로 항상 최신 상태를 기준으로 갱신합니다.

/**
 * 새 참여자가 들어오면 선택 목록에 추가
 */
useEffect(() => {
	return subscribeToUserJoined((joinedUserId) => {
		setSelectedUserIds((currentUserIds) => [...currentUserIds, joinedUserId]);
	});
}, []);

8.5 비반응형 이펙트 콜백은 useEffectEvent로 분리합니다

콜백은 최신 값을 읽고 이펙트는 구독 조건의 변화에만 반응합니다.

구독 이펙트의 콜백이 최신 프롭스, 상태를 읽되 그 값 때문에 재구독할 필요가 없다면 useEffectEvent를 씁니다. 연결 대상, 구독 조건처럼 바뀌면 재설치해야 하는 값은 이펙트 의존성에 남깁니다.

조건처리
React 19.2 이상비반응형 콜백에 ref 우회 대신 useEffectEvent를 씁니다
React 19.2 미만의존성에 따른 재구독을 먼저 검토하고, 최신 콜백만 바꿔야 할 때 ref 동기화를 검토합니다
클릭, 제출 등 사용자 액션이름 붙인 핸들러에 둡니다. 이펙트로 옮기지 않습니다
Effect Event 호출같은 컴포넌트의 이펙트나 다른 Effect Event 안에서만 호출합니다
Effect Event 전달다른 컴포넌트, 훅, JSX 이벤트 프롭에 넘기지 않습니다

반환 함수는 참조 동일성을 보장하지 않으며 이펙트 의존성에 넣지 않습니다. 의존성 경고를 없애려고 반응해야 할 값까지 감싸지 않습니다. useEffectEvent를 인식하는 최신 eslint-plugin-react-hooks와 biome을 쓰지 않으면 Correct 예제가 훅 규칙 위반으로 표시됩니다.

최신 콜백을 읽기 위해 ref를 직접 동기화합니다.

const onChangeRef = useRef(props.onChange);

useEffect(() => {
	onChangeRef.current = props.onChange;
}, [props.onChange]);

useEffect(() => {
	const unsubscribe = subscribeToProductChanges(props.productId, (change) => {
		onChangeRef.current(change);
	});

	return unsubscribe;
}, [props.productId]);

비반응형 콜백은 useEffectEvent로 분리합니다.

/**
 * 상품 변경 수신 시 최신 onChange 로직 실행
 */
const handleProductChange = useEffectEvent((change: ProductChange) => {
	props.onChange(change);
});

/**
 * 상품 변경 구독 생명주기 유지
 */
useEffect(() => {
	const unsubscribe = subscribeToProductChanges(props.productId, (change) => {
		handleProductChange(change);
	});

	return unsubscribe;
}, [props.productId]);

함께 보기

8.6 URL 파서, 파싱 결과, 플랫폼 객체의 이름을 구분합니다

주소가 소유한 상태, 플랫폼 객체, 서버 응답이 이름만으로 구분됩니다.

라우트 search 파라미터는 파싱 전 원본, 파싱 결과, 서버 응답이 구분되도록 이름을 고정합니다.

대상이름과 위치
파라미터별 파싱 함수 묶음<범위>UrlParsers. 화면의 URL 계약이므로 소유자 _constant에 둡니다
파싱 결과와 갱신 함수urlParams, setUrlParams
플랫폼 URLSearchParams 객체searchParams. 파싱 결과에는 쓰지 않습니다

query가 들어간 이름은 규칙 2.1에 따라 서버 요청 바인딩에만 씁니다. 값을 주소에 둘지는 규칙 8.2로 판단합니다.

파서 묶음의 역할이 이름에 드러나지 않습니다.

page/products/_constant/product-search.tsts
export const productSearch = {
	page: parseAsInteger.withDefault(pagination_default_page),
	keyword: parseAsString,
};

파서 묶음은 <범위>UrlParsers로 소유자 _constant 폴더에 둡니다.

page/products/_constant/product-url-parsers.tsts
/**
 * 상품 목록 화면이 주소에 올린 상태의 파서 묶음
 */
export const productUrlParsers = {
	page: parseAsInteger.withDefault(pagination_default_page),
	keyword: parseAsString,
};

파싱 결과에 플랫폼 객체와 서버 요청용 이름을 섞어 씁니다.

const [searchParams, setSearchParams] = useQueryStates(productUrlParsers);
const query = searchParams.keyword;

<UiSearchInput value={query} />;

파싱을 거친 값은 urlParams이고 별칭 없이 체인으로 읽습니다.

const [urlParams, setUrlParams] = useQueryStates(productUrlParsers);

<UiSearchInput value={urlParams.keyword} />;

플랫폼 URLSearchParams 객체만 searchParams입니다.

const [searchParams] = useSearchParams();

<UiShareLinkButton href={`/products?${searchParams.toString()}`} />;

함께 보기