useCounter
Create a simple composable function for managing a counter.
Why Do We Need useCounter?
Web applications frequently require functionality to increment and decrement numbers:
- Like button counts
- Shopping cart item quantities
- Pagination page numbers
- Timer or stopwatch seconds
Implementing these with ref and functions every time is redundant. useCounter allows you to reuse counter functionality in a consistent way.
Basic Implementation
Let's start with the simplest version.
import { ref } from "vue";
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
const inc = () => count.value++;
const dec = () => count.value--;
const reset = () => (count.value = initialValue);
return { count, inc, dec, reset };
}Usage
<script setup lang="ts">
import { useCounter } from "./useCounter";
const { count, inc, dec, reset } = useCounter(0);
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="inc">+1</button>
<button @click="dec">-1</button>
<button @click="reset">Reset</button>
</div>
</template>This alone provides basic counter functionality. However, there's still room for improvement.
Feature Extensions
The current implementation only has minimal functionality. Let's add the following features:
- Increment/decrement by arbitrary values (+5, -3, etc.)
- Min/max value constraints
- Set to a specific value
import { ref, computed } from "vue";
export interface UseCounterOptions {
min?: number;
max?: number;
}
export function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
const { min = -Infinity, max = Infinity } = options;
const count = ref(initialValue);
const inc = (delta = 1) => {
count.value = Math.min(max, count.value + delta);
};
const dec = (delta = 1) => {
count.value = Math.max(min, count.value - delta);
};
const set = (value: number) => {
count.value = Math.max(min, Math.min(max, value));
};
const reset = () => {
count.value = initialValue;
};
const isMin = computed(() => count.value <= min);
const isMax = computed(() => count.value >= max);
return { count, inc, dec, set, reset, isMin, isMax };
}What Has Been Improved?
- Arbitrary increment/decrement: By adding the
deltaargument, you can now increment/decrement by arbitrary values. - Value constraints: Added
minandmaxoptions to keep the counter value within a specific range. - Derived state: Added
isMinandisMaxcomputed properties to easily determine if the current value has reached the minimum or maximum.
Usage (Extended Version)
<script setup lang="ts">
import { useCounter } from "./useCounter";
// Constrain to range 0-10
const { count, inc, dec, set, reset, isMin, isMax } = useCounter(5, {
min: 0,
max: 10,
});
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="inc()" :disabled="isMax">+1</button>
<button @click="inc(5)" :disabled="isMax">+5</button>
<button @click="dec()" :disabled="isMin">-1</button>
<button @click="set(7)">Set to 7</button>
<button @click="reset">Reset</button>
<p v-if="isMin">Minimum value reached</p>
<p v-if="isMax">Maximum value reached</p>
</div>
</template>Comparison with VueUse
Let's look at the actual VueUse useCounter implementation.
Key differences:
- MaybeRef type support: VueUse allows passing a
RefasinitialValue - More detailed type definitions: Return value types are clearly defined
- Edge case handling: More robust error handling
VueUse's implementation is more versatile, but the basic structure is the same as what we've created.
Advanced Patterns
With the implementation so far, you understand the basics, but advanced implementations like VueUse use several additional techniques.
shallowRef vs ref
When dealing with primitive values like numbers, there's essentially no difference between ref and shallowRef. However, it's important to understand the concept.
import { ref, shallowRef } from "vue";
// ref: deep reactivity
const deepState = ref({ nested: { value: 0 } });
deepState.value.nested.value++; // reactively updated
// shallowRef: shallow reactivity
const shallowState = shallowRef({ nested: { value: 0 } });
shallowState.value.nested.value++; // NOT reactively updated
shallowState.value = { nested: { value: 1 } }; // This IS reactiveChoice for useCounter:
- Numbers are primitive, so
refis sufficient - However, VueUse sometimes uses
shallowReffor consistency - Performance impact is negligible for primitive values
MaybeRef Type and unref/toValue
One of VueUse's most powerful features is argument flexibility.
import type { MaybeRef } from "vue";
import { ref, unref } from "vue";
// MaybeRef<T> = T | Ref<T>
// Can accept both static and reactive values
export function useCounter(initialValue: MaybeRef<number> = 0) {
// unref extracts the value (if Ref, .value; otherwise, as-is)
const count = ref(unref(initialValue));
// ...
}
// Usage flexibility
const counter1 = useCounter(5); // static value
const initialRef = ref(10);
const counter2 = useCounter(initialRef); // reactive valueunref vs toValue:
import { unref } from "vue";
import { toValue } from "@vueuse/shared";
const value = ref(5);
const getter = () => 10;
unref(value); // 5 (gets Ref value)
unref(getter); // () => 10 (functions remain as-is)
toValue(value); // 5 (gets Ref value)
toValue(getter); // 10 (executes function and gets result)VueUse uses toValue to handle Ref | Getter | Static all at once.
Implementation Example: MaybeRef Support
import type { MaybeRef } from "vue";
import { ref, computed, unref } from "vue";
export interface UseCounterOptions {
min?: number;
max?: number;
}
export function useCounter(initialValue: MaybeRef<number> = 0, options: UseCounterOptions = {}) {
const { min = -Infinity, max = Infinity } = options;
// Extract value with unref, then convert to ref
const count = ref(unref(initialValue));
const inc = (delta = 1) => {
count.value = Math.min(max, count.value + delta);
};
const dec = (delta = 1) => {
count.value = Math.max(min, count.value - delta);
};
const set = (value: number) => {
count.value = Math.max(min, Math.min(max, value));
};
const reset = () => {
count.value = unref(initialValue); // Also extract original value on reset
};
const isMin = computed(() => count.value <= min);
const isMax = computed(() => count.value >= max);
return { count, inc, dec, set, reset, isMin, isMax };
}This implementation enables flexible usage like:
// Static value
const counter1 = useCounter(5);
// Reactive value
const initialCount = ref(10);
const counter2 = useCounter(initialCount);
// Changing initialCount later won't affect counter2
// (because the value was extracted once with unref)Learned Patterns
Through this section, you've learned the following patterns:
1. Basic Composable Function Structure
export function useXxx(initialValue, options = {}) {
// Reactive state
const state = ref(initialValue)
// Operation methods
const method1 = () => { /* ... */ }
const method2 = () => { /* ... */ }
// Derived state (computed)
const derivedState = computed(() => /* calculation based on state */)
// Return state and methods
return { state, method1, method2, derivedState }
}2. Options Argument Pattern
export interface UseXxxOptions {
option1?: Type1;
option2?: Type2;
}
export function useXxx(initialValue, options: UseXxxOptions = {}) {
const { option1 = defaultValue1, option2 = defaultValue2 } = options;
// ...
}3. Value Clamping Pattern
const clampedValue = Math.max(min, Math.min(max, value));4. Derived State with computed
// Define values dependent on state with computed
const isMin = computed(() => count.value <= min);
const isMax = computed(() => count.value >= max);
// Benefits:
// 1. Automatically updates reactively
// 2. Cached for efficiency
// 3. Can be used directly in templates (:disabled="isMax")Summary
useCounter is one of the simplest composable functions, but it's packed with important patterns:
- Managing reactive state (
ref) - Defining derived state (
computed) - Flexibility through options arguments
- Ensuring type safety
- Value constraints and validation
In the next section, we'll learn more complex state management patterns.
Practice: Implement useCounter
Let's implement useCounter using what you've learned so far.
Preparation: Development Environment Setup
If you haven't set up your development environment yet, refer to Setting Up to create the my-vueyouse project.
Step 1: Create Files
In the root of your my-vueyouse project, create the following directory and file.
mkdir -p packages/core/useCounterCreate packages/core/useCounter/index.ts and copy the following skeleton code.
import { ref, computed } from "vue";
export interface UseCounterOptions {
min?: number;
max?: number;
}
export function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
const { min = -Infinity, max = Infinity } = options;
const count = ref(initialValue);
// TODO: Implement inc, dec, set, reset methods
const inc = (delta = 1) => {
// implement here
};
const dec = (delta = 1) => {
// implement here
};
const set = (value: number) => {
// implement here
};
const reset = () => {
// implement here
};
const isMin = computed(() => {
// implement here
return false;
});
const isMax = computed(() => {
// implement here
return false;
});
return { count, inc, dec, set, reset, isMin, isMax };
}Step 2: Implement Methods
Implement the following methods:
- inc(delta): Increment count (clamped to not exceed
max) - dec(delta): Decrement count (clamped to not go below
min) - set(value): Set to specific value (clamped within
minandmaxrange) - reset(): Return to
initialValue - isMin: computed that returns
count.value <= min - isMax: computed that returns
count.value >= max
Hints:
- Combine
Math.min(max, value)andMath.max(min, value)to clamp values - There's an implementation example in the "Feature Extensions" section
Step 3: Export and Use
Export from packages/index.ts.
export { useCounter } from "./core/useCounter";Step 4: Create Demo File
Create src/demos/UseCounterDemo.vue to test.
<script setup lang="ts">
import { useCounter } from "vueyouse";
const { count, inc, dec, set, reset, isMin, isMax } = useCounter(5, {
min: 0,
max: 10,
});
</script>
<template>
<div>
<h2>useCounter Demo</h2>
<p>Count: {{ count }}</p>
<button @click="inc()" :disabled="isMax">+1</button>
<button @click="dec()" :disabled="isMin">-1</button>
<button @click="set(7)">Set to 7</button>
<button @click="reset">Reset</button>
<p v-if="isMin">Minimum value reached</p>
<p v-if="isMax">Maximum value reached</p>
</div>
</template>Step 5: Import in App.vue
Update src/App.vue to display the demo.
<script setup lang="ts">
import UseCounterDemo from "./demos/UseCounterDemo.vue";
</script>
<template>
<div>
<h1>VueYous Demos</h1>
<hr />
<UseCounterDemo />
</div>
</template>Start the dev server (pnpm run dev) and verify it works!
TIP
By separating demo files into src/demos/, you can add new sections without overwriting App.vue. Create independent demo files for each composable and import them in App.vue for display.
Practice Exercises
Try adding the following features:
double()method: Double the count (considering max value constraint)onChangecallback: Function called when value changesget()method: Returns current value (instead of count.value)
Hints
import { ref, computed, watch } from "vue";
export interface UseCounterOptions {
min?: number;
max?: number;
onChange?: (value: number) => void;
}
export function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
const { min = -Infinity, max = Infinity, onChange } = options;
const count = ref(initialValue);
// Existing methods...
const double = () => {
// Set count.value * 2 within max value constraint
set(count.value * 2);
};
const get = () => {
// Return current value
return count.value;
};
// Watch onChange
if (onChange) {
watch(count, (newValue) => onChange(newValue));
}
return { count, inc, dec, set, reset, double, get, isMin, isMax };
}Solution
Complete Implementation
import { ref, computed, watch } from "vue";
export interface UseCounterOptions {
min?: number;
max?: number;
onChange?: (value: number) => void;
}
export function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
const { min = -Infinity, max = Infinity, onChange } = options;
const count = ref(initialValue);
const inc = (delta = 1) => {
count.value = Math.min(max, count.value + delta);
};
const dec = (delta = 1) => {
count.value = Math.max(min, count.value - delta);
};
const set = (value: number) => {
count.value = Math.max(min, Math.min(max, value));
};
const reset = () => {
count.value = initialValue;
};
// 1. double() method
const double = () => {
// Using set method automatically applies max value constraint
set(count.value * 2);
};
// 3. get() method
const get = () => {
return count.value;
};
const isMin = computed(() => count.value <= min);
const isMax = computed(() => count.value >= max);
// 2. onChange callback
if (onChange) {
watch(count, (newValue) => {
onChange(newValue);
});
}
return { count, inc, dec, set, reset, double, get, isMin, isMax };
}Usage
<script setup lang="ts">
import { useCounter } from "./useCounter";
const { count, inc, dec, double, get, reset } = useCounter(5, {
min: 0,
max: 100,
onChange: (value) => {
console.log("Count changed:", value);
},
});
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Current value: {{ get() }}</p>
<button @click="inc()">+1</button>
<button @click="double()">Double</button>
<button @click="reset()">Reset</button>
</div>
</template>Key Points
- double(): By reusing the existing
set()method, we avoid duplicating value constraint logic - onChange: Using
watchautomatically executes the callback whenever the value changes - get(): An alias for
count.value. Convenient for retrieving the value outside templates
