0
# State Management
1
2
Reactive state management with reactive objects, computed values, and change watchers for complex application state management beyond component boundaries.
3
4
## Capabilities
5
6
### State Creation
7
8
Functions for creating reactive state values that can be shared across components and automatically trigger updates when changed.
9
10
```typescript { .api }
11
/**
12
* Creates a reactive state value
13
* @param value - The initial state value
14
* @param options - Options to customize the state or a friendly name
15
* @returns A State instance
16
*/
17
function state<T>(
18
value: T,
19
options?: string | StateOptions
20
): State<T>;
21
22
/**
23
* Options for creating state
24
*/
25
interface StateOptions {
26
/** Indicates whether to deeply make the state value observable */
27
deep?: boolean;
28
29
/** A friendly name for the state */
30
name?: string;
31
}
32
33
/**
34
* A read/write stateful value
35
*/
36
interface State<T> extends ReadonlyState<T> {
37
/** Gets or sets the current state value */
38
current: T;
39
40
/**
41
* Sets the current state value
42
* @param value - The new state value
43
*/
44
set(value: T): void;
45
46
/** Creates a readonly version of the state */
47
asReadonly(): ReadonlyState<T>;
48
}
49
50
/**
51
* A readonly stateful value
52
*/
53
interface ReadonlyState<T> {
54
/** Gets the current state value */
55
(): T;
56
57
/** Gets the current state value */
58
readonly current: T;
59
}
60
```
61
62
**Usage Examples:**
63
64
```typescript
65
import { FASTElement, customElement, html, Observable } from "@microsoft/fast-element";
66
import { state } from "@microsoft/fast-element/state.js";
67
68
// Global application state
69
const appState = state({
70
user: null as User | null,
71
theme: 'light' as 'light' | 'dark',
72
isLoading: false,
73
notifications: [] as Notification[]
74
}, { name: 'AppState', deep: true });
75
76
// Counter state with simple value
77
const counterState = state(0, 'Counter');
78
79
// User preferences state
80
const userPreferences = state({
81
language: 'en',
82
timezone: 'UTC',
83
emailNotifications: true,
84
darkMode: false
85
}, { deep: true, name: 'UserPreferences' });
86
87
// Shopping cart state
88
const cartState = state({
89
items: [] as CartItem[],
90
total: 0,
91
coupon: null as string | null
92
}, { deep: true });
93
94
@customElement("state-example")
95
export class StateExample extends FASTElement {
96
// Local component state
97
private localState = state({
98
selectedTab: 'profile',
99
formData: {
100
name: '',
101
email: '',
102
bio: ''
103
}
104
}, { deep: true });
105
106
connectedCallback() {
107
super.connectedCallback();
108
109
// Subscribe to global state changes
110
this.subscribeToStateChanges();
111
}
112
113
private subscribeToStateChanges() {
114
// Watch for user changes
115
const userNotifier = Observable.getNotifier(appState.current);
116
userNotifier.subscribe({
117
handleChange: (source, args) => {
118
console.log('App state changed:', args);
119
this.$fastController.update();
120
}
121
}, 'user');
122
123
// Watch for theme changes
124
userNotifier.subscribe({
125
handleChange: (source, args) => {
126
this.updateTheme(appState.current.theme);
127
this.$fastController.update();
128
}
129
}, 'theme');
130
}
131
132
// Actions that modify global state
133
login(user: User) {
134
appState.set({
135
...appState.current,
136
user,
137
isLoading: false
138
});
139
}
140
141
logout() {
142
appState.set({
143
...appState.current,
144
user: null
145
});
146
}
147
148
toggleTheme() {
149
const newTheme = appState.current.theme === 'light' ? 'dark' : 'light';
150
appState.set({
151
...appState.current,
152
theme: newTheme
153
});
154
}
155
156
addNotification(notification: Notification) {
157
const current = appState.current;
158
current.notifications.push(notification);
159
appState.set(current); // Trigger update
160
}
161
162
// Local state actions
163
selectTab(tab: string) {
164
this.localState.set({
165
...this.localState.current,
166
selectedTab: tab
167
});
168
}
169
170
updateFormData(field: string, value: string) {
171
const current = this.localState.current;
172
current.formData[field as keyof typeof current.formData] = value;
173
this.localState.set(current);
174
}
175
176
private updateTheme(theme: string) {
177
document.documentElement.setAttribute('data-theme', theme);
178
}
179
180
static template = html<StateExample>`
181
<div class="state-demo">
182
<header>
183
<h1>State Management Demo</h1>
184
<div class="user-info">
185
${() => appState().user
186
? html`Welcome, ${() => appState().user?.name}!`
187
: html`<button @click="${x => x.showLogin()}">Login</button>`
188
}
189
<button @click="${x => x.toggleTheme()}">
190
Theme: ${() => appState().theme}
191
</button>
192
</div>
193
</header>
194
195
<main>
196
<div class="tabs">
197
<button
198
class="${x => x.localState().selectedTab === 'profile' ? 'active' : ''}"
199
@click="${x => x.selectTab('profile')}">
200
Profile
201
</button>
202
<button
203
class="${x => x.localState().selectedTab === 'settings' ? 'active' : ''}"
204
@click="${x => x.selectTab('settings')}">
205
Settings
206
</button>
207
</div>
208
209
<div class="content">
210
${x => x.localState().selectedTab === 'profile'
211
? x.renderProfile()
212
: x.renderSettings()
213
}
214
</div>
215
</main>
216
217
<footer>
218
<div class="notifications">
219
${() => appState().notifications.map(n =>
220
`<div class="notification">${n.message}</div>`
221
).join('')}
222
</div>
223
</footer>
224
</div>
225
`;
226
227
private renderProfile() {
228
return html`
229
<form>
230
<input
231
type="text"
232
placeholder="Name"
233
.value="${x => x.localState().formData.name}"
234
@input="${(x, e) => x.updateFormData('name', (e.target as HTMLInputElement).value)}">
235
<input
236
type="email"
237
placeholder="Email"
238
.value="${x => x.localState().formData.email}"
239
@input="${(x, e) => x.updateFormData('email', (e.target as HTMLInputElement).value)}">
240
<textarea
241
placeholder="Bio"
242
.value="${x => x.localState().formData.bio}"
243
@input="${(x, e) => x.updateFormData('bio', (e.target as HTMLTextAreaElement).value)}">
244
</textarea>
245
</form>
246
`;
247
}
248
249
private renderSettings() {
250
return html`
251
<div class="settings">
252
<p>User preferences will go here</p>
253
<p>Current language: ${() => userPreferences().language}</p>
254
<p>Notifications: ${() => userPreferences().emailNotifications ? 'On' : 'Off'}</p>
255
</div>
256
`;
257
}
258
259
private showLogin() {
260
// Simulate login
261
this.login({
262
id: '1',
263
name: 'John Doe',
264
email: 'john@example.com'
265
});
266
}
267
}
268
269
// State management utilities
270
class StateManager {
271
private static states = new Map<string, State<any>>();
272
273
static createNamedState<T>(name: string, initialValue: T, options?: StateOptions): State<T> {
274
if (this.states.has(name)) {
275
return this.states.get(name)!;
276
}
277
278
const newState = state(initialValue, { ...options, name });
279
this.states.set(name, newState);
280
return newState;
281
}
282
283
static getState<T>(name: string): State<T> | undefined {
284
return this.states.get(name);
285
}
286
287
static destroyState(name: string): boolean {
288
return this.states.delete(name);
289
}
290
291
static getAllStates(): Map<string, State<any>> {
292
return new Map(this.states);
293
}
294
}
295
296
// Usage of state manager
297
const globalSettings = StateManager.createNamedState('globalSettings', {
298
apiUrl: 'https://api.example.com',
299
retryAttempts: 3,
300
timeout: 5000
301
});
302
303
interface User {
304
id: string;
305
name: string;
306
email: string;
307
}
308
309
interface Notification {
310
id: string;
311
message: string;
312
type: 'info' | 'warning' | 'error';
313
timestamp: Date;
314
}
315
316
interface CartItem {
317
id: string;
318
name: string;
319
price: number;
320
quantity: number;
321
}
322
```
323
324
### Owned State
325
326
Scoped state management for component-specific state that's tied to component lifecycle.
327
328
```typescript { .api }
329
/**
330
* Creates owner-scoped reactive state
331
* @param owner - The owner object that controls the state lifecycle
332
* @param value - The initial state value
333
* @param options - Options to customize the state or a friendly name
334
* @returns An OwnedState instance
335
*/
336
function ownedState<T>(
337
value: T | (() => T),
338
options?: string | StateOptions
339
): OwnedState<T>;
340
341
/**
342
* A read/write owned state value tied to an owner's lifecycle
343
*/
344
interface OwnedState<T> extends State<T> {
345
/** The owner of this state */
346
readonly owner: any;
347
}
348
349
/**
350
* A readonly owned state value
351
*/
352
interface ReadonlyOwnedState<T> extends ReadonlyState<T> {
353
/** The owner of this state */
354
readonly owner: any;
355
}
356
```
357
358
**Usage Examples:**
359
360
```typescript
361
import { FASTElement, customElement, html } from "@microsoft/fast-element";
362
import { ownedState } from "@microsoft/fast-element/state.js";
363
364
@customElement("owned-state-example")
365
export class OwnedStateExample extends FASTElement {
366
// State owned by this component instance
367
private componentState = ownedState(this, {
368
counter: 0,
369
items: [] as string[],
370
isExpanded: false
371
}, { deep: true, name: `ComponentState-${this.id || 'unknown'}` });
372
373
// Separate owned state for form data
374
private formState = ownedState(this, {
375
name: '',
376
email: '',
377
message: '',
378
isValid: false
379
}, 'FormState');
380
381
connectedCallback() {
382
super.connectedCallback();
383
console.log(`Component ${this.id} connected with state:`, this.componentState());
384
}
385
386
disconnectedCallback() {
387
super.disconnectedCallback();
388
console.log(`Component ${this.id} disconnected, state cleanup automatic`);
389
}
390
391
// Component-specific actions
392
incrementCounter() {
393
const current = this.componentState.current;
394
this.componentState.set({
395
...current,
396
counter: current.counter + 1
397
});
398
}
399
400
addItem() {
401
const current = this.componentState.current;
402
current.items.push(`Item ${current.items.length + 1}`);
403
this.componentState.set(current);
404
}
405
406
toggleExpanded() {
407
const current = this.componentState.current;
408
this.componentState.set({
409
...current,
410
isExpanded: !current.isExpanded
411
});
412
}
413
414
updateForm(field: string, value: string) {
415
const current = this.formState.current;
416
(current as any)[field] = value;
417
418
// Validate form
419
current.isValid = current.name.length > 0 &&
420
current.email.includes('@') &&
421
current.message.length > 10;
422
423
this.formState.set(current);
424
}
425
426
submitForm() {
427
if (this.formState().isValid) {
428
console.log('Submitting form:', this.formState());
429
// Reset form after submission
430
this.formState.set({
431
name: '',
432
email: '',
433
message: '',
434
isValid: false
435
});
436
}
437
}
438
439
static template = html<OwnedStateExample>`
440
<div class="owned-state-demo">
441
<h2>Owned State Demo</h2>
442
443
<section class="counter">
444
<p>Counter: ${x => x.componentState().counter}</p>
445
<button @click="${x => x.incrementCounter()}">Increment</button>
446
</section>
447
448
<section class="items">
449
<p>Items (${x => x.componentState().items.length}):</p>
450
<ul>
451
${x => x.componentState().items.map(item =>
452
`<li>${item}</li>`
453
).join('')}
454
</ul>
455
<button @click="${x => x.addItem()}">Add Item</button>
456
</section>
457
458
<section class="expandable">
459
<button @click="${x => x.toggleExpanded()}">
460
${x => x.componentState().isExpanded ? 'Collapse' : 'Expand'}
461
</button>
462
<div ?hidden="${x => !x.componentState().isExpanded}">
463
<p>This content is toggleable!</p>
464
</div>
465
</section>
466
467
<section class="form">
468
<h3>Contact Form</h3>
469
<form @submit="${x => x.submitForm()}">
470
<input
471
type="text"
472
placeholder="Name"
473
.value="${x => x.formState().name}"
474
@input="${(x, e) => x.updateForm('name', (e.target as HTMLInputElement).value)}">
475
476
<input
477
type="email"
478
placeholder="Email"
479
.value="${x => x.formState().email}"
480
@input="${(x, e) => x.updateForm('email', (e.target as HTMLInputElement).value)}">
481
482
<textarea
483
placeholder="Message (min 10 characters)"
484
.value="${x => x.formState().message}"
485
@input="${(x, e) => x.updateForm('message', (e.target as HTMLTextAreaElement).value)}">
486
</textarea>
487
488
<button type="submit" ?disabled="${x => !x.formState().isValid}">
489
Submit
490
</button>
491
</form>
492
</section>
493
</div>
494
`;
495
}
496
497
// Multiple instances demonstrate independent owned state
498
@customElement("state-instance-demo")
499
export class StateInstanceDemo extends FASTElement {
500
static template = html`
501
<div class="instance-demo">
502
<h1>Multiple Component Instances</h1>
503
<p>Each component below has its own independent owned state:</p>
504
505
<owned-state-example id="instance-1"></owned-state-example>
506
<owned-state-example id="instance-2"></owned-state-example>
507
<owned-state-example id="instance-3"></owned-state-example>
508
</div>
509
`;
510
}
511
```
512
513
### Computed State
514
515
Derived state that automatically recalculates when dependencies change, providing efficient computed values.
516
517
```typescript { .api }
518
/**
519
* Creates computed state that derives its value from other reactive sources
520
* @param compute - Function that computes the derived value
521
* @returns A ComputedState instance
522
*/
523
function computedState<T>(compute: ComputedInitializer<T>): ComputedState<T>;
524
525
/**
526
* Function that computes a derived value
527
*/
528
type ComputedInitializer<T> = (builder: ComputedBuilder) => T;
529
530
/**
531
* Builder for setting up computed state dependencies
532
*/
533
interface ComputedBuilder {
534
/**
535
* Sets up the compute function
536
* @param callback - The callback that computes the value
537
*/
538
setup(callback: ComputedSetupCallback): void;
539
}
540
541
/**
542
* Callback for computed state setup
543
*/
544
type ComputedSetupCallback = () => void;
545
546
/**
547
* A computed state value that derives from other reactive sources
548
*/
549
interface ComputedState<T> extends ReadonlyState<T> {
550
/** Indicates this is a computed state */
551
readonly isComputed: true;
552
}
553
```
554
555
**Usage Examples:**
556
557
```typescript
558
import { FASTElement, customElement, html } from "@microsoft/fast-element";
559
import { state, computedState } from "@microsoft/fast-element/state.js";
560
561
// Base reactive states
562
const userState = state({
563
firstName: 'John',
564
lastName: 'Doe',
565
birthDate: new Date('1990-01-01'),
566
email: 'john.doe@example.com'
567
}, { deep: true });
568
569
const cartState = state({
570
items: [
571
{ id: 1, name: 'Widget A', price: 10.99, quantity: 2 },
572
{ id: 2, name: 'Widget B', price: 5.50, quantity: 1 },
573
{ id: 3, name: 'Widget C', price: 15.00, quantity: 3 }
574
] as CartItem[],
575
taxRate: 0.08,
576
shippingCost: 5.99,
577
discountPercent: 0
578
}, { deep: true });
579
580
const appState = state({
581
currentRoute: '/home',
582
isAuthenticated: false,
583
theme: 'light' as 'light' | 'dark',
584
language: 'en'
585
});
586
587
// Computed states that derive from base states
588
const userDisplayName = computedState(builder => {
589
const user = userState();
590
return `${user.firstName} ${user.lastName}`;
591
});
592
593
const userAge = computedState(builder => {
594
const user = userState();
595
const today = new Date();
596
const birthDate = new Date(user.birthDate);
597
let age = today.getFullYear() - birthDate.getFullYear();
598
const monthDiff = today.getMonth() - birthDate.getMonth();
599
600
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
601
age--;
602
}
603
604
return age;
605
});
606
607
const cartSubtotal = computedState(builder => {
608
const cart = cartState();
609
return cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
610
});
611
612
const cartTax = computedState(builder => {
613
const cart = cartState();
614
const subtotal = cartSubtotal();
615
return subtotal * cart.taxRate;
616
});
617
618
const cartDiscount = computedState(builder => {
619
const cart = cartState();
620
const subtotal = cartSubtotal();
621
return subtotal * (cart.discountPercent / 100);
622
});
623
624
const cartTotal = computedState(builder => {
625
const cart = cartState();
626
const subtotal = cartSubtotal();
627
const tax = cartTax();
628
const discount = cartDiscount();
629
630
return subtotal + tax - discount + cart.shippingCost;
631
});
632
633
const cartItemCount = computedState(builder => {
634
const cart = cartState();
635
return cart.items.reduce((sum, item) => sum + item.quantity, 0);
636
});
637
638
const isCartEmpty = computedState(builder => {
639
return cartItemCount() === 0;
640
});
641
642
const currentPageTitle = computedState(builder => {
643
const app = appState();
644
const routes: Record<string, string> = {
645
'/home': 'Home',
646
'/products': 'Products',
647
'/cart': 'Shopping Cart',
648
'/profile': 'User Profile',
649
'/settings': 'Settings'
650
};
651
652
return routes[app.currentRoute] || 'Page Not Found';
653
});
654
655
const isUserMinor = computedState(builder => {
656
return userAge() < 18;
657
});
658
659
@customElement("computed-state-example")
660
export class ComputedStateExample extends FASTElement {
661
// Local computed state
662
private localMessage = computedState(builder => {
663
const displayName = userDisplayName();
664
const age = userAge();
665
const itemCount = cartItemCount();
666
667
return `Hello ${displayName} (${age} years old)! You have ${itemCount} items in your cart.`;
668
});
669
670
private cartSummary = computedState(builder => {
671
const subtotal = cartSubtotal();
672
const tax = cartTax();
673
const discount = cartDiscount();
674
const total = cartTotal();
675
const isEmpty = isCartEmpty();
676
677
if (isEmpty) {
678
return 'Your cart is empty';
679
}
680
681
return {
682
subtotal: subtotal.toFixed(2),
683
tax: tax.toFixed(2),
684
discount: discount.toFixed(2),
685
total: total.toFixed(2),
686
savings: discount > 0 ? `You saved $${discount.toFixed(2)}!` : null
687
};
688
});
689
690
// Actions that modify base state
691
updateUser(field: string, value: any) {
692
const current = userState.current;
693
(current as any)[field] = value;
694
userState.set(current);
695
}
696
697
addCartItem() {
698
const current = cartState.current;
699
const newItem = {
700
id: Date.now(),
701
name: `New Item ${current.items.length + 1}`,
702
price: Math.random() * 20 + 5,
703
quantity: 1
704
};
705
current.items.push(newItem);
706
cartState.set(current);
707
}
708
709
updateQuantity(itemId: number, newQuantity: number) {
710
const current = cartState.current;
711
const item = current.items.find(i => i.id === itemId);
712
if (item) {
713
if (newQuantity <= 0) {
714
current.items = current.items.filter(i => i.id !== itemId);
715
} else {
716
item.quantity = newQuantity;
717
}
718
cartState.set(current);
719
}
720
}
721
722
applyDiscount(percent: number) {
723
const current = cartState.current;
724
current.discountPercent = percent;
725
cartState.set(current);
726
}
727
728
navigateTo(route: string) {
729
appState.set({
730
...appState.current,
731
currentRoute: route
732
});
733
}
734
735
static template = html<ComputedStateExample>`
736
<div class="computed-state-demo">
737
<header>
738
<h1>${() => currentPageTitle()}</h1>
739
<nav>
740
<button @click="${x => x.navigateTo('/home')}">Home</button>
741
<button @click="${x => x.navigateTo('/products')}">Products</button>
742
<button @click="${x => x.navigateTo('/cart')}">Cart (${() => cartItemCount()})</button>
743
<button @click="${x => x.navigateTo('/profile')}">Profile</button>
744
</nav>
745
</header>
746
747
<main>
748
<section class="user-info">
749
<h2>User Information</h2>
750
<p>${x => x.localMessage()}</p>
751
<div class="user-form">
752
<input
753
type="text"
754
placeholder="First Name"
755
.value="${() => userState().firstName}"
756
@input="${(x, e) => x.updateUser('firstName', (e.target as HTMLInputElement).value)}">
757
<input
758
type="text"
759
placeholder="Last Name"
760
.value="${() => userState().lastName}"
761
@input="${(x, e) => x.updateUser('lastName', (e.target as HTMLInputElement).value)}">
762
<input
763
type="date"
764
.value="${() => userState().birthDate.toISOString().split('T')[0]}"
765
@input="${(x, e) => x.updateUser('birthDate', new Date((e.target as HTMLInputElement).value))}">
766
</div>
767
<div class="computed-values">
768
<p><strong>Display Name:</strong> ${() => userDisplayName()}</p>
769
<p><strong>Age:</strong> ${() => userAge()} years old</p>
770
<p><strong>Status:</strong> ${() => isUserMinor() ? 'Minor' : 'Adult'}</p>
771
</div>
772
</section>
773
774
<section class="cart-info">
775
<h2>Shopping Cart</h2>
776
<div class="cart-items">
777
${() => cartState().items.map(item =>
778
`<div class="cart-item">
779
<span>${item.name}</span>
780
<span>$${item.price.toFixed(2)}</span>
781
<input type="number"
782
value="${item.quantity}"
783
min="0"
784
onchange="this.getRootNode().host.updateQuantity(${item.id}, parseInt(this.value))">
785
<span>$${(item.price * item.quantity).toFixed(2)}</span>
786
</div>`
787
).join('')}
788
</div>
789
790
<div class="cart-actions">
791
<button @click="${x => x.addCartItem()}">Add Random Item</button>
792
<button @click="${x => x.applyDiscount(10)}">Apply 10% Discount</button>
793
<button @click="${x => x.applyDiscount(0)}">Remove Discount</button>
794
</div>
795
796
<div class="cart-summary">
797
${x => {
798
const summary = x.cartSummary();
799
if (typeof summary === 'string') {
800
return `<p>${summary}</p>`;
801
}
802
return `
803
<div class="summary-line">
804
<span>Subtotal:</span>
805
<span>$${summary.subtotal}</span>
806
</div>
807
<div class="summary-line">
808
<span>Tax:</span>
809
<span>$${summary.tax}</span>
810
</div>
811
<div class="summary-line">
812
<span>Discount:</span>
813
<span>-$${summary.discount}</span>
814
</div>
815
<div class="summary-line">
816
<span>Shipping:</span>
817
<span>$${cartState().shippingCost.toFixed(2)}</span>
818
</div>
819
<div class="summary-line total">
820
<span><strong>Total:</strong></span>
821
<span><strong>$${summary.total}</strong></span>
822
</div>
823
${summary.savings ? `<p class="savings">${summary.savings}</p>` : ''}
824
`;
825
}}
826
</div>
827
</section>
828
</main>
829
</div>
830
`;
831
}
832
833
interface CartItem {
834
id: number;
835
name: string;
836
price: number;
837
quantity: number;
838
}
839
```
840
841
### Reactive Objects
842
843
Function for making existing objects reactive, enabling automatic change detection on object properties.
844
845
```typescript { .api }
846
/**
847
* Makes an object reactive by adding observable properties
848
* @param target - The object to make reactive
849
* @param deep - Whether to deeply observe nested objects
850
* @returns The reactive version of the object
851
*/
852
function reactive<T extends object>(target: T, deep?: boolean): T;
853
```
854
855
**Usage Examples:**
856
857
```typescript
858
import { FASTElement, customElement, html, Observable } from "@microsoft/fast-element";
859
import { reactive } from "@microsoft/fast-element/state.js";
860
861
// Make existing objects reactive
862
const userModel = reactive({
863
profile: {
864
name: 'John Doe',
865
email: 'john@example.com',
866
avatar: '/avatars/john.jpg'
867
},
868
settings: {
869
theme: 'light',
870
notifications: true,
871
language: 'en'
872
},
873
preferences: {
874
layout: 'grid',
875
itemsPerPage: 20,
876
sortBy: 'name'
877
}
878
}, true); // deep reactive
879
880
const todoModel = reactive({
881
items: [
882
{ id: 1, text: 'Learn FAST Element', completed: false, priority: 'high' },
883
{ id: 2, text: 'Build awesome components', completed: false, priority: 'medium' },
884
{ id: 3, text: 'Ship to production', completed: false, priority: 'high' }
885
],
886
filter: 'all' as 'all' | 'active' | 'completed',
887
stats: {
888
total: 3,
889
completed: 0,
890
remaining: 3
891
}
892
}, true);
893
894
@customElement("reactive-example")
895
export class ReactiveExample extends FASTElement {
896
// Local reactive models
897
private formModel = reactive({
898
personalInfo: {
899
firstName: '',
900
lastName: '',
901
birthDate: '',
902
phone: ''
903
},
904
address: {
905
street: '',
906
city: '',
907
state: '',
908
zipCode: ''
909
},
910
validation: {
911
isValid: false,
912
errors: [] as string[]
913
}
914
}, true);
915
916
private dashboardModel = reactive({
917
widgets: [
918
{ id: 'weather', title: 'Weather', visible: true, position: { x: 0, y: 0 } },
919
{ id: 'calendar', title: 'Calendar', visible: true, position: { x: 1, y: 0 } },
920
{ id: 'tasks', title: 'Tasks', visible: false, position: { x: 0, y: 1 } },
921
{ id: 'news', title: 'News', visible: true, position: { x: 1, y: 1 } }
922
],
923
layout: 'grid' as 'grid' | 'list',
924
theme: 'light' as 'light' | 'dark'
925
});
926
927
connectedCallback() {
928
super.connectedCallback();
929
this.setupReactiveListeners();
930
}
931
932
private setupReactiveListeners() {
933
// Listen to user model changes
934
const userNotifier = Observable.getNotifier(userModel);
935
userNotifier.subscribe({
936
handleChange: (source, args) => {
937
console.log('User model changed:', args);
938
this.$fastController.update();
939
}
940
});
941
942
// Listen to todo model changes
943
const todoNotifier = Observable.getNotifier(todoModel);
944
todoNotifier.subscribe({
945
handleChange: (source, args) => {
946
this.updateTodoStats();
947
this.$fastController.update();
948
}
949
});
950
951
// Listen to form validation changes
952
const formNotifier = Observable.getNotifier(this.formModel);
953
formNotifier.subscribe({
954
handleChange: (source, args) => {
955
this.validateForm();
956
this.$fastController.update();
957
}
958
});
959
}
960
961
// User actions
962
updateUserProfile(field: string, value: any) {
963
const keys = field.split('.');
964
let target = userModel as any;
965
966
for (let i = 0; i < keys.length - 1; i++) {
967
target = target[keys[i]];
968
}
969
970
target[keys[keys.length - 1]] = value;
971
}
972
973
// Todo actions
974
addTodo(text: string) {
975
const newTodo = {
976
id: Date.now(),
977
text,
978
completed: false,
979
priority: 'medium' as 'low' | 'medium' | 'high'
980
};
981
todoModel.items.push(newTodo);
982
}
983
984
toggleTodo(id: number) {
985
const todo = todoModel.items.find(t => t.id === id);
986
if (todo) {
987
todo.completed = !todo.completed;
988
}
989
}
990
991
removeTodo(id: number) {
992
const index = todoModel.items.findIndex(t => t.id === id);
993
if (index !== -1) {
994
todoModel.items.splice(index, 1);
995
}
996
}
997
998
setFilter(filter: typeof todoModel.filter) {
999
todoModel.filter = filter;
1000
}
1001
1002
private updateTodoStats() {
1003
const completed = todoModel.items.filter(t => t.completed).length;
1004
todoModel.stats.total = todoModel.items.length;
1005
todoModel.stats.completed = completed;
1006
todoModel.stats.remaining = todoModel.items.length - completed;
1007
}
1008
1009
// Form actions
1010
updateForm(field: string, value: any) {
1011
const keys = field.split('.');
1012
let target = this.formModel as any;
1013
1014
for (let i = 0; i < keys.length - 1; i++) {
1015
target = target[keys[i]];
1016
}
1017
1018
target[keys[keys.length - 1]] = value;
1019
}
1020
1021
private validateForm() {
1022
const { personalInfo, address } = this.formModel;
1023
const errors: string[] = [];
1024
1025
if (!personalInfo.firstName) errors.push('First name is required');
1026
if (!personalInfo.lastName) errors.push('Last name is required');
1027
if (!personalInfo.birthDate) errors.push('Birth date is required');
1028
if (!address.street) errors.push('Street address is required');
1029
if (!address.city) errors.push('City is required');
1030
if (!address.zipCode) errors.push('Zip code is required');
1031
1032
this.formModel.validation.errors = errors;
1033
this.formModel.validation.isValid = errors.length === 0;
1034
}
1035
1036
// Dashboard actions
1037
toggleWidget(id: string) {
1038
const widget = this.dashboardModel.widgets.find(w => w.id === id);
1039
if (widget) {
1040
widget.visible = !widget.visible;
1041
}
1042
}
1043
1044
moveWidget(id: string, x: number, y: number) {
1045
const widget = this.dashboardModel.widgets.find(w => w.id === id);
1046
if (widget) {
1047
widget.position.x = x;
1048
widget.position.y = y;
1049
}
1050
}
1051
1052
changeLayout(layout: typeof this.dashboardModel.layout) {
1053
this.dashboardModel.layout = layout;
1054
}
1055
1056
static template = html<ReactiveExample>`
1057
<div class="reactive-demo">
1058
<nav class="tabs">
1059
<button>User Profile</button>
1060
<button>Todo List</button>
1061
<button>Form Demo</button>
1062
<button>Dashboard</button>
1063
</nav>
1064
1065
<section class="user-profile">
1066
<h2>User Profile</h2>
1067
<div class="profile-info">
1068
<input
1069
type="text"
1070
placeholder="Name"
1071
.value="${() => userModel.profile.name}"
1072
@input="${(x, e) => x.updateUserProfile('profile.name', (e.target as HTMLInputElement).value)}">
1073
<input
1074
type="email"
1075
placeholder="Email"
1076
.value="${() => userModel.profile.email}"
1077
@input="${(x, e) => x.updateUserProfile('profile.email', (e.target as HTMLInputElement).value)}">
1078
1079
<select
1080
.value="${() => userModel.settings.theme}"
1081
@change="${(x, e) => x.updateUserProfile('settings.theme', (e.target as HTMLSelectElement).value)}">
1082
<option value="light">Light Theme</option>
1083
<option value="dark">Dark Theme</option>
1084
</select>
1085
1086
<label>
1087
<input
1088
type="checkbox"
1089
.checked="${() => userModel.settings.notifications}"
1090
@change="${(x, e) => x.updateUserProfile('settings.notifications', (e.target as HTMLInputElement).checked)}">
1091
Enable Notifications
1092
</label>
1093
</div>
1094
</section>
1095
1096
<section class="todo-list">
1097
<h2>Todo List</h2>
1098
<div class="todo-stats">
1099
<p>Total: ${() => todoModel.stats.total}</p>
1100
<p>Completed: ${() => todoModel.stats.completed}</p>
1101
<p>Remaining: ${() => todoModel.stats.remaining}</p>
1102
</div>
1103
1104
<div class="todo-filters">
1105
<button
1106
class="${() => todoModel.filter === 'all' ? 'active' : ''}"
1107
@click="${x => x.setFilter('all')}">All</button>
1108
<button
1109
class="${() => todoModel.filter === 'active' ? 'active' : ''}"
1110
@click="${x => x.setFilter('active')}">Active</button>
1111
<button
1112
class="${() => todoModel.filter === 'completed' ? 'active' : ''}"
1113
@click="${x => x.setFilter('completed')}">Completed</button>
1114
</div>
1115
1116
<div class="todo-items">
1117
${() => todoModel.items
1118
.filter(todo => {
1119
if (todoModel.filter === 'active') return !todo.completed;
1120
if (todoModel.filter === 'completed') return todo.completed;
1121
return true;
1122
})
1123
.map(todo =>
1124
`<div class="todo-item ${todo.completed ? 'completed' : ''}">
1125
<input type="checkbox"
1126
${todo.completed ? 'checked' : ''}
1127
onchange="this.getRootNode().host.toggleTodo(${todo.id})">
1128
<span class="todo-text">${todo.text}</span>
1129
<span class="todo-priority ${todo.priority}">${todo.priority}</span>
1130
<button onclick="this.getRootNode().host.removeTodo(${todo.id})">×</button>
1131
</div>`
1132
).join('')}
1133
</div>
1134
1135
<form @submit="${(x, e) => {
1136
e.preventDefault();
1137
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement;
1138
if (input.value.trim()) {
1139
x.addTodo(input.value.trim());
1140
input.value = '';
1141
}
1142
}}">
1143
<input type="text" placeholder="Add new todo...">
1144
<button type="submit">Add</button>
1145
</form>
1146
</section>
1147
</div>
1148
`;
1149
}
1150
```
1151
1152
### Watch Function
1153
1154
Function for watching changes to reactive objects and properties, providing custom change handlers.
1155
1156
```typescript { .api }
1157
/**
1158
* Watches an object for changes and executes a callback
1159
* @param target - The object to watch
1160
* @param callback - The callback to execute on changes
1161
* @returns A disposable to stop watching
1162
*/
1163
function watch<T>(
1164
target: T,
1165
callback: (source: T, args: any) => void
1166
): Disposable;
1167
```
1168
1169
**Usage Examples:**
1170
1171
```typescript
1172
import { Disposable } from "@microsoft/fast-element";
1173
import { watch, reactive, state } from "@microsoft/fast-element/state.js";
1174
1175
// Reactive objects to watch
1176
const appSettings = reactive({
1177
theme: 'light',
1178
language: 'en',
1179
autoSave: true,
1180
debugMode: false
1181
});
1182
1183
const userSession = state({
1184
userId: null as string | null,
1185
loginTime: null as Date | null,
1186
lastActivity: new Date(),
1187
permissions: [] as string[]
1188
}, { deep: true });
1189
1190
// Watch setup with proper cleanup
1191
class WatchManager {
1192
private watchers: Disposable[] = [];
1193
1194
setupWatchers() {
1195
// Watch app settings changes
1196
const settingsWatcher = watch(appSettings, (source, args) => {
1197
console.log('App settings changed:', args);
1198
this.handleSettingsChange(args);
1199
});
1200
1201
// Watch user session changes
1202
const sessionWatcher = watch(userSession.current, (source, args) => {
1203
console.log('User session changed:', args);
1204
this.handleSessionChange(args);
1205
});
1206
1207
// Watch specific property changes
1208
const themeWatcher = watch(appSettings, (source, args) => {
1209
if (args.propertyName === 'theme') {
1210
this.applyTheme(source.theme);
1211
}
1212
});
1213
1214
this.watchers.push(settingsWatcher, sessionWatcher, themeWatcher);
1215
}
1216
1217
private handleSettingsChange(args: any) {
1218
// Save settings to localStorage
1219
localStorage.setItem('appSettings', JSON.stringify(appSettings));
1220
1221
// Apply settings
1222
if (args.propertyName === 'language') {
1223
this.loadLanguage(appSettings.language);
1224
}
1225
1226
if (args.propertyName === 'debugMode') {
1227
this.toggleDebugMode(appSettings.debugMode);
1228
}
1229
}
1230
1231
private handleSessionChange(args: any) {
1232
if (args.propertyName === 'userId') {
1233
if (userSession().userId) {
1234
this.onUserLogin();
1235
} else {
1236
this.onUserLogout();
1237
}
1238
}
1239
1240
if (args.propertyName === 'lastActivity') {
1241
this.resetIdleTimer();
1242
}
1243
}
1244
1245
private applyTheme(theme: string) {
1246
document.documentElement.setAttribute('data-theme', theme);
1247
}
1248
1249
private loadLanguage(language: string) {
1250
// Load language resources
1251
console.log(`Loading language: ${language}`);
1252
}
1253
1254
private toggleDebugMode(enabled: boolean) {
1255
if (enabled) {
1256
console.log('Debug mode enabled');
1257
window.addEventListener('error', this.handleGlobalError);
1258
} else {
1259
console.log('Debug mode disabled');
1260
window.removeEventListener('error', this.handleGlobalError);
1261
}
1262
}
1263
1264
private onUserLogin() {
1265
userSession.set({
1266
...userSession.current,
1267
loginTime: new Date(),
1268
lastActivity: new Date()
1269
});
1270
}
1271
1272
private onUserLogout() {
1273
// Clear sensitive data
1274
userSession.set({
1275
userId: null,
1276
loginTime: null,
1277
lastActivity: new Date(),
1278
permissions: []
1279
});
1280
}
1281
1282
private resetIdleTimer() {
1283
// Reset idle timer logic
1284
}
1285
1286
private handleGlobalError = (event: ErrorEvent) => {
1287
console.error('Global error:', event.error);
1288
};
1289
1290
cleanup() {
1291
// Clean up all watchers
1292
this.watchers.forEach(watcher => watcher.dispose());
1293
this.watchers = [];
1294
}
1295
}
1296
1297
// Component using watchers
1298
@customElement("watch-example")
1299
export class WatchExample extends FASTElement {
1300
private watchManager = new WatchManager();
1301
private localWatchers: Disposable[] = [];
1302
1303
connectedCallback() {
1304
super.connectedCallback();
1305
this.watchManager.setupWatchers();
1306
this.setupLocalWatchers();
1307
}
1308
1309
disconnectedCallback() {
1310
super.disconnectedCallback();
1311
this.watchManager.cleanup();
1312
this.cleanupLocalWatchers();
1313
}
1314
1315
private setupLocalWatchers() {
1316
// Watch for validation changes
1317
const validationWatcher = watch(this.formData, (source, args) => {
1318
if (args.propertyName) {
1319
this.validateField(args.propertyName, args.newValue);
1320
}
1321
});
1322
1323
// Watch for array changes
1324
const itemsWatcher = watch(this.items, (source, args) => {
1325
this.updateItemsDisplay();
1326
this.saveItemsToStorage();
1327
});
1328
1329
this.localWatchers.push(validationWatcher, itemsWatcher);
1330
}
1331
1332
private cleanupLocalWatchers() {
1333
this.localWatchers.forEach(watcher => watcher.dispose());
1334
this.localWatchers = [];
1335
}
1336
1337
// Reactive data
1338
private formData = reactive({
1339
email: '',
1340
password: '',
1341
confirmPassword: '',
1342
agreeToTerms: false,
1343
errors: {} as Record<string, string>
1344
});
1345
1346
private items = reactive({
1347
list: [] as Array<{ id: number; name: string; status: string }>,
1348
filter: 'all'
1349
});
1350
1351
private validateField(fieldName: string, value: any) {
1352
const errors = { ...this.formData.errors };
1353
1354
switch (fieldName) {
1355
case 'email':
1356
if (!value || !value.includes('@')) {
1357
errors.email = 'Please enter a valid email address';
1358
} else {
1359
delete errors.email;
1360
}
1361
break;
1362
1363
case 'password':
1364
if (!value || value.length < 8) {
1365
errors.password = 'Password must be at least 8 characters';
1366
} else {
1367
delete errors.password;
1368
}
1369
break;
1370
1371
case 'confirmPassword':
1372
if (value !== this.formData.password) {
1373
errors.confirmPassword = 'Passwords do not match';
1374
} else {
1375
delete errors.confirmPassword;
1376
}
1377
break;
1378
}
1379
1380
this.formData.errors = errors;
1381
}
1382
1383
private updateItemsDisplay() {
1384
console.log('Items updated:', this.items.list.length);
1385
}
1386
1387
private saveItemsToStorage() {
1388
localStorage.setItem('items', JSON.stringify(this.items.list));
1389
}
1390
1391
static template = html<WatchExample>`
1392
<div class="watch-demo">
1393
<h2>Watch Example</h2>
1394
<p>Open console to see watch notifications</p>
1395
1396
<section class="settings">
1397
<h3>App Settings</h3>
1398
<label>
1399
Theme:
1400
<select .value="${() => appSettings.theme}"
1401
@change="${(x, e) => appSettings.theme = (e.target as HTMLSelectElement).value}">
1402
<option value="light">Light</option>
1403
<option value="dark">Dark</option>
1404
</select>
1405
</label>
1406
1407
<label>
1408
<input type="checkbox"
1409
.checked="${() => appSettings.autoSave}"
1410
@change="${(x, e) => appSettings.autoSave = (e.target as HTMLInputElement).checked}">
1411
Auto Save
1412
</label>
1413
1414
<label>
1415
<input type="checkbox"
1416
.checked="${() => appSettings.debugMode}"
1417
@change="${(x, e) => appSettings.debugMode = (e.target as HTMLInputElement).checked}">
1418
Debug Mode
1419
</label>
1420
</section>
1421
</div>
1422
`;
1423
}
1424
```
1425
1426
## Types
1427
1428
```typescript { .api }
1429
/**
1430
* Disposable interface for cleanup
1431
*/
1432
interface Disposable {
1433
/** Disposes of the resource */
1434
dispose(): void;
1435
}
1436
1437
/**
1438
* Options for state creation
1439
*/
1440
interface StateOptions {
1441
/** Whether to deeply observe nested objects */
1442
deep?: boolean;
1443
1444
/** Friendly name for the state */
1445
name?: string;
1446
}
1447
1448
/**
1449
* Function signature for computed state initialization
1450
*/
1451
type ComputedInitializer<T> = (builder: ComputedBuilder) => T;
1452
1453
/**
1454
* Function signature for computed setup callback
1455
*/
1456
type ComputedSetupCallback = () => void;
1457
1458
/**
1459
* Builder interface for computed state setup
1460
*/
1461
interface ComputedBuilder {
1462
setup(callback: ComputedSetupCallback): void;
1463
}
1464
```