Reactivity
Reactivity answers: when data changes, how does the UI follow. In Viewfly you will most often use reactive, createSignal, computed, watch, and watchEffect. Think of them as three layers: mutable state, derived values, and side-effect listeners.
Minimal example: input stays in sync
This shows the core loop: the input updates model.text, the render function reads model.text, and the UI reflects the latest value.
import { reactive } from '@viewfly/core'
import { createApp } from '@viewfly/platform-browser'
const model = reactive({ text: 'Hello Viewfly' })
function App() {
return () => (
<div>
<input
value={model.text}
onInput={e => {
model.text = (e.target as HTMLInputElement).value
}}
/>
<p>Current input: {model.text}</p>
</div>
)
}
createApp(<App />).mount(document.getElementById('app')!)Two takeaways: reactive turns a plain object into tracked state; state must be read inside the render function so the framework knows the view depends on it.
Read/write rules
Start with two rules. Read reactive state in render—e.g. put model.text in JSX. Write from events or business logic—e.g. model.text = next.
Common pitfall: do not reassign the reactive binding to a brand-new plain object—you lose the reactive link.
function BadCase() {
let model = reactive({ count: 0 })
const reset = () => {
// Wrong: the variable now points at a plain object
model = { count: 0 }
}
return () => <button onClick={reset}>{model.count}</button>
}
function GoodCase() {
const model = reactive({ count: 0 })
const reset = () => {
// Right: mutate properties on the same reactive object
model.count = 0
}
return () => <button onClick={reset}>{model.count}</button>
}Common patterns
Form field object
When several fields edit together, one reactive object usually fits well.
function ProfileForm() {
const form = reactive({
name: '',
age: 18,
})
return () => (
<section>
<input
value={form.name}
onInput={e => {
form.name = (e.target as HTMLInputElement).value
}}
/>
<button type="button" onClick={() => (form.age += 1)}>
Age +1
</button>
<p>{form.name || 'Empty'} / {form.age}</p>
</section>
)
}Keep display and writes on the same field (e.g. form.name) so it is obvious where input and output meet.
Toggling list items
Lists often need per-row updates. Here each item.done toggles independently.
function TodoList() {
const state = reactive({
items: [
{ id: 1, text: 'Read the docs', done: false },
{ id: 2, text: 'Write examples', done: true },
],
})
return () => (
<ul>
{state.items.map(item => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.done}
onInput={e => {
item.done = (e.target as HTMLInputElement).checked
}}
/>
{item.text}
</label>
</li>
))}
</ul>
)
}Updating the current row inside map stays direct; conceptually you change this row only.
shallowReactive: track only top-level keys
shallowReactive fits when you only care about first-level property changes. Like reactive, it wraps an object, but nested objects are not deep-reactive.
import { shallowReactive } from '@viewfly/core'
function UserPanel() {
const state = shallowReactive({
user: { name: 'A', age: 18 },
version: 0,
})
return () => (
<div>
<p>
{state.user.name} / v{state.version}
</p>
<button
type="button"
onClick={() => {
// Mutating nested fields: updates are not guaranteed
state.user.age += 1
}}
>
Age +1 (deep mutate)
</button>
<button
type="button"
onClick={() => {
// Replacing a top-level field: triggers updates
state.user = { ...state.user, age: state.user.age + 1 }
}}
>
Age +1 (replace user)
</button>
</div>
)
}Remember: shallowReactive cares whether top-level fields change. Assignments like state.user = nextUser refresh the UI; relying on deep mutations such as state.user.name alone is unsafe.
isReactive: detect reactive objects
isReactive(value) tells you whether a value was created with reactive or shallowReactive. Useful in utilities, logging, or branches (“plain object vs reactive”).
import { reactive, shallowReactive, isReactive } from '@viewfly/core'
const deepState = reactive({ count: 0 })
const shallowState = shallowReactive({ count: 0 })
const plain = { count: 0 }
console.log(isReactive(deepState)) // true
console.log(isReactive(shallowState)) // true
console.log(isReactive(plain)) // falseisReactive only identifies values—it does not subscribe or schedule updates. Use it for debugging or branching, not as a replacement for watch.
toRaw: underlying plain object
toRaw(value) returns the raw object behind a reactive proxy. Typical uses: hand data to a third-party library that rejects proxies, or log something closer to the original shape.
import { reactive, toRaw } from '@viewfly/core'
const state = reactive({
user: { name: 'A', age: 18 },
})
const raw = toRaw(state)
console.log(raw === state) // false
console.log(raw.user.name) // AtoRaw is not a deep clone—it exposes the same underlying data. Prefer toRaw for reads, debugging, and bridges; for predictable UI updates, still write through the reactive object (state.xxx = next).
createSignal: getter / setter style
If you prefer a functional read/write API, use createSignal. It returns a signal: read with state(), write with state.set(next). Works for primitives and objects.
import { createSignal } from '@viewfly/core'
function Counter() {
const count = createSignal(0)
return () => (
<button type="button" onClick={() => count.set(count() + 1)}>
{count()}
</button>
)
}When the signal holds an object, a common mistake is mutating inner fields without calling the setter—no update fires.
function WrongCase() {
const user = createSignal({ name: 'A', age: 18 })
return () => (
<button
onClick={() => {
user().age += 1
}}
>
{user().age}
</button>
)
}Build a new object from the current value, then set it.
function RightCase() {
const user = createSignal({ name: 'A', age: 18 })
return () => (
<button
onClick={() => {
const current = user()
user.set({ ...current, age: current.age + 1 })
}}
>
{user().age}
</button>
)
}Rule of thumb: updates must go through set; skipping set skips reactive notifications.
computed: derived values
computed(getter) expresses values derived from other state—e.g. cart total from price × quantity. Read the result via .value.
import { reactive, computed } from '@viewfly/core'
function Cart() {
const state = reactive({
price: 99,
quantity: 2,
})
const total = computed(() => state.price * state.quantity)
return () => (
<div>
<button type="button" onClick={() => state.quantity++}>
Quantity +1
</button>
<p>Total: {total.value}</p>
</div>
)
}Keep computed getters pure—avoid network calls, analytics, and other side effects there so dependencies stay obvious.
watch: explicit sources
watch(trigger, callback) fits when you know exactly what to observe. trigger yields the source; callback(newVal, oldVal) runs on change. It returns a stop function.
import { reactive, watch } from '@viewfly/core'
function SearchBox() {
const model = reactive({ q: '' })
watch(
() => model.q,
(next, prev) => {
console.log('search changed:', prev, '->', next)
},
)
return () => (
<input
value={model.q}
onInput={e => {
model.q = (e.target as HTMLInputElement).value
}}
/>
)
}Great for “value changed → side effect”: fetching, logging, syncing URL params, etc.
watchEffect: auto-tracked side effects
watchEffect(effect) fits when you write the effect first and let the framework collect dependencies. It runs effect once immediately, then reruns whenever reactive state read inside changes. Also returns a stop function.
import { reactive, watchEffect } from '@viewfly/core'
function App() {
const model = reactive({ q: '' })
watchEffect(() => {
document.title = model.q ? `Search: ${model.q}` : 'Search'
})
return () => (
<input
value={model.q}
onInput={e => {
model.q = (e.target as HTMLInputElement).value
}}
/>
)
}If you can name the sources cleanly, prefer watch; when triggers would be noisy, consider watchEffect.
Teardown: inside vs outside components
watch / watchEffect registered in a component body stop automatically when the component is destroyed. Outside components, keep the stop function and call it when done.
import { reactive, watch } from '@viewfly/core'
const store = reactive({ count: 0 })
const stop = watch(
() => store.count,
v => {
console.log('count:', v)
},
)
// Stop manually when no longer needed
stop()Forgetting to stop watchers created outside components is a common leak.
Common pitfalls
1) Read only in the body, not in render
Here model.count is read once in the body (e.g. logged) but not in the view—clicks will not refresh the label:
function BadCase() {
const model = reactive({ count: 0 })
console.log(model.count)
return () => <button onClick={() => model.count++}>+1</button>
}Read inside render so the view depends on the field.
function GoodCase() {
const model = reactive({ count: 0 })
return () => <button onClick={() => model.count++}>{model.count}</button>
}2) Destructuring snapshots
Destructuring grabs a snapshot at that moment—it will not track future model.count changes:
function BadCase() {
const model = reactive({ count: 0 })
const { count } = model
return () => <button onClick={() => model.count++}>{count}</button>
}Use model.count in render instead of destructuring reactive fields early in the body.
function GoodCase() {
const model = reactive({ count: 0 })
return () => <button onClick={() => model.count++}>{model.count}</button>
}