Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법

··설명 약 8분·작성: 해비·블로그 소개

Zustand 상태를 변경해도 리렌더링되지 않으면 무엇을 확인하나요?

Zustand 값을 바꿔도 화면이 그대로면 새 state가 만들어졌는지, 해당 값을 selector로 구독하는지, 선택 결과의 참조가 안정적인지를 확인합니다. 한 작업만 있는 별도 store에서 직접 변경과 새 참조 업데이트를 비교합니다.

적용 조건·범위: 실행 환경: Zustand 5가 설치된 별도 React·TypeScript 연습 앱입니다. 환경 준비는 Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기를 참고하세요. 기존 TaskFlow 데이터는 사용하지 않습니다. 아래 전체 코드를 src/App.tsx에 저장하고 기존 src/main.tsx의 App 연결을 유지합니다.

본문 근거: 공식 useShallow 오류 설명 · zustand.docs.pmnd.rs · zustand.docs.pmnd.rs/guides/updating-state

이 기술만 따로 실행하기

이 개념 직접 실행

상태를 바꾸고 화면에 반영되는 결과를 확인합니다.

예제 크게 보기 (새 탭)

기대 결과: 잘못된 변경은 store 내부 객체를 변이하지만 구독한 tasks 참조가 같아 화면은 false로 남습니다. 초기 상태로 돌린 뒤 새 참조로 변경하면 true가 표시됩니다.

직접 실행: 환경 준비와 전체 파일

실행 환경: Zustand 5가 설치된 별도 React·TypeScript 연습 앱입니다. 환경 준비는 Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기를 참고하세요. 기존 TaskFlow 데이터는 사용하지 않습니다. 아래 전체 코드를 src/App.tsx에 저장하고 기존 src/main.tsx의 App 연결을 유지합니다.

import { create } from 'zustand';
type Task = { id: number; completed: boolean };
type State = { tasks: Task[]; broken: () => void; fixed: () => void; reset: () => void };
const useDemo = create<State>()((set) => ({
  tasks: [{ id: 1, completed: false }],
  broken: () => set((state) => {
    state.tasks[0].completed = true;
    return { tasks: state.tasks };
  }),
  fixed: () => set((state) => ({
    tasks: state.tasks.map((task) => ({ ...task, completed: true })),
  })),
  reset: () => set({ tasks: [{ id: 1, completed: false }] }),
}));
export default function App() {
  const tasks = useDemo((state) => state.tasks);
  const broken = useDemo((state) => state.broken);
  const fixed = useDemo((state) => state.fixed);
  const reset = useDemo((state) => state.reset);
  return (
    <main>
      <p>완료: {String(tasks[0].completed)}</p>
      <button onClick={broken}>잘못된 변경</button>
      <button onClick={reset}>초기 상태</button>
      <button onClick={fixed}>새 참조로 변경</button>
    </main>
  );
}
npm run dev
npm run build

확인 순서: 잘못된 변경 → 초기 상태 → 새 참조로 변경

자가 확인: broken 함수를 정상 앱에 남기지 마세요. tasks 대신 tasks[0].completed를 구독하면 왜 증상이 달라질 수 있는지 설명해 보세요.

기존 검증 기록: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.

가장 먼저 확인할 것

다음 코드처럼 기존 배열을 직접 수정하고 같은 참조를 반환하면 문제가 생길 수 있습니다.

toggleTaskBroken: (id) =>
  set((state) => {
    const task = state.tasks.find(
      (item) => item.id === id,
    );


    if (task) {
      task.completed = !task.completed;
    }


    return {
      tasks: state.tasks,
    };
  }),

state.tasks는 이전과 같은 배열입니다.

새 참조로 변경

toggleTask: (id) =>
  set((state) => ({
    tasks: state.tasks.map((task) =>
      task.id === id
        ? {
            ...task,
            completed: !task.completed,
          }
        : task,
    ),
  })),

배열과 변경된 task 객체의 참조가 새로 만들어집니다.

selector가 다른 값을 보고 있는 경우

store state는 바뀌었는데 컴포넌트가 해당 값을 구독하지 않을 수도 있습니다.

const taskCount = useTaskStore(
  (state) => state.tasks.length,
);

완료 여부만 바꾸면 배열 길이는 그대로입니다.

따라서 taskCount selector 결과도 그대로입니다.

이 컴포넌트가 완료 개수까지 보여줘야 한다면 필요한 값을 선택해야 합니다.

const completedCount = useTaskStore(
  (state) =>
    state.tasks.filter(
      (task) => task.completed,
    ).length,
);

파생 배열 selector

const completedTasks = useTaskStore(
  (state) =>
    state.tasks.filter(
      (task) => task.completed,
    ),
);

filter는 새 배열을 반환합니다. 위 selector는 이 참조 문제를 보여주는 예이며 Zustand 5의 기본 create에서는 무한 업데이트가 발생할 수 있습니다. tasks의 기존 배열 참조를 선택한 뒤 화면에서 계산하거나 useShallow로 얕게 같은 결과를 재사용하는 방향을 확인하세요. 공식 useShallow 오류 설명은 이 문제를 다룹니다.

필요하다면 tasks 자체를 구독한 뒤 렌더 내부에서 계산하거나 비교 전략을 별도로 적용합니다.

진단 순서

화면이 바뀌지 않을 때 다음 순서로 확인합니다.

1. action이 실제 호출되는가
2. set이 실행되는가
3. 변경된 state 값이 맞는가
4. 객체·배열 참조가 바뀌었는가
5. 컴포넌트 selector가 해당 값을 구독하는가
6. selector 결과가 의도한 비교 단위를 반환하는가

console.log를 임시로 넣어 확인할 수 있습니다.

toggleTask: (id) =>
  set((state) => {
    const nextTasks = state.tasks.map(
      (task) =>
        task.id === id
          ? {
              ...task,
              completed: !task.completed,
            }
          : task,
    );


    console.log({
      before: state.tasks,
      after: nextTasks,
    });


    return { tasks: nextTasks };
  }),

확인 후 디버그 로그는 제거합니다.

학습 완료 체크리스트

  • mutation과 불변 업데이트 차이를 설명할 수 있습니다.
  • selector가 실제 변경 값을 구독하는지 확인할 수 있습니다.
  • 배열 길이처럼 변화하지 않는 파생 값을 잘못 구독하는 문제를 찾을 수 있습니다.
  • 리렌더링 문제를 단계별로 진단할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.

별도 재현 예제에서 잘못된 직접 변경과 정상 참조 변경을 비교하고, 정상 앱에서 같은 조작의 목록·완료 개수 결과를 확인합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 직접 실행

정상 앱의 immutable toggle을 유지하고 docs/mutation-reproduction.md에서 잘못된 직접 변경과 참조 변경을 비교합니다. 의도적 오류 코드를 앱 기본 경로에 심지 않습니다.

예제 크게 보기 (새 탭)

중간부터 시작하는 전체 실습 파일

기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. docs/mutation-reproduction.md — 이전 단계에 없던 파일을 만듭니다.

변경 이유와 결과: 정상 앱의 immutable toggle을 유지하고 docs/mutation-reproduction.md에서 잘못된 직접 변경과 참조 변경을 비교합니다. 의도적 오류 코드를 앱 기본 경로에 심지 않습니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

docs/mutation-reproduction.md 전체 파일

# 참조 변경 비교

잘못된 예: task.completed를 직접 바꾸고 같은 tasks 배열을 set하면 tasks를 구독한 화면이 갱신되지 않을 수 있습니다.
완료본은 map으로 새 배열·변경한 task 객체를 반환하는 toggleTask를 유지합니다.
작업 두 개 추가 → 첫 작업 완료 → 전체 2·완료 1 → 완료 필터에서 첫 작업만 보이는지 확인합니다.

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 터미널의 localhost 주소를 엽니다.
  3. 정상 앱의 immutable toggle을 유지하고 docs/mutation-reproduction.md에서 잘못된 직접 변경과 참조 변경을 비교합니다. 의도적 오류 코드를 앱 기본 경로에 심지 않습니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

원문에 있는 기존 체크포인트 검증 기록은 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증 통과입니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.

이전 프로젝트 단계: Zustand 리렌더링 원리와 selector 최적화 방법의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.

이 시작본과 완료본의 src/store/useTaskStore.ts는 정상적인 immutable toggleTask를 이미 사용합니다. 이번 변경 파일은 docs/mutation-reproduction.md이며 앱 코드를 오류 구현으로 교체하지 않습니다. 직접 변경과 새 배열·객체 반환은 상단의 별도 재현 앱에서 비교하고, 정상 앱에서는 TaskSummary가 완료 개수도 구독하여 함께 바뀌는지 확인하세요. 아래 부분 예제의 completed 필드 계약도 동일합니다.

이전 체크포인트

zustand-step-08-rerender

이번 목표

별도 재현 예제에서 잘못된 직접 변경과 정상 참조 변경을 비교하고, 정상 앱에서 같은 조작의 목록·완료 개수 결과를 확인합니다.

재현용 잘못된 코드

toggleTask: (id) =>
  set((state) => {
    const task = state.tasks.find(
      (item) => item.id === id,
    );


    if (task) {
      task.completed = !task.completed;
    }


    return {
      tasks: state.tasks,
    };
  }),

이 코드는 학습을 위한 재현용입니다. 최종 코드에 남기지 않습니다.

수정 코드

toggleTask: (id) =>
  set((state) => ({
    tasks: state.tasks.map((task) =>
      task.id === id
        ? {
            ...task,
            completed: !task.completed,
          }
        : task,
    ),
  })),

TaskSummary 점검

import { useTaskStore } from '../store/useTaskStore';


export default function TaskSummary() {
  const total = useTaskStore(
    (state) => state.tasks.length,
  );


  const completed = useTaskStore(
    (state) =>
      state.tasks.filter(
        (task) => task.completed,
      ).length,
  );


  return (
    <p>
      전체 {total}개 · 완료 {completed}개
    </p>
  );
}

이제 완료 상태가 변경되면 completed selector 결과가 바뀝니다.

Before / After

Before
같은 배열 참조 재사용
화면 갱신 기준이 불명확


After
새 배열과 새 객체 생성
변경되는 값을 selector로 구독

실행 확인

  1. 작업을 2개 이상 추가합니다.
  2. 완료 버튼을 누릅니다.
  3. 목록 표시와 완료 개수가 함께 바뀌는지 확인합니다.
  4. 삭제와 추가도 다시 확인합니다.
npm run build

현재 확장 단계

✓ 1~8. Store·selector·측정
→ 9. 리렌더링 오류 해결
○ 10. persist
○ 11~15. middleware·migration·실무 설계

완료 체크포인트

zustand-step-09-rerender-fix

다음 학습

다음 글에서는 새로고침 후에도 유지할 가치가 있는 상태만 persist middleware로 저장합니다.

다음 프로젝트 단계: Zustand persist 사용법: 새로고침 후 상태 저장하기

공식 문서

글 소개·학습 준비·전체 목차
Zustand: 화면 갱신 오류 해결 표지

Zustand · 9 / 15 · 오류 해결 · 리렌더링

Zustand state를 변경했는데 화면이 바뀌지 않는다면 대부분 다음 세 영역을 먼저 확인하면 됩니다.

  1. 실제로 새 state가 만들어졌는가
  2. 컴포넌트가 바뀐 값을 selector로 구독하고 있는가
  3. selector가 비교하기 어려운 참조를 반환하고 있지 않은가

먼저 한 작업만 가진 별도 store에서 잘못된 업데이트와 새 참조 업데이트를 비교합니다. 하단에서는 같은 수정 원칙을 TaskFlow에 적용합니다.

이번 글에서 배우는 내용

  • 같은 객체·배열 참조를 유지했을 때 문제
  • selector 범위 오류
  • 파생 배열 selector 문제
  • action 내부 mutation 진단
  • 리렌더링 문제를 찾는 순서

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기