Skip to content

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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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”).

tsx
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)) // false

isReactive 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.

tsx
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) // A

toRaw 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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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:

tsx
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.

tsx
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:

tsx
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.

tsx
function GoodCase() {
  const model = reactive({ count: 0 })
  return () => <button onClick={() => model.count++}>{model.count}</button>
}

Next steps ​

MIT License