or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

attributes.mdcontext-system.mdcss-styling.mddata-binding.mddependency-injection.mdhtml-templates.mdindex.mdobservable-system.mdssr-hydration.mdstate-management.mdtemplate-directives.mdtesting-utilities.mdutilities.mdweb-components.md

state-management.mddocs/

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

```