gw-store
v0.2.0
Published
A small immutable React store with selectors and typed sync or async actions
Maintainers
Readme
GW Store
React 컴포넌트 수명에 맞춰 생성되는 작은 불변 외부 스토어입니다. 자유롭게 상태를
변경하는 useStore와, 동기·비동기 함수를 미리 등록하는 useActionStore를 함께
제공합니다. 별도의 middleware 없이 일반 async 함수와 await를 사용할 수 있습니다.
설치
npm install gw-storeReact 18 이상이 필요합니다. react-dom에는 의존하지 않으므로 React Native에서도
사용할 수 있습니다.
기본 사용법
import { shallowEqual, useSelector, useStore, type Store } from "gw-store";
type CounterState = {
count: number;
profile: {
name: string;
};
};
function Count({ store }: { store: Store<CounterState> }) {
// 인라인 selector를 사용해도 안전합니다.
const count = useSelector(store, (state) => state.count);
return <strong>{count}</strong>;
}
export function Counter() {
const store = useStore<CounterState>({
count: 0,
profile: { name: "GW" },
});
return (
<div>
<Count store={store} />
<button onClick={() => store.dispatch({ count: store.state.count + 1 })}>
Increase
</button>
<button
onClick={() =>
store.dispatch((draft) => {
draft.profile.name = "Store";
})
}
>
Rename
</button>
</div>
);
}useStore(initialState)는 최초 렌더에서 스토어를 한 번만 만들며 이후 같은 객체를
반환합니다. initialState가 바뀌어도 스토어를 재생성하거나 초기화하지 않습니다.
Selector와 동등성 비교
useSelector는 useSyncExternalStore와 선택 결과 캐시를 함께 사용합니다. selector가
렌더마다 새 함수로 만들어지더라도, 동일한 스토어 상태를 읽는 동안에는 캐시된 결과를
반환하므로 getSnapshot의 정체성이 흔들려 발생하는 무한 렌더를 피합니다.
문자열이나 숫자처럼 안정적인 값을 선택할 때는 기본 Object.is 비교면 충분합니다.
const name = useSelector(store, (state) => state.profile.name);객체나 배열을 새로 만들어 반환한다면 비교 함수를 전달해야 관련 없는 상태 변경에서 렌더를 건너뛸 수 있습니다.
const profile = useSelector(
store,
(state) => ({ name: state.profile.name }),
shallowEqual,
);selector가 props를 캡처하거나 렌더 사이에 바뀌는 경우도 지원합니다.
Dispatch 규칙
얕은 patch는 patch 자체가 가진 enumerable property만 복사합니다.
store.dispatch({ count: 2 });중첩 값을 변경할 때는 Immer recipe를 사용합니다. draft 타입은 Draft<TState>로
추론되므로 readonly 상태 타입도 recipe 안에서는 안전하게 변경할 수 있습니다.
store.dispatch((draft) => {
draft.profile.name = "New name";
});초기 상태와 이후 스냅샷은 깊게 동결됩니다. recipe가 상태를 바꾸지 않으면
subscriber에게 알리지 않으며 store.state는 읽기 전용입니다.
선택적으로 알림 key를 전달할 수 있으며 직접 등록한 subscriber에서 받을 수 있습니다.
const unsubscribe = store.subscribe((state, key) => {
console.log(key, state);
});
store.dispatch({ count: 3 }, { key: "counter" });
unsubscribe();여러 동기 업데이트를 한 번만 알리려면 batch를 사용합니다.
store.batch(
() => {
store.dispatch({ count: 1 });
store.dispatch((draft) => {
draft.profile.name = "Batched";
});
},
{ key: "counter:update" },
);subscriber가 알림 중 다시 dispatch해도 새 알림은 현재 알림이 끝난 뒤 순서대로 처리됩니다.
등록형 Action Store
업데이트 함수를 한곳에 모으고 싶다면 useActionStore를 사용합니다. 호출부에서는
Redux action object나 middleware 없이 타입이 보존된 함수를 직접 호출합니다.
import { useActionStore, useSelector } from "gw-store";
type UserState = {
count: number;
status: "idle" | "loading" | "success" | "error";
user: { id: string; name: string } | null;
error: string | null;
};
function UserPanel() {
const initialState: UserState = {
count: 0,
status: "idle",
user: null,
error: null,
};
const store = useActionStore(
initialState,
{
increment({ set }, amount: number = 1) {
set((draft) => {
draft.count += amount;
});
},
async loadUser({ set, getState }, id: string, signal?: AbortSignal) {
set({ status: "loading", error: null });
try {
const response = await fetch(`/api/users/${id}`, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
set({ user, status: "success" });
return getState().user;
} catch (error) {
set({
status: "error",
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
},
},
);
const status = useSelector(store, (state) => state.status);
return (
<>
<button onClick={() => store.actions.increment(2)}>+2</button>
<button onClick={() => void store.actions.loadUser("42")}>Load</button>
<span>{status}</span>
</>
);
}각 action은 { set, getState, batch }를 받습니다.
set: 얕은 patch 또는 Immer recipe를 적용합니다.getState():await이후에도 항상 최신 스냅샷을 반환합니다.batch: 여러 동기 업데이트를 한 번만 알립니다.- action의 인자와 반환값은
store.actions에 그대로 추론됩니다. - 직접 등록한 subscriber의 알림 key에는 기본적으로 action 이름이 전달됩니다.
- 오류는 일반 함수처럼 throw/reject되므로 호출자가
try/catch할 수 있습니다. - 취소가 필요하면 별도 라이브러리 대신 표준
AbortSignal을 action 인자로 전달합니다.
batch는 동기 구간만 묶습니다. await 이후 여러 업데이트를 하나로 알리고 싶다면
그 업데이트들을 별도의 batch(() => { ... }) 호출 안에 넣습니다.
action 정의는 첫 렌더에서 한 번 등록됩니다. 렌더마다 변하는 props나 값은 action 클로저에 캡처하지 말고 action 인자로 전달해야 합니다.
React Server Components
패키지는 React Hook만 제공하므로 배포 파일에 "use client" 경계를 포함합니다.
Next.js App Router에서는 이 패키지를 사용하는 컴포넌트가 Client Component로
처리됩니다.
개발
npm run check
npm run test:package
npm run build
npm pack --dry-run패키지는 CommonJS와 ESM을 모두 제공하며 공개 진입점은 gw-store 하나입니다.
릴리스
릴리스 버전의 변경 사항을 CHANGELOG.md에 기록하고 다음 검증을 통과해야 합니다.
npm run release:check
npm publish --dry-run배포는 새 버전을 커밋한 뒤 main 브랜치에 push하면 자동으로 시작됩니다.
이미 npm에 게시된 버전이면 배포 단계는 안전하게 건너뜁니다.
git add .
git commit -m "Release v0.2.0"
git push origin mainGitHub Actions는 lockfile로 의존성을 설치하고 prepublishOnly 검증과 prepack
빌드를 거쳐 public 패키지와 provenance를 npm에 게시합니다. npm 패키지 설정에서
GitHub 저장소 oh-jinsu/gw-store와 workflow publish.yaml을 Trusted Publisher로
등록한 뒤에는 장기 NPM_PUBLISH_TOKEN secret을 제거할 수 있습니다.
