LabHub
学习 学习路径 课程

状态管理 — 自己写一个库

两百行的 store

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

목표

Redux·Zustand·Jotai 가 공통으로 푸는 문제를 직접 풀어 봅니다. 파일 하나, 200줄이 안 됩니다.

만들 것

/root/work/state/store.mjs 에서 createStore 를 export 합니다.

export function createStore(initial) {
  return { getState, dispatch, subscribe, select }
}
함수 계약
getState() 현재 상태를 즉시 돌려준다
dispatch(fn) fn(현재) 의 반환값이 새 상태. 원본을 고치지 않는다
subscribe(l) 해제 함수를 반환한다
select(sel) sel(현재) 를 돌려주되, 상태가 그대로면 다시 계산하지 않는다

채점 방식

채점기가 여러분의 store.mjs 를 import 해서 계약을 하나씩 확인합니다. 브라우저도 프레임워크도 쓰지 않습니다.

node --version        # v22
node store.mjs        # 문법 확인용 (아무것도 출력 안 해도 됩니다)

단계

  1. getState · dispatch
  2. 제자리 수정 금지
  3. subscribe → 해제 함수
  4. 같은 참조면 알리지 않기
  5. select — 파생은 계산
  6. 메모이제이션
  7. 마이크로태스크 배치
  8. 순회 중 해제 안전성
  9. 정리 → 09-notes.md

참고

스토어의 뼈대

/root/work/state/store.mjs 에서 createStore(initial) 를 export 하세요. 돌려주는 객체에 getState()dispatch(fn) 이 있어야 하고, dispatchfn(현재상태)반환값을 새 상태로 삼습니다.

mkdir -p /root/work/state. ESM 이므로 export function createStore(initial) { ... }. 확장자가 .mjs 라 package.json 은 필요 없습니다. dispatch 안에서 상태를 제자리 수정하지 마세요 — 다음 단계가 그걸 봅니다.

제자리 수정을 막는다

dispatch 가 넘겨받은 상태 객체를 절대 고치지 않도록 하세요. 채점기가 dispatch 전후로 원본 객체를 비교합니다.

Object.freeze 로 스스로를 지키는 것도 좋은 방법입니다 — 실수로 고치면 조용히 무시되는 대신 오류가 납니다. 상태 갱신은 항상 새 객체({...s, n: s.n+1})로 하세요.

구독과 해제

subscribe(listener) 를 추가하고 해제 함수를 반환하세요. 해제한 뒤에는 다시 불리면 안 됩니다.

반환값이 함수여야 합니다. 해제는 몇 번 불러도 안전해야 합니다(두 번 부르면 다른 리스너가 지워지는 구현이 흔한 함정입니다).

안 바뀌었으면 알리지 않는다

dispatch(s => s) 처럼 같은 참조가 돌아오면 리스너를 부르지 않게 하세요.

if (next === prev) return 한 줄입니다. 이게 없으면 상태는 그대로인데 렌더가 돌고, 그 렌더가 다시 dispatch 하면 무한 루프가 됩니다.

파생 상태는 계산한다

select(selector) 를 추가하세요. store.select(s => s.items.length)항상 현재 상태 기준으로 값을 돌려줘야 합니다. 파생값을 상태에 저장하면 안 됩니다.

상태에 count 같은 필드를 넣어 두고 싶어지지만, 그러면 둘이 어긋나는 순간이 옵니다. 채점기가 items 만 바꾼 뒤 파생값이 따라오는지 봅니다.

같은 입력이면 다시 계산하지 않는다

select 에 메모이제이션을 넣으세요. 상태가 바뀌지 않았는데 같은 selector 로 여러 번 물으면 selector 함수가 한 번만 실행돼야 합니다.

마지막 상태 참조와 마지막 결과를 selector 별로 기억합니다(Map 이나 WeakMap). 여기서 불변성이 값어치를 합니다 — 상태가 불변이라 참조 비교 하나로 '안 바뀌었다'를 알 수 있습니다.

한 틱에 세 번 바꿔도 렌더는 한 번

연속된 dispatch 를 마이크로태스크 하나로 묶으세요. dispatch(a); dispatch(b); dispatch(c) 뒤 리스너는 정확히 한 번 불려야 하고, 그때 상태는 세 번 모두 반영된 최종값이어야 합니다.

queueMicrotaskscheduled 플래그 하나면 됩니다. React 18 의 automatic batching 이 이것입니다. 주의: getState() 는 배치와 무관하게 즉시 최신값을 줘야 합니다 — dispatch 자체를 미루면 안 됩니다.

순회 중에 해제해도 안전하게

리스너 안에서 구독을 해제해도 다른 리스너가 건너뛰어지지 않게 하세요.

[...listeners].forEach(...) — 복사본을 순회합니다. 한 줄이지만 이게 없으면 '가끔 하나가 안 불린다'는, 재현율 낮고 원인 찾기 어려운 버그가 됩니다.

라이브러리가 왜 그렇게 생겼는지

09-notes.md 에 세 줄 이상. 불변성이 무엇을 O(1) 로 만들었는지, 파생 상태를 저장하면 무엇이 어긋나는지, 배치가 없으면 무엇이 낭비되는지.

본문에 불변, 파생, 배치 가 들어가야 합니다. 이 셋이 Redux·Zustand·Vue 가 서로 다르게 생겼어도 똑같이 푸는 문제입니다.