@uistate/core 4.1.2 → 5.0.0
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.
- package/LICENSE-eventTest.md +26 -0
- package/README.md +409 -42
- package/cssState.js +32 -1
- package/eventState.js +58 -49
- package/eventTest.js +196 -0
- package/examples/001-counter/README.md +44 -0
- package/examples/001-counter/eventState.js +86 -0
- package/examples/001-counter/index.html +30 -0
- package/examples/002-counter-improved/README.md +44 -0
- package/examples/002-counter-improved/eventState.js +86 -0
- package/examples/002-counter-improved/index.html +44 -0
- package/examples/003-input-reactive/README.md +44 -0
- package/examples/003-input-reactive/eventState.js +86 -0
- package/examples/003-input-reactive/index.html +30 -0
- package/examples/004-computed-state/README.md +45 -0
- package/examples/004-computed-state/eventState.js +86 -0
- package/examples/004-computed-state/index.html +62 -0
- package/examples/005-conditional-rendering/README.md +42 -0
- package/examples/005-conditional-rendering/eventState.js +86 -0
- package/examples/005-conditional-rendering/index.html +36 -0
- package/examples/006-list-rendering/README.md +49 -0
- package/examples/006-list-rendering/eventState.js +86 -0
- package/examples/006-list-rendering/index.html +60 -0
- package/examples/007-form-validation/README.md +52 -0
- package/examples/007-form-validation/eventState.js +86 -0
- package/examples/007-form-validation/index.html +99 -0
- package/examples/008-undo-redo/README.md +70 -0
- package/examples/008-undo-redo/eventState.js +86 -0
- package/examples/008-undo-redo/index.html +105 -0
- package/examples/009-localStorage-side-effects/README.md +72 -0
- package/examples/009-localStorage-side-effects/eventState.js +86 -0
- package/examples/009-localStorage-side-effects/index.html +54 -0
- package/examples/010-decoupled-components/README.md +74 -0
- package/examples/010-decoupled-components/eventState.js +86 -0
- package/examples/010-decoupled-components/index.html +90 -0
- package/examples/011-async-patterns/README.md +98 -0
- package/examples/011-async-patterns/eventState.js +86 -0
- package/examples/011-async-patterns/index.html +129 -0
- package/examples/028-counter-improved-eventTest/LICENSE +55 -0
- package/examples/028-counter-improved-eventTest/README.md +131 -0
- package/examples/028-counter-improved-eventTest/app/store.js +9 -0
- package/examples/028-counter-improved-eventTest/index.html +49 -0
- package/examples/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
- package/examples/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
- package/examples/028-counter-improved-eventTest/runtime/core/router.js +271 -0
- package/examples/028-counter-improved-eventTest/store.d.ts +8 -0
- package/examples/028-counter-improved-eventTest/style.css +170 -0
- package/examples/028-counter-improved-eventTest/tests/README.md +208 -0
- package/examples/028-counter-improved-eventTest/tests/counter.test.js +116 -0
- package/examples/028-counter-improved-eventTest/tests/eventTest.js +176 -0
- package/examples/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
- package/examples/028-counter-improved-eventTest/tests/run.js +20 -0
- package/examples/030-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/030-todo-app-with-eventTest/README.md +121 -0
- package/examples/030-todo-app-with-eventTest/app/router.js +25 -0
- package/examples/030-todo-app-with-eventTest/app/store.js +16 -0
- package/examples/030-todo-app-with-eventTest/app/views/home.js +11 -0
- package/examples/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
- package/examples/030-todo-app-with-eventTest/index.html +65 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/030-todo-app-with-eventTest/store.d.ts +18 -0
- package/examples/030-todo-app-with-eventTest/style.css +170 -0
- package/examples/030-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
- package/examples/030-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
- package/examples/031-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/031-todo-app-with-eventTest/README.md +54 -0
- package/examples/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/examples/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/examples/031-todo-app-with-eventTest/app/bridges.js +113 -0
- package/examples/031-todo-app-with-eventTest/app/router.js +26 -0
- package/examples/031-todo-app-with-eventTest/app/store.js +15 -0
- package/examples/031-todo-app-with-eventTest/app/views/home.js +46 -0
- package/examples/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/examples/031-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/examples/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/examples/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/examples/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/examples/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/examples/031-todo-app-with-eventTest/index.html +103 -0
- package/examples/031-todo-app-with-eventTest/package-lock.json +2184 -0
- package/examples/031-todo-app-with-eventTest/package.json +24 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/examples/031-todo-app-with-eventTest/store.d.ts +23 -0
- package/examples/031-todo-app-with-eventTest/style.css +170 -0
- package/examples/031-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/examples/031-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/examples/032-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/032-todo-app-with-eventTest/README.md +54 -0
- package/examples/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/examples/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/examples/032-todo-app-with-eventTest/app/actions/index.js +153 -0
- package/examples/032-todo-app-with-eventTest/app/bridges.js +113 -0
- package/examples/032-todo-app-with-eventTest/app/router.js +26 -0
- package/examples/032-todo-app-with-eventTest/app/store.js +15 -0
- package/examples/032-todo-app-with-eventTest/app/views/home.js +46 -0
- package/examples/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/examples/032-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/examples/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/examples/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/examples/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/examples/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/examples/032-todo-app-with-eventTest/index.html +87 -0
- package/examples/032-todo-app-with-eventTest/package-lock.json +2184 -0
- package/examples/032-todo-app-with-eventTest/package.json +24 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/examples/032-todo-app-with-eventTest/store.d.ts +23 -0
- package/examples/032-todo-app-with-eventTest/style.css +170 -0
- package/examples/032-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/examples/032-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/index.js +14 -3
- package/package.json +16 -7
- package/stateSerializer.js +99 -4
- package/templateManager.js +50 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const createEventState = (initial = {}) => {
|
|
2
|
+
const store = JSON.parse(JSON.stringify(initial));
|
|
3
|
+
|
|
4
|
+
const bus = new EventTarget();
|
|
5
|
+
let destroyed = false;
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
get: (path) => {
|
|
9
|
+
if (destroyed) throw new Error('Cannot get from destroyed store');
|
|
10
|
+
if (!path) return store;
|
|
11
|
+
|
|
12
|
+
// Parse dot-paths: 'items.0' → ['items', '0']
|
|
13
|
+
const parts = path.split('.').flatMap(part => {
|
|
14
|
+
const match = part.match(/([^\[]+)\[(\d+)\]/);
|
|
15
|
+
return match ? [match[1], match[2]] : part;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return parts.reduce(
|
|
19
|
+
(obj, prop) => (obj && obj[prop] !== undefined ? obj[prop] : undefined),
|
|
20
|
+
store
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
set: (path, value) => {
|
|
24
|
+
if (destroyed) throw new Error('Cannot set on destroyed store');
|
|
25
|
+
if (!path) return;
|
|
26
|
+
const parts = path.split('.');
|
|
27
|
+
const last = parts.pop();
|
|
28
|
+
let target = store;
|
|
29
|
+
|
|
30
|
+
for (const key of parts) {
|
|
31
|
+
// create intermediate objects as needed
|
|
32
|
+
if (typeof target[key] !== 'object' || target[key] === null) {
|
|
33
|
+
target[key] = {};
|
|
34
|
+
}
|
|
35
|
+
target = target[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
target[last] = value;
|
|
39
|
+
|
|
40
|
+
if (!destroyed) {
|
|
41
|
+
// exact path
|
|
42
|
+
bus.dispatchEvent(new CustomEvent(path, { detail: value }));
|
|
43
|
+
|
|
44
|
+
// parent wildcards: a, a.b -> 'a.*', 'a.b.*'
|
|
45
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
46
|
+
const parent = parts.slice(0, i).join('.');
|
|
47
|
+
bus.dispatchEvent(new CustomEvent(`${parent}.*`, { detail: { path, value } }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// root wildcard
|
|
51
|
+
bus.dispatchEvent(new CustomEvent('*', { detail: { path, value } }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value;
|
|
55
|
+
},
|
|
56
|
+
subscribe(path, handler) {
|
|
57
|
+
if (destroyed) throw new Error('store destroyed');
|
|
58
|
+
if (!path || typeof handler !== 'function') {
|
|
59
|
+
throw new TypeError('subscribe(path, handler) requires a string path and function handler');
|
|
60
|
+
}
|
|
61
|
+
const onEvent = (evt) => handler(evt.detail, evt);
|
|
62
|
+
bus.addEventListener(path, onEvent);
|
|
63
|
+
|
|
64
|
+
return function unsubscribe() {
|
|
65
|
+
bus.removeEventListener(path, onEvent);
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
off(unsubscribe) {
|
|
69
|
+
if (typeof unsubscribe !== 'function') {
|
|
70
|
+
throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
|
|
71
|
+
}
|
|
72
|
+
return unsubscribe();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
destroy() {
|
|
76
|
+
if (!destroyed) {
|
|
77
|
+
destroyed = true;
|
|
78
|
+
// EventTarget has no parentNode - just mark as destroyed
|
|
79
|
+
// Future sets/subscribes will be blocked by destroyed flag
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default createEventState;
|
|
86
|
+
export { createEventState };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>009 localStorage Side Effects - EventState</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>Persistent Counter</h1>
|
|
10
|
+
|
|
11
|
+
<h2>Count: <span id="count">0</span></h2>
|
|
12
|
+
|
|
13
|
+
<button id="increment">+1</button>
|
|
14
|
+
<button id="decrement">-1</button>
|
|
15
|
+
<button id="reset">Reset</button>
|
|
16
|
+
|
|
17
|
+
<p><small>Reload the page - your count persists!</small></p>
|
|
18
|
+
|
|
19
|
+
<script type="module">
|
|
20
|
+
import { createEventState } from './eventState.js';
|
|
21
|
+
|
|
22
|
+
const STORAGE_KEY = 'eventstate-counter';
|
|
23
|
+
|
|
24
|
+
// Load initial state from localStorage
|
|
25
|
+
const savedCount = localStorage.getItem(STORAGE_KEY);
|
|
26
|
+
const initialCount = savedCount !== null ? parseInt(savedCount, 10) : 0;
|
|
27
|
+
|
|
28
|
+
// Create store with saved state
|
|
29
|
+
const store = createEventState({ count: initialCount });
|
|
30
|
+
|
|
31
|
+
// Side effect: save to localStorage on every change
|
|
32
|
+
store.subscribe('count', (value) => {
|
|
33
|
+
localStorage.setItem(STORAGE_KEY, value);
|
|
34
|
+
document.getElementById('count').textContent = value;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Operations
|
|
38
|
+
document.getElementById('increment').onclick = () => {
|
|
39
|
+
store.set('count', store.get('count') + 1);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
document.getElementById('decrement').onclick = () => {
|
|
43
|
+
store.set('count', store.get('count') - 1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
document.getElementById('reset').onclick = () => {
|
|
47
|
+
store.set('count', 0);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Initial render
|
|
51
|
+
document.getElementById('count').textContent = initialCount;
|
|
52
|
+
</script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 010 Decoupled Components - Shared State
|
|
2
|
+
|
|
3
|
+
Demonstrates how components can communicate through shared state without direct coupling.
|
|
4
|
+
|
|
5
|
+
## What's Here
|
|
6
|
+
|
|
7
|
+
- **Component A (Writer):** Sends messages
|
|
8
|
+
- **Component B (Reader):** Displays current message
|
|
9
|
+
- **Component C (Logger):** Logs all messages with timestamps
|
|
10
|
+
- **Zero coupling:** Components don't know about each other
|
|
11
|
+
|
|
12
|
+
## How It Works
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
// Shared store - the ONLY connection
|
|
16
|
+
const store = createEventState({ message: '' });
|
|
17
|
+
|
|
18
|
+
// Component A: Writes to state
|
|
19
|
+
document.getElementById('sendBtn').onclick = () => {
|
|
20
|
+
store.set('message', inputValue);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Component B: Reads from state
|
|
24
|
+
store.subscribe('message', (value) => {
|
|
25
|
+
document.getElementById('display').textContent = value;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Component C: Also reads from state
|
|
29
|
+
store.subscribe('message', (value) => {
|
|
30
|
+
logMessage(value);
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Key Insight
|
|
35
|
+
|
|
36
|
+
**State is the interface between components.**
|
|
37
|
+
|
|
38
|
+
Components communicate by:
|
|
39
|
+
1. Writing to shared state
|
|
40
|
+
2. Subscribing to shared state
|
|
41
|
+
3. **Never** calling each other directly
|
|
42
|
+
|
|
43
|
+
This is the **pub/sub pattern** in action:
|
|
44
|
+
- ✅ Components are decoupled
|
|
45
|
+
- ✅ Easy to add/remove components
|
|
46
|
+
- ✅ No prop drilling
|
|
47
|
+
- ✅ No callbacks passed down
|
|
48
|
+
|
|
49
|
+
Other frameworks need:
|
|
50
|
+
- React: Context API, prop drilling, or state management libraries
|
|
51
|
+
- Vue: Provide/inject or Vuex
|
|
52
|
+
- Svelte: Context API or stores
|
|
53
|
+
|
|
54
|
+
**EventState:** Just share the store. That's it.
|
|
55
|
+
|
|
56
|
+
## Architecture Pattern
|
|
57
|
+
|
|
58
|
+
This demonstrates the **mediator pattern**:
|
|
59
|
+
- Components don't talk to each other
|
|
60
|
+
- They talk to the store
|
|
61
|
+
- The store mediates all communication
|
|
62
|
+
|
|
63
|
+
This is how your full UIstate framework works:
|
|
64
|
+
- Intents → State changes
|
|
65
|
+
- State changes → UI updates
|
|
66
|
+
- Everything is decoupled through events
|
|
67
|
+
|
|
68
|
+
## Run It
|
|
69
|
+
|
|
70
|
+
Open `index.html`, type messages, and watch all three components react independently.
|
|
71
|
+
|
|
72
|
+
## Try This
|
|
73
|
+
|
|
74
|
+
Add a fourth component that counts vowels in messages. It only needs to subscribe to `'message'` - no other code changes needed.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const createEventState = (initial = {}) => {
|
|
2
|
+
const store = JSON.parse(JSON.stringify(initial));
|
|
3
|
+
|
|
4
|
+
const bus = new EventTarget();
|
|
5
|
+
let destroyed = false;
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
get: (path) => {
|
|
9
|
+
if (destroyed) throw new Error('Cannot get from destroyed store');
|
|
10
|
+
if (!path) return store;
|
|
11
|
+
|
|
12
|
+
// Parse dot-paths: 'items.0' → ['items', '0']
|
|
13
|
+
const parts = path.split('.').flatMap(part => {
|
|
14
|
+
const match = part.match(/([^\[]+)\[(\d+)\]/);
|
|
15
|
+
return match ? [match[1], match[2]] : part;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return parts.reduce(
|
|
19
|
+
(obj, prop) => (obj && obj[prop] !== undefined ? obj[prop] : undefined),
|
|
20
|
+
store
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
set: (path, value) => {
|
|
24
|
+
if (destroyed) throw new Error('Cannot set on destroyed store');
|
|
25
|
+
if (!path) return;
|
|
26
|
+
const parts = path.split('.');
|
|
27
|
+
const last = parts.pop();
|
|
28
|
+
let target = store;
|
|
29
|
+
|
|
30
|
+
for (const key of parts) {
|
|
31
|
+
// create intermediate objects as needed
|
|
32
|
+
if (typeof target[key] !== 'object' || target[key] === null) {
|
|
33
|
+
target[key] = {};
|
|
34
|
+
}
|
|
35
|
+
target = target[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
target[last] = value;
|
|
39
|
+
|
|
40
|
+
if (!destroyed) {
|
|
41
|
+
// exact path
|
|
42
|
+
bus.dispatchEvent(new CustomEvent(path, { detail: value }));
|
|
43
|
+
|
|
44
|
+
// parent wildcards: a, a.b -> 'a.*', 'a.b.*'
|
|
45
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
46
|
+
const parent = parts.slice(0, i).join('.');
|
|
47
|
+
bus.dispatchEvent(new CustomEvent(`${parent}.*`, { detail: { path, value } }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// root wildcard
|
|
51
|
+
bus.dispatchEvent(new CustomEvent('*', { detail: { path, value } }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value;
|
|
55
|
+
},
|
|
56
|
+
subscribe(path, handler) {
|
|
57
|
+
if (destroyed) throw new Error('store destroyed');
|
|
58
|
+
if (!path || typeof handler !== 'function') {
|
|
59
|
+
throw new TypeError('subscribe(path, handler) requires a string path and function handler');
|
|
60
|
+
}
|
|
61
|
+
const onEvent = (evt) => handler(evt.detail, evt);
|
|
62
|
+
bus.addEventListener(path, onEvent);
|
|
63
|
+
|
|
64
|
+
return function unsubscribe() {
|
|
65
|
+
bus.removeEventListener(path, onEvent);
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
off(unsubscribe) {
|
|
69
|
+
if (typeof unsubscribe !== 'function') {
|
|
70
|
+
throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
|
|
71
|
+
}
|
|
72
|
+
return unsubscribe();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
destroy() {
|
|
76
|
+
if (!destroyed) {
|
|
77
|
+
destroyed = true;
|
|
78
|
+
// EventTarget has no parentNode - just mark as destroyed
|
|
79
|
+
// Future sets/subscribes will be blocked by destroyed flag
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default createEventState;
|
|
86
|
+
export { createEventState };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>010 Decoupled Components - EventState</title>
|
|
7
|
+
<style>
|
|
8
|
+
.component {
|
|
9
|
+
border: 2px solid #333;
|
|
10
|
+
padding: 20px;
|
|
11
|
+
margin: 10px 0;
|
|
12
|
+
}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<h1>Decoupled Components</h1>
|
|
17
|
+
<p>Two independent "components" sharing state without knowing about each other.</p>
|
|
18
|
+
|
|
19
|
+
<!-- Component A: Writer -->
|
|
20
|
+
<div class="component">
|
|
21
|
+
<h2>Component A: Writer</h2>
|
|
22
|
+
<input type="text" id="messageInput" placeholder="Type a message...">
|
|
23
|
+
<button id="sendBtn">Send</button>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Component B: Reader -->
|
|
27
|
+
<div class="component">
|
|
28
|
+
<h2>Component B: Reader</h2>
|
|
29
|
+
<p><strong>Received:</strong> <span id="messageDisplay">(nothing yet)</span></p>
|
|
30
|
+
<p><small>Message count: <span id="messageCount">0</span></small></p>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- Component C: Logger -->
|
|
34
|
+
<div class="component">
|
|
35
|
+
<h2>Component C: Logger</h2>
|
|
36
|
+
<ul id="messageLog"></ul>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<script type="module">
|
|
40
|
+
import { createEventState } from './eventState.js';
|
|
41
|
+
|
|
42
|
+
// Shared store - the ONLY connection between components
|
|
43
|
+
const store = createEventState({
|
|
44
|
+
message: '',
|
|
45
|
+
messageCount: 0
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ===== COMPONENT A: Writer =====
|
|
49
|
+
// Knows nothing about B or C, just writes to state
|
|
50
|
+
document.getElementById('sendBtn').onclick = () => {
|
|
51
|
+
const input = document.getElementById('messageInput');
|
|
52
|
+
const value = input.value.trim();
|
|
53
|
+
|
|
54
|
+
if (value) {
|
|
55
|
+
store.set('message', value);
|
|
56
|
+
store.set('messageCount', store.get('messageCount') + 1);
|
|
57
|
+
input.value = '';
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
document.getElementById('messageInput').onkeypress = (e) => {
|
|
62
|
+
if (e.key === 'Enter') {
|
|
63
|
+
document.getElementById('sendBtn').click();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ===== COMPONENT B: Reader =====
|
|
68
|
+
// Knows nothing about A or C, just reads from state
|
|
69
|
+
store.subscribe('message', (value) => {
|
|
70
|
+
document.getElementById('messageDisplay').textContent = value || '(nothing yet)';
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
store.subscribe('messageCount', (value) => {
|
|
74
|
+
document.getElementById('messageCount').textContent = value;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ===== COMPONENT C: Logger =====
|
|
78
|
+
// Knows nothing about A or B, just logs all messages
|
|
79
|
+
store.subscribe('message', (value) => {
|
|
80
|
+
if (value) {
|
|
81
|
+
const log = document.getElementById('messageLog');
|
|
82
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
83
|
+
const li = document.createElement('li');
|
|
84
|
+
li.textContent = `[${timestamp}] ${value}`;
|
|
85
|
+
log.appendChild(li);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 011 Async Patterns - Debouncing & Loading States
|
|
2
|
+
|
|
3
|
+
Demonstrates how to handle async operations with EventState.
|
|
4
|
+
|
|
5
|
+
## What's Here
|
|
6
|
+
|
|
7
|
+
- **Debounced search:** Wait for user to stop typing before searching
|
|
8
|
+
- **Loading states:** Show loading indicator during async operations
|
|
9
|
+
- **Error handling:** Graceful failure with try/catch
|
|
10
|
+
- **No async library needed** - just Promises and state
|
|
11
|
+
|
|
12
|
+
## How It Works
|
|
13
|
+
|
|
14
|
+
### Debounced Search
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
let searchTimeout = null;
|
|
18
|
+
|
|
19
|
+
store.subscribe('searchQuery', async (value) => {
|
|
20
|
+
clearTimeout(searchTimeout);
|
|
21
|
+
|
|
22
|
+
store.set('isSearching', true);
|
|
23
|
+
|
|
24
|
+
// Wait 300ms before searching
|
|
25
|
+
searchTimeout = setTimeout(async () => {
|
|
26
|
+
const result = await performSearch(value);
|
|
27
|
+
store.set('searchResult', result);
|
|
28
|
+
store.set('isSearching', false);
|
|
29
|
+
}, 300);
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Loading States
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
document.getElementById('fetchBtn').onclick = async () => {
|
|
37
|
+
store.set('isLoading', true);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const data = await fetchData();
|
|
41
|
+
store.set('apiData', data);
|
|
42
|
+
} finally {
|
|
43
|
+
store.set('isLoading', false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Key Insight
|
|
49
|
+
|
|
50
|
+
**Async is just state changes over time.**
|
|
51
|
+
|
|
52
|
+
Pattern for any async operation:
|
|
53
|
+
1. Set loading state to `true`
|
|
54
|
+
2. Perform async operation
|
|
55
|
+
3. Set result state
|
|
56
|
+
4. Set loading state to `false`
|
|
57
|
+
|
|
58
|
+
Other frameworks need:
|
|
59
|
+
- React: `useState` + `useEffect` + cleanup functions
|
|
60
|
+
- Vue: `ref` + `watch` + async watchers
|
|
61
|
+
- Svelte: Reactive statements + `#await` blocks
|
|
62
|
+
|
|
63
|
+
**EventState:** Just use async/await in subscribers. That's it.
|
|
64
|
+
|
|
65
|
+
## Patterns Demonstrated
|
|
66
|
+
|
|
67
|
+
### 1. Debouncing
|
|
68
|
+
Wait for user to stop typing before triggering expensive operations.
|
|
69
|
+
|
|
70
|
+
### 2. Loading Indicators
|
|
71
|
+
Show feedback during async operations to improve UX.
|
|
72
|
+
|
|
73
|
+
### 3. Disabling Actions
|
|
74
|
+
Prevent duplicate requests by disabling buttons during loading.
|
|
75
|
+
|
|
76
|
+
### 4. Cleanup
|
|
77
|
+
Clear timeouts to prevent race conditions.
|
|
78
|
+
|
|
79
|
+
## Run It
|
|
80
|
+
|
|
81
|
+
Open `index.html`:
|
|
82
|
+
- Type in the search box (notice the 300ms delay)
|
|
83
|
+
- Click "Fetch Data" (notice the loading state)
|
|
84
|
+
|
|
85
|
+
## Try This
|
|
86
|
+
|
|
87
|
+
Open DevTools Network tab and throttle to "Slow 3G" to see loading states more clearly.
|
|
88
|
+
|
|
89
|
+
## Real-World Usage
|
|
90
|
+
|
|
91
|
+
This pattern works for:
|
|
92
|
+
- ✅ API calls
|
|
93
|
+
- ✅ Autocomplete
|
|
94
|
+
- ✅ Form submission
|
|
95
|
+
- ✅ File uploads
|
|
96
|
+
- ✅ Any async operation
|
|
97
|
+
|
|
98
|
+
Just manage loading/error/success states in the store.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const createEventState = (initial = {}) => {
|
|
2
|
+
const store = JSON.parse(JSON.stringify(initial));
|
|
3
|
+
|
|
4
|
+
const bus = new EventTarget();
|
|
5
|
+
let destroyed = false;
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
get: (path) => {
|
|
9
|
+
if (destroyed) throw new Error('Cannot get from destroyed store');
|
|
10
|
+
if (!path) return store;
|
|
11
|
+
|
|
12
|
+
// Parse dot-paths: 'items.0' → ['items', '0']
|
|
13
|
+
const parts = path.split('.').flatMap(part => {
|
|
14
|
+
const match = part.match(/([^\[]+)\[(\d+)\]/);
|
|
15
|
+
return match ? [match[1], match[2]] : part;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return parts.reduce(
|
|
19
|
+
(obj, prop) => (obj && obj[prop] !== undefined ? obj[prop] : undefined),
|
|
20
|
+
store
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
set: (path, value) => {
|
|
24
|
+
if (destroyed) throw new Error('Cannot set on destroyed store');
|
|
25
|
+
if (!path) return;
|
|
26
|
+
const parts = path.split('.');
|
|
27
|
+
const last = parts.pop();
|
|
28
|
+
let target = store;
|
|
29
|
+
|
|
30
|
+
for (const key of parts) {
|
|
31
|
+
// create intermediate objects as needed
|
|
32
|
+
if (typeof target[key] !== 'object' || target[key] === null) {
|
|
33
|
+
target[key] = {};
|
|
34
|
+
}
|
|
35
|
+
target = target[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
target[last] = value;
|
|
39
|
+
|
|
40
|
+
if (!destroyed) {
|
|
41
|
+
// exact path
|
|
42
|
+
bus.dispatchEvent(new CustomEvent(path, { detail: value }));
|
|
43
|
+
|
|
44
|
+
// parent wildcards: a, a.b -> 'a.*', 'a.b.*'
|
|
45
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
46
|
+
const parent = parts.slice(0, i).join('.');
|
|
47
|
+
bus.dispatchEvent(new CustomEvent(`${parent}.*`, { detail: { path, value } }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// root wildcard
|
|
51
|
+
bus.dispatchEvent(new CustomEvent('*', { detail: { path, value } }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value;
|
|
55
|
+
},
|
|
56
|
+
subscribe(path, handler) {
|
|
57
|
+
if (destroyed) throw new Error('store destroyed');
|
|
58
|
+
if (!path || typeof handler !== 'function') {
|
|
59
|
+
throw new TypeError('subscribe(path, handler) requires a string path and function handler');
|
|
60
|
+
}
|
|
61
|
+
const onEvent = (evt) => handler(evt.detail, evt);
|
|
62
|
+
bus.addEventListener(path, onEvent);
|
|
63
|
+
|
|
64
|
+
return function unsubscribe() {
|
|
65
|
+
bus.removeEventListener(path, onEvent);
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
off(unsubscribe) {
|
|
69
|
+
if (typeof unsubscribe !== 'function') {
|
|
70
|
+
throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
|
|
71
|
+
}
|
|
72
|
+
return unsubscribe();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
destroy() {
|
|
76
|
+
if (!destroyed) {
|
|
77
|
+
destroyed = true;
|
|
78
|
+
// EventTarget has no parentNode - just mark as destroyed
|
|
79
|
+
// Future sets/subscribes will be blocked by destroyed flag
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default createEventState;
|
|
86
|
+
export { createEventState };
|