mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
379 lines
8.9 KiB
Markdown
379 lines
8.9 KiB
Markdown
# State Management Patterns
|
|
|
|
Patterns for designing and using state management in TypeScript libraries.
|
|
|
|
## Contents
|
|
|
|
- [Mental Models](#mental-models) — Zustand, Jotai, XState, Valtio
|
|
- [Core Subscription Interface](#core-subscription-interface)
|
|
- [Middleware as Higher-Order Functions](#middleware-as-higher-order-functions)
|
|
- [Slice Pattern](#slice-pattern)
|
|
- [Atomic Composition](#atomic-composition-jotai-pattern)
|
|
- [Selector Pattern](#selector-pattern)
|
|
- [Split Stores When Truly Isolated](#split-stores-when-truly-isolated)
|
|
- [Cross-Store Access](#cross-store-access)
|
|
- [Derived State Patterns](#derived-state-patterns)
|
|
- [Optimistic Updates](#optimistic-updates)
|
|
- [State Initialization](#state-initialization)
|
|
- [Request State Pattern](#request-state-pattern)
|
|
- [Proxy-Based Reactivity](#proxy-based-reactivity-valtio)
|
|
- [Presets as Transparent Collections](#presets-as-transparent-collections)
|
|
- [Dev Tools Integration](#dev-tools-integration)
|
|
|
|
## Mental Models
|
|
|
|
Daishi Kato created Zustand, Jotai, and Valtio with intentionally different architectures because **different problems need different mental models**.
|
|
|
|
| Model | Mental Model | Best For |
|
|
| ---------------------- | ------------------------------------- | ------------------------------ |
|
|
| Top-down (Zustand) | Single store, slice into pieces | Module state, non-React access |
|
|
| Bottom-up (Jotai) | Composable atoms, build up | useState replacement |
|
|
| State machine (XState) | Explicit states and transitions | Complex workflows |
|
|
| Proxy (Valtio) | Mutable-looking, immutable underneath | Mutable-preferring devs |
|
|
|
|
**The principle:** Don't force a mental model. Choose based on how developers naturally think about the domain.
|
|
|
|
---
|
|
|
|
## Core Subscription Interface
|
|
|
|
The minimal interface any framework can consume:
|
|
|
|
```ts
|
|
interface Store<T> {
|
|
get(): T;
|
|
subscribe(callback: (value: T) => void): () => void;
|
|
}
|
|
```
|
|
|
|
This enables framework adapters to be ~10 lines using `useSyncExternalStore` (React), `shallowRef` (Vue), or `createSignal` (Solid).
|
|
|
|
---
|
|
|
|
## Middleware as Higher-Order Functions
|
|
|
|
Middleware wraps the state creator, not the store:
|
|
|
|
```ts
|
|
// Zustand pattern
|
|
create(
|
|
devtools(
|
|
persist(
|
|
immer((set) => ({ count: 0 })),
|
|
{ name: 'store' }
|
|
)
|
|
)
|
|
);
|
|
```
|
|
|
|
**Benefits:**
|
|
|
|
- Composable in any order
|
|
- Type-safe (each middleware can modify types)
|
|
- Tree-shakeable (unused middleware not bundled)
|
|
|
|
---
|
|
|
|
## Slice Pattern
|
|
|
|
Split state by concern, combine at creation time:
|
|
|
|
```ts
|
|
// Each module factory receives (set, get) and returns state slice
|
|
const createVolumeSlice = (set, get) => ({
|
|
volume: 1,
|
|
setVolume: (v) => set({ volume: v }),
|
|
});
|
|
|
|
const createPlaybackSlice = (set, get) => ({
|
|
playing: false,
|
|
play: () => set({ playing: true }),
|
|
});
|
|
|
|
// Combine by spreading at creation time
|
|
const useStore = create((...a) => ({
|
|
...createVolumeSlice(...a),
|
|
...createPlaybackSlice(...a),
|
|
}));
|
|
```
|
|
|
|
**Why creation-time composition:**
|
|
|
|
| Benefit | Why |
|
|
| --------------------------- | -------------------------------- |
|
|
| Cross-module access natural | `get`/`set` see entire store |
|
|
| Atomic updates span modules | One `set` updates multiple areas |
|
|
| Middleware applies to whole | No per-module confusion |
|
|
| Tree-shaking possible | Unused modules excluded |
|
|
|
|
---
|
|
|
|
## Atomic Composition (Jotai Pattern)
|
|
|
|
Build complex state from simple atoms:
|
|
|
|
```ts
|
|
const countAtom = atom(0);
|
|
const doubledAtom = atom((get) => get(countAtom) * 2);
|
|
const asyncAtom = atom(async (get) => fetch(`/api/${get(countAtom)}`));
|
|
```
|
|
|
|
**Benefits:**
|
|
|
|
- Fine-grained subscriptions
|
|
- Derived state is automatic
|
|
- Async handled uniformly
|
|
|
|
---
|
|
|
|
## Selector Pattern
|
|
|
|
Minimize re-renders with selectors:
|
|
|
|
```ts
|
|
// Subscribes to entire state (causes re-renders)
|
|
const state = useStore();
|
|
|
|
// Subscribes to selected slice (fine-grained)
|
|
const volume = useStore((state) => state.volume);
|
|
```
|
|
|
|
**Implementation considerations:**
|
|
|
|
- Shallow equality by default
|
|
- Custom equality function option
|
|
- Memoized selector support
|
|
|
|
---
|
|
|
|
## Split Stores When Truly Isolated
|
|
|
|
| Situation | Recommendation | Why |
|
|
| ----------------------------- | ------------------------- | -------------------------- |
|
|
| Totally isolated concerns | Multiple stores | Cleaner boundaries |
|
|
| Might ever want cross-updates | Single store with modules | Atomic updates possible |
|
|
| Multiple contexts needed | Single store | Simpler provider hierarchy |
|
|
|
|
**Heuristic:** "When you feel that something is getting difficult to maintain, that's the moment to start splitting."
|
|
|
|
---
|
|
|
|
## Cross-Store Access
|
|
|
|
When stores must communicate, make the relationship explicit:
|
|
|
|
```ts
|
|
interface AppContext {
|
|
userStore: Store<UserState>;
|
|
settingsStore: Store<SettingsState>;
|
|
}
|
|
|
|
function createFeatureStore(context: AppContext) {
|
|
return create((set, get) => ({
|
|
syncWithUser: () => {
|
|
const user = context.userStore.getState();
|
|
set({ userId: user.id });
|
|
},
|
|
}));
|
|
}
|
|
```
|
|
|
|
**Why explicit references:**
|
|
|
|
- Coordination goes through typed interface
|
|
- TypeScript catches mismatches
|
|
- No hidden global state
|
|
- Easy to test with mock stores
|
|
|
|
---
|
|
|
|
## Derived State Patterns
|
|
|
|
### Selector-Based
|
|
|
|
Compute derived state at subscription time:
|
|
|
|
```ts
|
|
store.subscribe(
|
|
(state) => state.items.reduce((sum, item) => sum + item.price, 0),
|
|
(totalPrice) => updateUI(totalPrice)
|
|
);
|
|
```
|
|
|
|
### Computed Atoms
|
|
|
|
Define derived state as a dependency graph:
|
|
|
|
```ts
|
|
const itemsAtom = atom([]);
|
|
const totalPriceAtom = atom((get) => get(itemsAtom).reduce((sum, item) => sum + item.price, 0));
|
|
```
|
|
|
|
| Pattern | Use When |
|
|
| ------------------ | ----------------------------------- |
|
|
| Selectors | Derived data varies by consumer |
|
|
| Computed atoms | Derived data shared across app |
|
|
| Memoized selectors | Expensive computation needs caching |
|
|
|
|
---
|
|
|
|
## Optimistic Updates
|
|
|
|
For responsive UI during async operations:
|
|
|
|
```ts
|
|
async function updateVolume(newVolume: number) {
|
|
const previous = store.getState().volume;
|
|
|
|
// Optimistic update
|
|
store.setState({ volume: newVolume });
|
|
|
|
try {
|
|
await api.setVolume(newVolume);
|
|
} catch {
|
|
// Rollback on failure
|
|
store.setState({ volume: previous });
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## State Initialization
|
|
|
|
### Lazy Initialization
|
|
|
|
Defer expensive work until needed:
|
|
|
|
```ts
|
|
createStore((set) => ({
|
|
data: null,
|
|
initialize: async () => {
|
|
const data = await fetchExpensiveData();
|
|
set({ data });
|
|
},
|
|
}));
|
|
```
|
|
|
|
### Hydration Support
|
|
|
|
Allow external state injection (SSR, persistence):
|
|
|
|
```ts
|
|
createStore((set) => ({
|
|
data: null,
|
|
hydrate: (serverState) => set(serverState),
|
|
}));
|
|
|
|
// Client: store.getState().hydrate(window.__INITIAL_STATE__)
|
|
```
|
|
|
|
### Reset Pattern
|
|
|
|
Return to known initial state:
|
|
|
|
```ts
|
|
const initialState = { count: 0, items: [] };
|
|
|
|
createStore((set) => ({
|
|
...initialState,
|
|
reset: () => set(initialState),
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## Request State Pattern
|
|
|
|
Standardized shape for async operations:
|
|
|
|
```ts
|
|
type RequestState<T, E = Error> =
|
|
| { status: 'idle' }
|
|
| { status: 'pending' }
|
|
| { status: 'success'; data: T; timestamp: number }
|
|
| { status: 'error'; error: E; timestamp: number };
|
|
```
|
|
|
|
Helper functions:
|
|
|
|
```ts
|
|
function isLoading(state: RequestState<unknown>): boolean;
|
|
function isSuccess<T>(state: RequestState<T>): state is SuccessState<T>;
|
|
function isError<E>(state: RequestState<unknown, E>): state is ErrorState<E>;
|
|
```
|
|
|
|
---
|
|
|
|
## Proxy-Based Reactivity (Valtio)
|
|
|
|
Mutate naturally, get automatic tracking:
|
|
|
|
```ts
|
|
const state = proxy({ count: 0, nested: { value: 1 } });
|
|
|
|
// Mutations work directly
|
|
state.count++;
|
|
state.nested.value = 2;
|
|
|
|
// Subscribe to changes
|
|
subscribe(state, () => console.log('changed'));
|
|
|
|
// React hook tracks accessed properties
|
|
const snap = useSnapshot(state);
|
|
```
|
|
|
|
**Trade-offs:**
|
|
|
|
- Simpler mental model
|
|
- Proxies can obscure types
|
|
- Deep reactivity automatic but sometimes unwanted
|
|
|
|
---
|
|
|
|
## Presets as Transparent Collections
|
|
|
|
Make presets visible and extensible:
|
|
|
|
```ts
|
|
// Good: Preset is just an array, visible and extensible
|
|
const websitePreset = [analyticsModule, cachingModule, loggingModule];
|
|
|
|
createStore({ modules: websitePreset });
|
|
createStore({ modules: [...websitePreset, customModule] });
|
|
|
|
// Bad: Preset hides internals
|
|
createStore({ preset: 'website' }); // What's in it?
|
|
```
|
|
|
|
---
|
|
|
|
## Dev Tools Integration
|
|
|
|
Patterns for debugging support:
|
|
|
|
```ts
|
|
// Named stores for devtools
|
|
const useStore = create(
|
|
devtools(
|
|
(set) => ({ ... }),
|
|
{ name: 'MediaStore' }
|
|
)
|
|
)
|
|
|
|
// Action names
|
|
set({ volume: 0.5 }, false, 'setVolume')
|
|
|
|
// Time-travel support via snapshots
|
|
const snapshot = store.getState()
|
|
store.setState(previousSnapshot)
|
|
```
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- [Principles](principles.md) — composition and extensibility
|
|
- [Extensibility](extensibility.md) — middleware patterns
|
|
- [Libraries](libraries.md) — reference implementations
|