zustand-ease
v0.1.0
Published
Eliminate four-state boilerplate in Zustand stores — register global defaults once, write only the success case per component.
Maintainers
Readme
zustand-ease
A strictly enforced, type-safe async-state architecture for React + Zustand — predictable lifecycle patterns with no escape hatches.
// Before: repetitive guard logic in every component
function UserPage() {
const { data, isLoading, error } = useUserStore();
if (isLoading) return <Spinner />;
if (error) return <ErrorView />;
if (!data) return null;
return <Profile user={data} />;
}
// After: pre-bind your store once and only handle the case that matters
const UserEaseStateBuilder = bindEaseStateBuilder(useUserStore, (s) => s.state);
function UserPage() {
return <UserEaseStateBuilder success={(user) => <Profile user={user} />} />;
}Design principles
1 — State scoping
Every store is scoped to a React subtree. State is only accessible from within the Provider tree that owns it — there is no global getState() escape hatch, no singleton reference, and no way to read or mutate state from outside the component hierarchy.
Each call to createScopedStore returns a { Provider, useStore } pair. Multiple Provider instances of the same store are fully independent, making it trivial to render the same UI with isolated state in different parts of the tree.
const { Provider: UserProvider, useStore: useUserStore } =
createScopedStore<UserStore>((set, get) => ({ ... }));
// Two independent User sections — no shared state, no conflicts
<UserProvider><UserCardA /></UserProvider>
<UserProvider><UserCardB /></UserProvider>2 — Explicit state modeling
Every async operation moves through exactly four states: initial, loading, success, failure. There are no boolean flags (isLoading, hasError, isSuccess) that can be simultaneously true and create impossible combinations. The type system enforces that each state carries only the fields that make sense for it.
type EaseState<T> =
| { _type: "initial" }
| { _type: "loading"; message?: string; progress?: number }
| { _type: "success"; data: T }
| { _type: "failure"; message?: string; error?: unknown; retry?: () => void };Impossible states like "loading and has data" or "success and has error" do not exist in the type.
3 — Immutability
State is produced by pure factory functions that return new plain objects — never mutated in place. EaseState<User> and EaseState<Order> are structurally incompatible via the _phantom brand field, so the TypeScript compiler catches accidental type mixing at build time.
// Rejected by TypeScript — good
const userState: EaseState<User> = success(order); // ✗ Type error4 — Minimal boilerplate
Register global fallback renderers for loading, failure, and initial once in EaseStateComponentProvider. Every EaseStateBuilder in the tree falls back to those defaults automatically — you only write the success case per component. Override locally by passing the corresponding prop to any individual builder.
// Register once
<EaseStateComponentProvider
loading={(msg) => <Spinner label={msg} />}
failure={(msg, _err, retry) => <ErrorView message={msg} onRetry={retry} />}
>
<App />
</EaseStateComponentProvider>
// Write only the success case everywhere else
const UserEaseStateBuilder = bindEaseStateBuilder(useUserStore, (s) => s.state);
<UserEaseStateBuilder success={(user) => <Profile user={user} />} />5 — Predictability
State transitions are driven entirely by the store factory — not by React component lifecycle or side-effectful hooks. The same input always produces the same rendered output. useEaseStateListener callbacks are stabilised via internal refs so they always read the latest closure values without stale-closure bugs.
EaseStateType constants replace magic strings, giving autocomplete and compile-time safety without TypeScript enum transpilation quirks:
EaseStateType.INITIAL; // 'initial'
EaseStateType.LOADING; // 'loading'
EaseStateType.SUCCESS; // 'success'
EaseStateType.FAILURE; // 'failure'6 — Performance optimization
usePreviousEaseState caches the most recent value of each state subtype independently using useLayoutEffect (synchronous, before paint). During a loading phase, the previous success data is available immediately — enabling skeleton UIs that show stale content instead of blank placeholders, with zero additional fetch cost.
const { exSuccessData } = usePreviousEaseState(useUserStore, (s) => s.state);
const UserEaseStateBuilder = bindEaseStateBuilder(useUserStore, (s) => s.state);
<UserEaseStateBuilder
loading={() => <Skeleton hint={exSuccessData?.name} />} // stale data, no flash
success={(user) => <Profile user={user} />}
/>;7 — Composability
EaseMultiStateBuilder and useMultiEaseStateListener operate on arrays of pre-extracted state values, letting you coordinate across multiple stores without coupling them to each other. Stores remain independent; coordination is expressed at the component layer.
const userState = useUserStore((s) => s.state);
const postsState = usePostsStore((s) => s.state);
// Renders once all stores succeed; shows shared loading/failure in the meantime
<EaseMultiStateBuilder
states={[userState, postsState]}
success={([user, posts]) => <Dashboard user={user.data} posts={posts.data} />}
/>;
// Fires once all stores reach a given state
useMultiEaseStateListener([userState, postsState], {
filterType: EaseStateType.SUCCESS,
onStateChange: () => analytics.track("dashboard_loaded"),
runOnlyOnce: true,
});8 — Encapsulated state updates
State can only be mutated from within the factory passed to createScopedStore. UI components call actions — they never set state directly. Retry callbacks close over get() from the factory, not over an external store reference, so there is no way for a component or external module to bypass the intended transition logic.
createScopedStore<UserStore>((set, get) => ({
state: initial(),
fetch: async () => {
if (get().state._type === "loading") return; // guard lives in the factory
set({ state: loading() });
try {
set({ state: success(await api.getUser()) });
} catch (e) {
// retry closes over get() — not an external reference
set({ state: failure("Failed", e, () => get().fetch()) });
}
},
}));Installation
npm install zustand-easePeer dependencies:
npm install zustand reactQuick start
1 — Create a scoped store
// stores/userStore.ts
import { createScopedStore } from "zustand-ease";
import {
type EaseState,
initial,
loading,
success,
failure,
} from "zustand-ease";
interface User {
id: string;
name: string;
email: string;
}
interface UserStore {
state: EaseState<User>;
fetch: () => Promise<void>;
reset: () => void;
}
export const { Provider: UserProvider, useStore: useUserStore } =
createScopedStore<UserStore>((set, get) => ({
state: initial(),
fetch: async () => {
if (get().state._type === "loading") return;
set({ state: loading("Fetching user…") });
try {
set({ state: success(await api.getUser()) });
} catch (e) {
set({ state: failure("Could not load user", e, () => get().fetch()) });
}
},
reset: () => set({ state: initial() }),
}));2 — Register global fallback renderers
// App.tsx
import { EaseStateComponentProvider } from "zustand-ease";
export default function App() {
return (
<EaseStateComponentProvider
loading={(msg) => <Spinner label={msg} />}
failure={(msg, _err, retry) => (
<ErrorView message={msg} onRetry={retry} />
)}
initial={() => null}
>
<Router />
</EaseStateComponentProvider>
);
}Nest a second EaseStateComponentProvider anywhere to override defaults for a subtree only. The nearest provider wins.
3 — Provide and consume the store
// pages/UserPage.tsx
import { bindEaseStateBuilder } from "zustand-ease";
import { UserProvider, useUserStore } from "../stores/userStore";
const UserEaseStateBuilder = bindEaseStateBuilder(useUserStore, (s) => s.state);
function UserContent() {
const fetch = useUserStore((s) => s.fetch);
const reset = useUserStore((s) => s.reset);
useEffect(() => {
fetch();
}, []);
return (
<>
<UserEaseStateBuilder success={(user) => <Profile user={user} />} />
<button onClick={fetch}>Reload</button>
<button onClick={reset}>Reset</button>
</>
);
}
export function UserPage() {
return (
<UserProvider>
<UserContent />
</UserProvider>
);
}API
createScopedStore
Creates a scoped Zustand store. Returns { Provider, useStore }.
const { Provider, useStore } = createScopedStore<S>((set, get, api) => S);Provider— mounts a new isolated store instance for its subtree. EachProvidermount is fully independent.useStore— a selector hook available only insideProvider. Throws with a clear message if called outside.
// Actions and state accessed the same way
function RefreshButton() {
const fetch = useUserStore((s) => s.fetch);
return <button onClick={fetch}>Refresh</button>;
}
// Throws — cannot access state outside Provider
function OutsideComponent() {
const state = useUserStore((s) => s.state); // ✗ runtime error
}The factory receives set, get, and api — identical to Zustand's create. Use get() inside async actions to read current state and inside retry callbacks to avoid stale external references.
EaseState<T> — type and factories
import { initial, loading, success, failure } from "zustand-ease";
initial(); // { _type: 'initial' }
loading("Fetching…", 0.5); // { _type: 'loading', message, progress }
success(data); // { _type: 'success', data }
failure("Oops", err, retryFn); // { _type: 'failure', message, error, retry }EaseStateType — discriminant constants
import { EaseStateType } from "zustand-ease";
if (state._type === EaseStateType.SUCCESS) console.log(state.data);
if (state._type === EaseStateType.FAILURE) state.retry?.();Values are plain string literals (as const) — fully tree-shakable, no enum transpilation.
Type guards
import { isInitialState, isLoadingState, isSuccessState, isFailureState } from "zustand-ease";
if (isSuccessState(state)) console.log(state.data); // narrowed to SuccessState<T>
if (isFailureState(state)) state.retry?.();<EaseStateComponentProvider>
<EaseStateComponentProvider
loading={(message, progress) => ReactNode}
failure={(message, error, retry) => ReactNode}
initial={() => ReactNode}
>
{children}
</EaseStateComponentProvider>All props optional. Nested providers override the outer one for their subtree.
bindEaseStateBuilder (Preferred)
Creating a bound component is the preferred way to consume the store slice, reducing boilerplate and preventing repetitively passing store and select props everywhere.
import { bindEaseStateBuilder } from "zustand-ease";
export const UserEaseStateBuilder = bindEaseStateBuilder(
useUserStore,
(s) => s.state
);<EaseStateBuilder>
Renders a single store slice. If you don't use bindEaseStateBuilder, you must pass store and select directly. Only success is required.
| Prop | Type | Required |
| --------- | ----------------------------------------- | -------- |
| store | EaseStoreSelector<S> | ✓ |
| select | (state: S) => EaseState<T> | ✓ |
| success | (data: T) => ReactNode | ✓ |
| loading | (message?, progress?) => ReactNode | — |
| failure | (message?, error?, retry?) => ReactNode | — |
| initial | () => ReactNode | — |
<UserEaseStateBuilder
success={(user) => <Profile user={user} />}
loading={() => <SkeletonCard />} {/* overrides the global spinner here only */}
failure={(msg, _err, retry) => ( {/* overrides the global error card here only */}
<InlineError message={msg} onRetry={retry} />
)}
/><EaseMultiStateBuilder>
Waits for all states to succeed before rendering the success UI.
Priority order: failure → initial → loading → all-success.
Pre-extract state values before passing — React's Rules of Hooks prohibit calling store hooks inside a loop.
const userState = useUserStore((s) => s.state);
const postsState = usePostsStore((s) => s.state);
<EaseMultiStateBuilder
states={[userState, postsState]}
success={([userSt, postsSt]) => (
<Dashboard user={userSt.data} posts={postsSt.data} />
)}
/>;| Prop | Type | Required |
| --------- | ------------------------------------------------ | -------- |
| states | EaseState<unknown>[] | ✓ |
| success | (states: SuccessState<unknown>[]) => ReactNode | ✓ |
| loading | (message?, progress?) => ReactNode | — |
| failure | (message?, error?, retry?) => ReactNode | — |
| initial | () => ReactNode | — |
<RawStateBuilder>
Passes the raw EaseState<T> to a single render function on every state change. Use this when one component handles all states internally — no switching logic, no context fallbacks.
| Prop | Type | Required |
| ---------- | ------------------------------ | -------- |
| store | EaseStoreSelector<S> | ✓ |
| select | (state: S) => EaseState<T> | ✓ |
| children | (state: EaseState<T>) => ReactNode | ✓ |
<RawStateBuilder store={useUserStore} select={(s) => s.state}>
{(state) => <UserCard state={state} />}
</RawStateBuilder>The child component receives the full EaseState<T> union and is responsible for all rendering decisions.
useEaseStateListener
Fires typed callbacks on state transitions. Listener refs are stabilised internally — inline arrow functions are safe, no useCallback needed.
useEaseStateListener(useUserStore, (s) => s.state, {
onSuccess: (user) => analytics.identify(user.id),
onFailure: (msg) => toast.error(msg ?? "Something went wrong"),
runOnMount: true,
});| Option | Type | Default |
| ------------ | ----------------------------------------------------- | ------- |
| onInitial | () => void | — |
| onLoading | (message?, progress?) => void | — |
| onSuccess | (data: T) => void | — |
| onFailure | (message?, error?, retry?) => void | — |
| runOnMount | boolean | false |
| listenWhen | (prev: EaseState<T>, next: EaseState<T>) => boolean | — |
runOnMount fires the appropriate callback against the current state immediately on mount, before any transition occurs.
listenWhen is a predicate that receives the previous and next state. Return true to allow the callback, false to suppress it:
useEaseStateListener(useUserStore, (s) => s.state, {
// Only fire when transitioning INTO success from loading
listenWhen: (prev, next) =>
prev._type === EaseStateType.LOADING &&
next._type === EaseStateType.SUCCESS,
onSuccess: () => toast.success("Data loaded"),
});useMultiEaseStateListener
Fires onStateChange only when all pre-extracted states match filterType.
const userState = useUserStore((s) => s.state);
const postsState = usePostsStore((s) => s.state);
useMultiEaseStateListener([userState, postsState], {
filterType: EaseStateType.SUCCESS,
onStateChange: () => analytics.track("dashboard_loaded"),
runOnlyOnce: true,
});| Option | Type | Required | Default |
| --------------- | ---------------------------------------- | -------- | ------- |
| filterType | EaseStateType | ✓ | — |
| onStateChange | (states: EaseState<unknown>[]) => void | ✓ | — |
| runOnMount | boolean | — | false |
| runOnlyOnce | boolean | — | false |
runOnlyOnce prevents the callback from firing more than once across the component's lifetime, useful for one-time analytics events on first full page load.
usePreviousEaseState
Caches the most recent value of each state subtype independently. Each slot holds the last time the store was in that state — not the current one.
const { exInitial, exLoading, exSuccess, exFailure, exSuccessData } =
usePreviousEaseState(useUserStore, (s) => s.state);| Field | Type | Description |
| --------------- | ------------------------------ | ------------------------------- |
| exInitial | InitialState<T> \| undefined | Last seen initial state |
| exLoading | LoadingState<T> \| undefined | Last seen loading state |
| exSuccess | SuccessState<T> \| undefined | Last seen success state |
| exFailure | FailureState<T> \| undefined | Last seen failure state |
| exSuccessData | T \| undefined | Shorthand for exSuccess?.data |
Slots are updated via useLayoutEffect (synchronous, before paint). During the render where a new loading arrives, exSuccess still holds the previous success value — the update happens after the render, before the next paint.
Stale-data skeleton pattern:
const { exSuccessData } = usePreviousEaseState(useUserStore, (s) => s.state);
const UserEaseStateBuilder = bindEaseStateBuilder(useUserStore, (s) => s.state);
<UserEaseStateBuilder
// Show stale name in the skeleton while reloading — no blank flash
loading={() => <Skeleton hint={exSuccessData?.name} />}
success={(user) => <Profile user={user} />}
/>;Combining with useEaseStateListener — act only when data changes:
Compare the current success against the previous one to decide whether a dependent fetch is necessary. The trick is that exSuccess read during render is the previous success — capture it in a ref before effects run so the listener sees the render-time value, not the post-layout-effect value.
function ProfilePage() {
const { exSuccess } = usePreviousEaseState(
useTeamMemberStore,
(s) => s.state,
);
const fetchActivity = useActivityStore((s) => s.fetchFor);
const prevSuccessRef = useRef(exSuccess);
prevSuccessRef.current = exSuccess; // always the render-time value
useEaseStateListener(useTeamMemberStore, (s) => s.state, {
onSuccess: (member) => {
const prev = prevSuccessRef.current?.data;
if (prev?.id !== member.id) {
fetchActivity(member.id); // different member — fetch
}
// same member reloaded — skip
},
});
}Why it works:
success(A)→loading→success(B): during thesuccess(B)render,exSuccessis stillA(slot not yet updated).prevSuccessRef.currentcapturesA. Listener fires →prev.id !== member.id→ fetches for B. ✓success(A)→loading→success(A)(same item, reload):exSuccessduring render is still the previousA.prev.id === member.id→ skips the fetch. ✓
TypeScript
EaseStoreSelector<S> — the type of useStore returned by createScopedStore. All hooks and components accept this type. UseBoundStore from plain create() is structurally incompatible (it lacks the _easeScoped phantom brand), so passing a global store to EaseStateBuilder is a compile-time error.
_phantom brand field — never assigned at runtime. Exists solely so EaseState<User> and EaseState<Order> are structurally incompatible despite having the same shape.
EaseStateType as as const object — values are plain string literals. No enum transpilation, fully tree-shakable.
License
MIT
