Vue.js 18 🟢 Reactive State Creation with ref()
Reactivity is the mechanism that connects a component’s state to its rendered output. When the state changes, the DOM updates. Vue’s reactivity system is built on proxies, and ref() is the function that creates a reactive reference to a value. It is the most common way to declare state in a <script setup> component, and understanding what it returns, how it is unwrapped, and how it interacts with the template is the foundation for everything else in the Composition API.
Key point: ref(value) returns a reactive object with a single property: .value. In the script, the value is read and written through .value. In the template, the ref is auto-unwrapped, so count in the template is the same as count.value in the script. A ref can hold any value type: primitives, objects, arrays, and even other refs. When the value inside a ref changes, Vue triggers a re-render of the components that depend on it.
Why ref() exists
The reactivity problem. A plain JavaScript variable is not reactive. Assigning to it does not notify anyone. A render function that reads the variable does not know when it changes. Vue’s reactivity system wraps the variable in a proxy that intercepts reads and writes, tracks which components depend on it, and triggers re-renders when it changes. ref() is the function that creates this wrapper for a single value.
The primitive problem. JavaScript primitives—numbers, strings, booleans—cannot be wrapped in a proxy. A proxy can only wrap an object. ref() solves this by boxing the primitive in an object with a .value property. The proxy is on the object; the .value property holds the primitive. Reading and writing .value goes through the proxy, which is what makes the primitive reactive.
The unwrapping problem. Writing count.value everywhere is verbose. In the template, Vue auto-unwraps the ref, so count is enough. In the script, the .value is required. The distinction is deliberate: the template is compiled to a render function that accesses .value automatically, while the script is plain JavaScript that must be explicit.
The consistency problem. ref() works for any value type. A number, a string, an object, an array—all are wrapped in a ref in the same way. The .value access is uniform. The reactivity is uniform. This consistency is why ref() is the recommended way to declare state, even for objects that reactive() could also wrap.
The composition problem. A composable function that encapsulates logic returns refs. The component that uses the composable reads and writes the refs. The refs are the interface between the composable and the component. The reactivity travels through the ref, so the component re-renders when the composable’s state changes.
a. Creating and using refs
A ref is created with the ref() function, which is imported from Vue.
<script setup>
import { ref } from 'vue'
const count = ref(0)
const name = ref('Alice')
const items = ref([1, 2, 3])
const user = ref({ id: 1, name: 'Alice' })
</script>
The ref() function takes an initial value and returns a ref object. The ref’s .value property holds the value. The initial value can be any type.
In the script, the value is read and written through .value.
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
console.log(count.value) // 0
increment()
console.log(count.value) // 1
</script>
The count.value++ reads the current value, increments it, and writes it back. The write triggers the reactivity system, which schedules a re-render of any component that depends on count.
For an object or array, the ref wraps the entire object. The .value property is the object, and the object’s properties are accessed normally.
<script setup>
import { ref } from 'vue'
const user = ref({ name: 'Alice', age: 30 })
user.value.name = 'Bob' // reactive
user.value = { name: 'Carol' } // reactive
</script>
Replacing the entire object (user.value = ...) is reactive. Mutating a property (user.value.name = ...) is also reactive, because the object inside the ref is made reactive by the ref’s proxy.
In the template, the ref is auto-unwrapped. The .value is not needed.
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p>
<button @click="count++">Increment</button>
</template>
The template’s {{ count }} is compiled to count.value. The @click="count++" is compiled to count.value++. The unwrapping is automatic, and the template reads and writes the ref as if it were a plain variable.
The auto-unwrapping applies only to top-level refs in the template. A ref nested inside an object is not unwrapped.
<script setup>
import { ref } from 'vue'
const state = {
count: ref(0),
name: 'Alice'
}
</script>
<template>
<p>{{ state.count }}</p> <!-- ref object, not the value -->
<p>{{ state.count.value }}</p> <!-- the value -->
</template>
The state.count is a ref object. The template does not unwrap it because it is not a top-level ref. The .value must be used explicitly. This is why top-level refs are preferred over refs nested in plain objects.
b. Reactivity and the .value access
The reactivity of a ref is triggered by writes to .value. The write goes through the proxy, which notifies the dependency tracking system.
<script setup>
import { ref } from 'vue'
const count = ref(0)
setTimeout(() => {
count.value = 10 // triggers re-render
}, 1000)
</script>
After one second, the count.value is set to 10. The components that read count in their render function are re-rendered. The DOM updates to show 10.
A write of the same value does not trigger a re-render. Vue compares the new value to the old value with Object.is.
<script setup>
const count = ref(0)
count.value = 0 // no re-render
The Object.is comparison means that NaN is equal to NaN, and +0 and -0 are different. This is the same comparison that React uses for state updates.
The .value property is not the only way to access the ref. The unref() function returns the value if the argument is a ref, or the argument itself if it is not.
<script setup>
import { ref, unref } from 'vue'
const count = ref(0)
console.log(unref(count)) // 0
console.log(unref(42)) // 42
</script>
The unref function is useful in composables that accept either a ref or a plain value.
The isRef() function checks whether a value is a ref.
<script setup>
import { ref, isRef } from 'vue'
const count = ref(0)
console.log(isRef(count)) // true
console.log(isRef(42)) // false
</script>
The isRef function is used in composables that need to distinguish between a ref and a plain value.
The shallowRef() function creates a ref that is only reactive at the top level. Mutating a nested property does not trigger a re-render.
<script setup>
import { shallowRef } from 'vue'
const user = shallowRef({ name: 'Alice' })
user.value.name = 'Bob' // no re-render
user.value = { name: 'Bob' } // re-render
</script>
The shallowRef is used for performance when the nested data does not need to be reactive. It is also used for large data structures where the deep reactivity would be expensive.
The triggerRef() function manually triggers the re-render of a shallowRef.
<script setup>
import { shallowRef, triggerRef } from 'vue'
const user = shallowRef({ name: 'Alice' })
user.value.name = 'Bob'
triggerRef(user) // manually trigger the re-render
</script>
The triggerRef is used when the nested data is mutated and the re-render is needed.
c. Refs in composables and toRefs
A composable is a function that returns refs. The component that uses the composable reads and writes the refs, and the reactivity travels through them.
// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}
The composable returns a count ref and two functions. The component uses the composable:
<script setup>
import { useCounter } from './composables/useCounter'
const { count, increment, decrement } = useCounter(10)
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</template>
The count is a ref. The template auto-unwraps it. The increment and decrement are functions that mutate the ref. The reactivity is intact because the ref is returned from the composable and used in the component.
The toRefs() function converts a reactive object into an object of refs. This is useful when a composable returns a reactive object and the component wants to destructure it without losing reactivity.
import { reactive, toRefs } from 'vue'
export function useUser() {
const user = reactive({ name: 'Alice', age: 30 })
return toRefs(user)
}
The component destructures the refs:
<script setup>
import { useUser } from './composables/useUser'
const { name, age } = useUser()
</script>
<template>
<p>{{ name }} is {{ age }}</p>
</template>
The toRefs preserves the reactivity when the reactive object is destructured. Without it, the destructured properties would be plain values that do not update.
The toRef() function creates a ref that is linked to a property of a reactive object.
import { reactive, toRef } from 'vue'
const user = reactive({ name: 'Alice' })
const nameRef = toRef(user, 'name')
nameRef.value = 'Bob' // updates user.name
The toRef is used when a single property of a reactive object needs to be passed as a ref.
A ref can be used in a computed property. The computed reads the ref’s .value and returns a new value. The computed is itself a ref.
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
</script>
<template>
<p>{{ fullName }}</p>
</template>
The fullName is a read-only ref. It updates when firstName or lastName changes. The computed’s .value is accessed in the template through the auto-unwrap.
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: BASIC REF -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p>
</template>
<!-- ============================================ -->
<!-- PART 2: WRITING TO A REF -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
<!-- ============================================ -->
<!-- PART 3: OBJECT REF -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const user = ref({ name: 'Alice', age: 30 })
function birthday() {
user.value.age++
}
</script>
<template>
<p>{{ user.name }} is {{ user.age }}</p>
<button @click="birthday">Birthday</button>
</template>
<!-- ============================================ -->
<!-- PART 4: ARRAY REF -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const items = ref(['a', 'b', 'c'])
function addItem() {
items.value.push('d')
}
</script>
<template>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
<button @click="addItem">Add</button>
</template>
<!-- ============================================ -->
<!-- PART 5: NESTED REF NOT UNWRAPPED -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const state = { count: ref(0) }
</script>
<template>
<p>{{ state.count.value }}</p>
</template>
<!-- ============================================ -->
<!-- PART 6: UNREF -->
<!-- ============================================ -->
<script setup>
import { ref, unref } from 'vue'
const count = ref(0)
console.log(unref(count)) // 0
</script>
<!-- ============================================ -->
<!-- PART 7: SHALLOW REF -->
<!-- ============================================ -->
<script setup>
import { shallowRef } from 'vue'
const user = shallowRef({ name: 'Alice' })
function update() {
user.value.name = 'Bob' // no re-render
user.value = { name: 'Bob' } // re-render
}
</script>
<!-- ============================================ -->
<!-- PART 8: COMPOSABLE WITH REF -->
<!-- ============================================ -->
<script setup>
import { useCounter } from './composables/useCounter'
const { count, increment } = useCounter(10)
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+</button>
</template>
<!-- ============================================ -->
<!-- PART 9: TOREFS -->
<!-- ============================================ -->
<script setup>
import { reactive, toRefs } from 'vue'
const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = toRefs(user)
</script>
<template>
<p>{{ name }} is {{ age }}</p>
</template>
<!-- ============================================ -->
<!-- PART 10: COMPUTED FROM REFS -->
<!-- ============================================ -->
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
</script>
<template>
<p>{{ fullName }}</p>
</template>
The ten parts covered a basic ref, writing to a ref, an object ref, an array ref, a nested ref, unref, shallowRef, a composable with a ref, toRefs, and computed from refs.
Quick Reference
Ref Functions
| Function | Purpose |
|---|---|
ref(value) | Create a reactive ref |
shallowRef(value) | Create a top-level-only reactive ref |
unref(ref) | Get the value |
isRef(value) | Check if a ref |
toRef(obj, key) | Ref linked to an object property |
toRefs(obj) | Object of refs from a reactive object |
triggerRef(ref) | Manually trigger a re-render |
Access Patterns
| Context | Access |
|---|---|
| Script | count.value |
| Template | count (auto-unwrapped) |
| Nested in object | state.count.value |
| In composable | count.value |
Ref Value Types
| Type | Example |
|---|---|
| Number | ref(0) |
| String | ref('Alice') |
| Boolean | ref(true) |
| Array | ref([1, 2, 3]) |
| Object | ref({ id: 1 }) |
| Null | ref(null) |
Reactivity Behavior
| Operation | Reactive |
|---|---|
count.value = 10 | Yes |
count.value++ | Yes |
user.value.name = 'Bob' | Yes |
user.value = {...} | Yes |
items.value.push(x) | Yes |
state.count.value = 10 | Yes |
Best Practices
✅ Do This:
<!-- Use ref for all state -->
const count = ref(0) <!-- ✅ -->
<!-- Use .value in the script -->
count.value++ <!-- ✅ -->
<!-- Use the ref directly in the template -->
{{ count }} <!-- ✅ -->
<!-- Return refs from composables -->
return { count, increment } <!-- ✅ -->
<!-- Use toRefs to destructure reactive objects -->
const { name, age } = toRefs(user) <!-- ✅ -->
<!-- Use shallowRef for large data structures -->
const bigData = shallowRef(loadLargeData()) <!-- ✅ -->
<!-- Use computed for derived values -->
const doubled = computed(() => count.value * 2) <!-- ✅ -->
❌ Don’t Do This:
<!-- Don't forget .value in the script -->
count++ // does not update the ref <!-- ❌ -->
<!-- Don't use .value in the template -->
{{ count.value }} // unnecessary <!-- ⚠️ -->
<!-- Don't destructure a reactive object without toRefs -->
const { name } = user // loses reactivity <!-- ❌ -->
<!-- Don't nest refs in plain objects -->
const state = { count: ref(0) } // not auto-unwrapped <!-- ⚠️ -->
<!-- Don't use ref for values that do not change -->
const PI = ref(3.14) // use a plain constant <!-- ⚠️ -->
<!-- Don't use reactive for primitives -->
const count = reactive(0) // error <!-- ❌ -->
<!-- Don't mutate a ref's value from a different component -->
props.count.value // props are read-only <!-- ❌ -->
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Value does not update | Missing .value | Use count.value |
| Template shows ref object | Used .value in template | Remove .value |
| Destructured state loses reactivity | Used plain destructuring | Use toRefs |
| Nested ref not unwrapped | Ref inside plain object | Use .value |
| Re-render not triggered | Mutated nested in shallowRef | Use ref or triggerRef |
| Props mutated | Ref from props | Emit an event |
Real-World Examples
1. Counter
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
2. Form Input
<script setup>
import { ref } from 'vue'
const name = ref('')
</script>
<template>
<input v-model="name">
<p>{{ name }}</p>
</template>
3. Todo List
<script setup>
import { ref } from 'vue'
const todos = ref([])
const newTodo = ref('')
function addTodo() {
todos.value.push({ id: Date.now(), text: newTodo.value })
newTodo.value = ''
}
</script>
4. User Object
<script setup>
import { ref } from 'vue'
const user = ref({ name: 'Alice', email: 'alice@example.com' })
</script>
<template>
<p>{{ user.name }}</p>
<p>{{ user.email }}</p>
</template>
5. Loading State
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(true)
const data = ref(null)
onMounted(async () => {
const res = await fetch('/api/data')
data.value = await res.json()
loading.value = false
})
</script>
6. Composable
export function useToggle(initial = false) {
const value = ref(initial)
const toggle = () => value.value = !value.value
return { value, toggle }
}
7. Computed from Refs
<script setup>
import { ref, computed } from 'vue'
const items = ref([1, 2, 3])
const total = computed(() => items.value.reduce((a, b) => a + b, 0))
</script>
8. toRefs
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Alice' })
const { count, name } = toRefs(state)
</script>
9. Shallow Ref for Large Data
<script setup>
import { shallowRef } from 'vue'
const chartData = shallowRef(loadChartData())
</script>
10. Trigger Ref
<script setup>
import { shallowRef, triggerRef } from 'vue'
const list = shallowRef([1, 2, 3])
function addItem() {
list.value.push(4)
triggerRef(list)
}
</script>
Visual
Ref Structure
┌─────────────────────────────────────────────────────────────┐
│ const count = ref(0) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Ref object │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ .value: 0 │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Proxy intercepts reads and writes to .value │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Script: count.value │
│ Template: count (auto-unwrapped) │
│ │
└─────────────────────────────────────────────────────────────┘
Script vs Template
┌─────────────────────────────────────────────────────────────┐
│ <script setup> │
│ const count = ref(0) │
│ const doubled = computed(() => count.value * 2) │
│ │
│ console.log(count.value) // 0 │
│ count.value++ // write │
│ </script> │
│ │
│ <template> │
│ {{ count }} <!-- count.value --> │
│ {{ doubled }} <!-- doubled.value --> │
│ <button @click="count++"> <!-- count.value++ --> │
│ </template> │
│ │
│ .value in script. No .value in template. │
│ │
└─────────────────────────────────────────────────────────────┘
Reactivity Trigger
┌─────────────────────────────────────────────────────────────┐
│ const count = ref(0) │
│ │
│ Template reads count.value │
│ │ │
│ ▼ │
│ Dependency tracked: this component depends on count │
│ │ │
│ ▼ │
│ count.value = 10 │
│ │ │
│ ▼ │
│ Proxy intercepts the write │
│ │ │
│ ▼ │
│ Notify dependents │
│ │ │
│ ▼ │
│ Component re-renders │
│ │ │
│ ▼ │
│ DOM updates to show 10 │
│ │
└─────────────────────────────────────────────────────────────┘
Nested Ref Not Unwrapped
┌─────────────────────────────────────────────────────────────┐
│ const state = { count: ref(0) } │
│ │
│ Top-level ref: │
│ const count = ref(0) │
│ Template: {{ count }} → 0 │
│ │
│ Nested ref: │
│ const state = { count: ref(0) } │
│ Template: {{ state.count }} → [object Object] │
│ Template: {{ state.count.value }} → 0 │
│ │
│ Auto-unwrapping applies to top-level refs only. │
│ Nested refs require explicit .value. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | ref(initialValue) |
| Returns | Reactive ref object |
| Access in script | .value |
| Access in template | Auto-unwrapped |
| Value types | Any |
| Reactivity | On .value write |
| Comparison | Object.is |
| Shallow variant | shallowRef |
| Value extraction | unref |
| Ref check | isRef |
| Object to refs | toRefs |
| Property to ref | toRef |
| Manual trigger | triggerRef |
Key takeaways:
ref()creates a reactive reference to a value. The value is stored in the.valueproperty. In the script, it is accessed through.value. In the template, it is auto-unwrapped.- A ref can hold any value type. Primitives, objects, arrays, and other refs. The
.valueaccess is uniform, and the reactivity is uniform. - The reactivity triggers on writes to
.value. Vue compares the new value to the old value withObject.isand re-renders the components that depend on the ref if the value changed. - Auto-unwrapping applies only to top-level refs in the template. A ref nested inside a plain object is not unwrapped. This is why top-level refs are preferred.
shallowRef()creates a ref that is only reactive at the top level. Mutating a nested property does not trigger a re-render. Use it for large data structures where deep reactivity is unnecessary.toRefs()preserves reactivity when destructuring a reactive object. Without it, the destructured properties are plain values that do not update.- Composables return refs. The component that uses the composable reads and writes the refs. The reactivity travels through them, so the component re-renders when the composable’s state changes.
Remember: ref() is the primary tool for declaring reactive state in a Vue component. It wraps a value in a reactive object with a .value property. The script accesses the value through .value; the template auto-unwraps it. The reactivity triggers on writes, and the components that read the ref re-render. Use ref() for primitives and for objects that are replaced or deeply mutated. Use shallowRef() for large data where deep reactivity is expensive. Use toRefs() to destructure reactive objects without losing reactivity. And remember that the .value is the mechanism: the script is plain JavaScript, and the template is a compiled render function that accesses the value for you.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!