🔄

Reactive state
field / derived / state, the global store, read/watch.

flash-theater's core pitch is: declare data, bind it into the template, and never write an observeFieldScoped or a manual setField call yourself. Everything below compiles straight to SceneGraph onChange handlers.

field and derived

field declares a real SceneGraph interface field — public, externally-settable data (a prop), typed as one of string | integer | float | boolean | node | array | assocarray, with the default literal's own shape checked against the declared type at compile time. derived declares a value recomputed automatically whenever anything it reads changes — dependencies are inferred statically, not declared by hand, and a dependency cycle is a compile error. Its own declared type is checked too: the compiler infers a best-effort static type for the expression (literals, field/state/derived references, arithmetic, comparisons, a call to a same-script function or a ClassName(...).method()) and rejects a confirmed mismatch — anything it can't confidently infer (a builtin call, a theme.*/member access, ...) is never flagged, so object/dynamic stays the escape hatch for anything more dynamic than that.

Widget.thr
<script>
field width: integer = 200
field height: integer = 60
derived isWide: boolean = width > 300
derived label: string = describeSize(width, height)

private function describeSize(w: integer, h: integer): string {
  return str(w) + "x" + str(h)
}
</script>

<component>
<Rectangle id="box" width="{width}" height="{height}">
  <Label id="caption" text="{label}" />
</Rectangle>
</component>

state — private, writable, reactive

SceneGraph has no privacy mechanism for interface fields — every <field> is externally reachable, so it can never be genuine component-local state. state is a private m.x member instead — unreachable from outside the component — with its own dedicated write statement, state <name> = <expr>, since a private member has no SceneGraph field observer to auto-fire a change cascade the way a field write does.

FavoriteCounter.thr (excerpt)
state showCelebration: boolean = false
state tierLabel: string = "starting out"

public function addFavorite() {
  newCount = favoriteCount + 1
  store(favoriteCount) = newCount
  state tierLabel = newCount < 3 ? "starting out" : newCount < 10 ? "collector" : "super fan"
}

A plain assignment to a name that happens to match a field's name is not a hidden field write — it's an ordinary new local, shadowing the field for the rest of that function (real BrightScript scoping, deliberately not special-cased). There is currently no syntax for mutating a field from inside the component's own code — use state for anything that needs to be both private and writable.

The global store

A schemaless, zero-declaration global key/value store, built in — no <store> tag to write (that was removed). Read a value with read (one-time snapshot) or watch (reactive — recomputes whenever that top-level key changes), and write with the store(<key>) = <expr> statement.

FavoriteCounter.thr (excerpt)
<script>
watch favoriteCount = store(favoriteCount)
derived favoritesLabel: string = describeFavorites(favoriteCount)

private function describeFavorites(count: integer): string {
  if (count == 1) {
    return "1 favorite"
  }
  return str(count) + " favorites"
}

public function addFavorite() {
  store(favoriteCount) = favoriteCount + 1
}
</script>

<component>
<Label id="label" text="{favoritesLabel}" />
</component>
read initialCount = store(favoriteCount)     ' one-time snapshot, taken once
watch favoriteCount = store(favoriteCount)   ' reactive — recomputes on every store write

A store write can only ever replace a whole top-level key, never a nested path (store(some.value) = 2 is a compile error) — this traces back to real SceneGraph semantics: a field observer only fires on reassignment of the field itself, never on an in-place mutation of something already stored in it, so a nested-path write would either not work or silently fail to notify any watch. theme.a.b access works the same way — see the Theme page.

Array and assocarray defaults

field/state both accept array/assocarray as a declared type, with a literal default — the literal's contents must be pure literals (no identifiers, no calls); use derived for anything computed.

Widget.thr
field tags: array = ["news", "sports"]
field config: assocarray = { retries: 3, timeout: 10 }
state items: array = []

Reference implementation — apps/reactive-state-demo

Every mechanism on this page has a router-mounted, scaled chapter in apps/reactive-state-demo — 4 chapters (/field-and-derived through /array-and-assocarray-defaults), reachable with REWIND/FAST-FORWARD once compiled and sideloaded. Each chapter shows a default, no-customization example alongside a deliberately different, customized one — e.g. GlobalStoreDemo.thr puts a read and a watch on the SAME store key side by side, so pressing "Bump store(demoCount)" visibly updates the watch-bound label while the read-bound one stays frozen at its initial value — the one-time-snapshot-vs-reactive distinction, live. This is the live, compiling reference for the whole page — see findings/reactive-state-demo-app.md for what each chapter covers and findings/demo-app-conventions.md for the app-structure convention it follows.

⚠️ Not (yet) supported

  • ○ Writing to a plain field from inside the component's own code — there's no dedicated grammar for it; use state instead.
  • ○ A nested-path store write (store(a.b) = x) — only a whole top-level key can be reassigned.
  • ○ state's declared type is decorative, not checked against its default literal's shape (unlike field, which is checked) — only array/assocarray *contents* are validated for state.

Exact grammar: GRAMMAR.md. Full feature status: docs/features.md.