context-scoped-state 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +388 -0
  2. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,388 @@
1
+ <div align="center">
2
+ <img src="./logo.svg" alt="context-scoped-state logo" width="120" height="120">
3
+ <h1>context-scoped-state</h1>
4
+ <p><strong>State management that respects component boundaries.</strong></p>
5
+ </div>
6
+
7
+ Unlike global state libraries (Redux, Zustand), `context-scoped-state` keeps your state where it belongs — scoped to the component tree that needs it. Each context provider creates an independent store instance, making your components truly reusable and your tests truly isolated.
8
+
9
+ ## Why Scoped State?
10
+
11
+ Global state is convenient, but it comes with hidden costs:
12
+
13
+ - **Testing nightmares** — State leaks between tests, requiring complex cleanup
14
+ - **Component coupling** — Reusing components means sharing their global state
15
+ - **Implicit dependencies** — Components magically depend on global singletons
16
+
17
+ `context-scoped-state` solves this by leveraging React's Context API the right way. Same API simplicity, but with proper encapsulation.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install context-scoped-state
23
+ ```
24
+
25
+ ```bash
26
+ yarn add context-scoped-state
27
+ ```
28
+
29
+ ```bash
30
+ pnpm add context-scoped-state
31
+ ```
32
+
33
+ > **Peer Dependencies:** React 18+
34
+
35
+ ## Try it Online
36
+
37
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/HarshRohila/context-scoped-state/tree/master/examples/playground)
38
+
39
+ ## Quick Start
40
+
41
+ ### 1. Create Your Store (one file, one export)
42
+
43
+ > Wondering why classes? See [API Design Choices](#api-design-choices).
44
+
45
+ ```tsx
46
+ // counterStore.ts
47
+ import { Store, createStoreHook } from 'context-scoped-state';
48
+
49
+ class CounterStore extends Store<{ count: number }> {
50
+ protected getInitialState() {
51
+ return { count: 0 };
52
+ }
53
+
54
+ increment() {
55
+ // Callback-based: receives current state, returns new state
56
+ this.setState((state) => ({ count: state.count + 1 }));
57
+ }
58
+
59
+ decrement() {
60
+ // Direct value: pass the new state directly
61
+ this.setState({ count: this.getState().count - 1 });
62
+ }
63
+ }
64
+
65
+ // This single export is all you need
66
+ export const useCounterStore = createStoreHook(CounterStore);
67
+ ```
68
+
69
+ ### 2. Use in Your App
70
+
71
+ ```tsx
72
+ import { useCounterStore } from './counterStore';
73
+
74
+ function Counter() {
75
+ const counterStore = useCounterStore();
76
+
77
+ return (
78
+ <div>
79
+ <span>{counterStore.state.count}</span>
80
+ <button onClick={() => counterStore.increment()}>+</button>
81
+ <button onClick={() => counterStore.decrement()}>-</button>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function App() {
87
+ return (
88
+ <useCounterStore.Context>
89
+ <Counter />
90
+ </useCounterStore.Context>
91
+ );
92
+ }
93
+ ```
94
+
95
+ That's it. One hook export gives you the hook and its `.Context` provider. No extra setup needed.
96
+
97
+ ### Partial State Updates with patchState
98
+
99
+ For stores with multiple properties, use `patchState` to update only specific fields:
100
+
101
+ ```tsx
102
+ class UserStore extends Store<{ name: string; age: number; email: string }> {
103
+ protected getInitialState() {
104
+ return { name: '', age: 0, email: '' };
105
+ }
106
+
107
+ updateName(name: string) {
108
+ // Only updates name, preserves age and email
109
+ this.patchState({ name });
110
+ }
111
+
112
+ incrementAge() {
113
+ // Callback-based: receives current state, returns partial update
114
+ this.patchState((state) => ({ age: state.age + 1 }));
115
+ }
116
+ }
117
+ ```
118
+
119
+ - `setState` — Replaces the entire state
120
+ - `patchState` — Merges partial updates into existing state
121
+
122
+ ## Examples
123
+
124
+ ### Independent Nested Stores
125
+
126
+ Each `Context` creates a completely independent store instance. Perfect for reusable widget patterns:
127
+
128
+ ```tsx
129
+ function PlayerScore() {
130
+ const store = useScoreStore();
131
+ return <span>Score: {store.state.score}</span>;
132
+ }
133
+
134
+ function Game() {
135
+ return (
136
+ <div>
137
+ {/* Player 1 has their own score */}
138
+ <useScoreStore.Context>
139
+ <h2>Player 1</h2>
140
+ <PlayerScore />
141
+ </useScoreStore.Context>
142
+
143
+ {/* Player 2 has their own score */}
144
+ <useScoreStore.Context>
145
+ <h2>Player 2</h2>
146
+ <PlayerScore />
147
+ </useScoreStore.Context>
148
+ </div>
149
+ );
150
+ }
151
+ ```
152
+
153
+ Both players have completely independent state — no configuration needed.
154
+
155
+ ### Testing with MockContext
156
+
157
+ Test components in any state without complex setup:
158
+
159
+ ```tsx
160
+ import { render, screen } from '@testing-library/react';
161
+
162
+ test('shows warning when balance is low', () => {
163
+ render(
164
+ <useAccountStore.MockContext state={{ balance: 5, currency: 'USD' }}>
165
+ <AccountStatus />
166
+ </useAccountStore.MockContext>,
167
+ );
168
+
169
+ expect(screen.getByText('Low balance warning')).toBeInTheDocument();
170
+ });
171
+
172
+ test('shows normal status when balance is healthy', () => {
173
+ render(
174
+ <useAccountStore.MockContext state={{ balance: 1000, currency: 'USD' }}>
175
+ <AccountStatus />
176
+ </useAccountStore.MockContext>,
177
+ );
178
+
179
+ expect(screen.queryByText('Low balance warning')).not.toBeInTheDocument();
180
+ });
181
+ ```
182
+
183
+ No mocking libraries. No global state cleanup. Just render with the state you need.
184
+
185
+ ### Dynamic Initial State with Context Value
186
+
187
+ Pass a `value` prop to Context to provide data for `getInitialState()`. This is useful when you need to initialize store state from React props:
188
+
189
+ ```tsx
190
+ type CounterState = { count: number };
191
+
192
+ class CounterStore extends Store<CounterState> {
193
+ protected getInitialState(contextValue?: Partial<CounterState>) {
194
+ return { count: contextValue?.count ?? 0 };
195
+ }
196
+
197
+ increment() {
198
+ this.setState((s) => ({ count: s.count + 1 }));
199
+ }
200
+ }
201
+
202
+ const useCounterStore = createStoreHook(CounterStore);
203
+
204
+ // Initialize store state from a React prop
205
+ function CounterWidget({ initialCount }: { initialCount: number }) {
206
+ return (
207
+ <useCounterStore.Context value={{ count: initialCount }}>
208
+ <Counter />
209
+ </useCounterStore.Context>
210
+ );
211
+ }
212
+
213
+ // Now you can render multiple widgets with different starting values
214
+ function App() {
215
+ return (
216
+ <>
217
+ <CounterWidget initialCount={0} />
218
+ <CounterWidget initialCount={100} />
219
+ </>
220
+ );
221
+ }
222
+ ```
223
+
224
+ ## Why context-scoped-state Over Other Libraries?
225
+
226
+ | Feature | context-scoped-state | Redux | Zustand |
227
+ | ---------------------- | -------------------- | ---------------------------- | -------------- |
228
+ | **Scoped by default** | Yes | No | No |
229
+ | **Multiple instances** | Automatic | Manual wiring | Manual wiring |
230
+ | **Test isolation** | Built-in MockContext | Requires setup | Requires reset |
231
+ | **Boilerplate** | Low | High | Low |
232
+ | **Type safety** | Full | Requires setup | Good |
233
+ | **Learning curve** | Just classes | Actions, reducers, selectors | Simple |
234
+
235
+ ### The Core Difference
236
+
237
+ **Global state libraries** make you fight against React's component model. You end up with:
238
+
239
+ - Selector functions to prevent re-renders
240
+ - Complex test fixtures to reset global state
241
+ - Workarounds for component reusability
242
+
243
+ **context-scoped-state** works _with_ React:
244
+
245
+ - State lives in the component tree, just like React intended
246
+ - Each provider = new instance, automatically
247
+ - Testing is just rendering with different props
248
+
249
+ ### When to Use What
250
+
251
+ **Use context-scoped-state when:**
252
+
253
+ - Building reusable components with internal state
254
+ - You want test isolation without extra setup
255
+ - State naturally belongs to a subtree, not the whole app
256
+
257
+ **Need global state?** Just place the Context at your app root — same API, app-wide access.
258
+
259
+ ### Why Not Just Use useState or useReducer?
260
+
261
+ **vs useState:**
262
+
263
+ - `useState` binds state directly to the component — poor separation of concerns and hard to test since you can't easily set a component to a specific state
264
+ - Lifting state up with `useState` requires refactoring components and passing props; with `context-scoped-state`, just move the Context wrapper up the tree
265
+
266
+ **vs useReducer:**
267
+
268
+ - No action types, switch statements, or dispatch boilerplate
269
+ - Just call methods directly: `store.increment()` instead of `dispatch({ type: 'INCREMENT' })`
270
+ - Full TypeScript autocomplete for your actions
271
+
272
+ ---
273
+
274
+ ## API Design Choices
275
+
276
+ These design decisions are intentional trade-offs that optimize for debuggability, clarity, and simplicity.
277
+
278
+ ### Why Classes for Stores?
279
+
280
+ Classes let us use `protected` on state-setting methods (`setState`, `patchState`). This means all state updates must go through the store class — components cannot directly modify state.
281
+
282
+ **Why this matters:** When debugging, you can set a single breakpoint in your store's action methods to see exactly who is changing state and when. No more hunting through components to find where state got mutated.
283
+
284
+ ```tsx
285
+ class CounterStore extends Store<{ count: number }> {
286
+ increment() {
287
+ // Set a breakpoint here to catch ALL count changes
288
+ this.setState((state) => ({ count: state.count + 1 }));
289
+ }
290
+ }
291
+ ```
292
+
293
+ ### Why Can't I Destructure Actions?
294
+
295
+ This won't work:
296
+
297
+ ```tsx
298
+ const { increment } = useCounterStore(); // ❌ Breaks 'this' binding
299
+ increment(); // Error: cannot read setState of undefined
300
+ ```
301
+
302
+ You must use:
303
+
304
+ ```tsx
305
+ const store = useCounterStore(); // ✅
306
+ store.increment();
307
+ ```
308
+
309
+ **This is a feature, not a bug.** The store is an external dependency — it should look like one. When you see `store.increment()`, it's clear you're calling a method on an external object. If you just saw `increment()`, it would look like a local function, hiding the fact that it's modifying external state.
310
+
311
+ ### Why `useStore.Context` Instead of Separate Exports?
312
+
313
+ Instead of:
314
+
315
+ ```tsx
316
+ // Two exports to manage
317
+ export const useCounterStore = createStoreHook(CounterStore);
318
+ export const CounterStoreContext = useCounterStore.Context;
319
+ ```
320
+
321
+ We have:
322
+
323
+ ```tsx
324
+ // One export does it all
325
+ export const useCounterStore = createStoreHook(CounterStore);
326
+
327
+ // Usage
328
+ <useCounterStore.Context>
329
+ <App />
330
+ </useCounterStore.Context>;
331
+ ```
332
+
333
+ **Simplicity:** One export per store file. The hook and its context travel together — you can't accidentally import one without having access to the other.
334
+
335
+ ### Context `value` vs MockContext `state`
336
+
337
+ Both `Context` and `MockContext` accept props, but they work differently:
338
+
339
+ ```tsx
340
+ // Context: value is passed TO getInitialState() for computation
341
+ <useCounterStore.Context value={{ count: 10 }}>
342
+
343
+ // MockContext: state REPLACES getInitialState() entirely
344
+ <useCounterStore.MockContext state={{ count: 10 }}>
345
+ ```
346
+
347
+ **Why the distinction?**
348
+
349
+ - `Context.value` — Provides input data for `getInitialState()` to use. Your `getInitialState()` method is still the single source of truth for how state is computed.
350
+ - `MockContext.state` — Bypasses `getInitialState()` completely and sets the state directly. This is only for tests where you need to put the store in a specific state.
351
+
352
+ **Debuggability:** In production code, `getInitialState()` is always called. You can set a breakpoint there to see exactly how initial state is computed. With `MockContext`, the state is set directly for test convenience.
353
+
354
+ ### Why `getInitialState()` Method Instead of a Property?
355
+
356
+ Instead of:
357
+
358
+ ```tsx
359
+ class CounterStore extends Store<{ count: number }> {
360
+ initialState = { count: 0 }; // Static value
361
+ }
362
+ ```
363
+
364
+ We use:
365
+
366
+ ```tsx
367
+ class CounterStore extends Store<{ count: number }> {
368
+ protected getInitialState() {
369
+ // Can include logic!
370
+ return { count: 0 };
371
+ }
372
+ }
373
+ ```
374
+
375
+ **Flexibility:** A method lets you compute initial state dynamically:
376
+
377
+ ```tsx
378
+ protected getInitialState() {
379
+ return {
380
+ count: parseInt(localStorage.getItem('count') ?? '0'),
381
+ timestamp: Date.now(),
382
+ };
383
+ }
384
+ ```
385
+
386
+ ---
387
+
388
+ **context-scoped-state** — Because not all state needs to be global.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-scoped-state",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {