home / skills / fawzymohamed / devops / vue-composables
This skill helps you implement Vue 3 composables with localStorage persistence in Nuxt 4, enabling progress, quizzes, and certificates.
npx playbooks add skill fawzymohamed/devops --skill vue-composablesReview the files below or copy the command above to add this skill to your agents.
---
name: vue-composables
description: Patterns for Vue 3 composables with localStorage persistence in Nuxt 4. Activate when creating composables, working with useProgress, useQuiz, useCertificate, or localStorage.
---
# Vue Composables Patterns (Nuxt 4)
## Activation Triggers
- Creating new composables in `app/composables/` directory
- Working with reactive state management
- Implementing localStorage persistence
- Building useProgress, useQuiz, or useCertificate
## Nuxt 4 Composables Location
```
app/
└── composables/
├── useProgress.ts
├── useQuiz.ts
└── useCertificate.ts
```
## Composable Structure Template
```typescript
// app/composables/useExample.ts
import { ref, computed, readonly } from 'vue'
export function useExample() {
// Private state
const _state = ref<StateType>(initialValue)
// Computed values
const derivedValue = computed(() => {
return _state.value.something
})
// Actions
function doSomething(param: ParamType) {
_state.value = // update logic
}
// Return public API
return {
state: readonly(_state), // Prevent external mutation
derivedValue,
doSomething
}
}
```
## useProgress Implementation
```typescript
// app/composables/useProgress.ts
import type { UserProgress, SubtopicProgress } from '~/data/types'
const STORAGE_KEY = 'devops-lms-progress'
export function useProgress() {
// Use useState for cross-component reactivity (SSR-safe)
const progress = useState<UserProgress>('user-progress', () => {
return loadFromStorage() ?? createDefaultProgress()
})
// Load from localStorage (client-side only)
function loadFromStorage(): UserProgress | null {
if (typeof window === 'undefined') return null
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : null
} catch (e) {
console.warn('Failed to load progress from localStorage', e)
return null
}
}
// Create default progress structure
function createDefaultProgress(): UserProgress {
return {
startedAt: new Date().toISOString(),
phases: {}
}
}
// Save to localStorage
function saveToStorage() {
if (typeof window === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress.value))
} catch (e) {
console.warn('Failed to save progress to localStorage', e)
}
}
// Ensure nested structure exists
function ensureStructure(phaseId: string, topicId: string) {
if (!progress.value.phases[phaseId]) {
progress.value.phases[phaseId] = { topics: {} }
}
if (!progress.value.phases[phaseId].topics[topicId]) {
progress.value.phases[phaseId].topics[topicId] = { subtopics: {} }
}
}
// Mark a subtopic as complete
function markComplete(phaseId: string, topicId: string, subtopicId: string) {
ensureStructure(phaseId, topicId)
progress.value.phases[phaseId].topics[topicId].subtopics[subtopicId] = {
completed: true,
completedAt: new Date().toISOString(),
quizScore: null
}
saveToStorage()
}
// Record quiz score
function recordQuizScore(
phaseId: string,
topicId: string,
subtopicId: string,
score: number
) {
ensureStructure(phaseId, topicId)
const subtopic = progress.value.phases[phaseId].topics[topicId].subtopics[subtopicId]
if (subtopic) {
subtopic.quizScore = score
subtopic.quizCompletedAt = new Date().toISOString()
} else {
progress.value.phases[phaseId].topics[topicId].subtopics[subtopicId] = {
completed: false,
completedAt: null,
quizScore: score,
quizCompletedAt: new Date().toISOString()
}
}
saveToStorage()
}
// Check if specific item is complete
function isComplete(phaseId: string, topicId?: string, subtopicId?: string): boolean {
if (!topicId) {
// Check entire phase
const phase = progress.value.phases[phaseId]
if (!phase) return false
// Would need roadmap data to check all topics
return false
}
if (!subtopicId) {
// Check entire topic
const topic = progress.value.phases[phaseId]?.topics[topicId]
if (!topic) return false
// Would need roadmap data to check all subtopics
return false
}
// Check specific subtopic
return !!progress.value.phases[phaseId]?.topics[topicId]?.subtopics[subtopicId]?.completed
}
// Get completion count
function getCompletedCount(): number {
let count = 0
for (const phase of Object.values(progress.value.phases)) {
for (const topic of Object.values(phase.topics)) {
for (const subtopic of Object.values(topic.subtopics)) {
if (subtopic.completed) count++
}
}
}
return count
}
// Export/Import for data portability
function exportProgress(): string {
return JSON.stringify(progress.value, null, 2)
}
function importProgress(data: string) {
try {
const parsed = JSON.parse(data) as UserProgress
progress.value = parsed
saveToStorage()
return true
} catch {
return false
}
}
// Reset all progress
function resetProgress() {
progress.value = createDefaultProgress()
saveToStorage()
}
return {
progress: readonly(progress),
markComplete,
recordQuizScore,
isComplete,
getCompletedCount,
exportProgress,
importProgress,
resetProgress
}
}
```
## useQuiz Implementation
```typescript
// app/composables/useQuiz.ts
import type { Quiz, QuizQuestion, QuizAnswer } from '~/data/types'
export function useQuiz(quiz: Ref<Quiz> | Quiz) {
const quizData = isRef(quiz) ? quiz : ref(quiz)
const currentIndex = ref(0)
const answers = ref<QuizAnswer[]>([])
const isComplete = ref(false)
const score = ref(0)
const currentQuestion = computed(() =>
quizData.value.questions[currentIndex.value]
)
const totalQuestions = computed(() =>
quizData.value.questions.length
)
const isLastQuestion = computed(() =>
currentIndex.value === totalQuestions.value - 1
)
const isFirstQuestion = computed(() =>
currentIndex.value === 0
)
const passed = computed(() =>
score.value >= quizData.value.passingScore
)
function submitAnswer(selected: string | string[] | boolean) {
answers.value[currentIndex.value] = {
questionIndex: currentIndex.value,
selected
}
}
function nextQuestion() {
if (!isLastQuestion.value) {
currentIndex.value++
}
}
function previousQuestion() {
if (!isFirstQuestion.value) {
currentIndex.value--
}
}
function goToQuestion(index: number) {
if (index >= 0 && index < totalQuestions.value) {
currentIndex.value = index
}
}
function finishQuiz() {
score.value = calculateScore()
isComplete.value = true
}
function calculateScore(): number {
let correct = 0
answers.value.forEach((answer, index) => {
const question = quizData.value.questions[index]
if (isAnswerCorrect(answer, question)) {
correct++
}
})
return Math.round((correct / totalQuestions.value) * 100)
}
function isAnswerCorrect(answer: QuizAnswer, question: QuizQuestion): boolean {
if (!answer) return false
switch (question.type) {
case 'single':
return answer.selected === question.correctAnswer
case 'multiple':
const selected = answer.selected as string[]
const correct = question.correctAnswers!
return (
selected.length === correct.length &&
selected.every(s => correct.includes(s))
)
case 'true-false':
return answer.selected === question.correctAnswer
default:
return false
}
}
function getAnswerForQuestion(index: number): QuizAnswer | undefined {
return answers.value[index]
}
function reset() {
currentIndex.value = 0
answers.value = []
isComplete.value = false
score.value = 0
}
return {
// State (readonly)
currentIndex: readonly(currentIndex),
currentQuestion,
totalQuestions,
isLastQuestion,
isFirstQuestion,
answers: readonly(answers),
isComplete: readonly(isComplete),
score: readonly(score),
passed,
// Actions
submitAnswer,
nextQuestion,
previousQuestion,
goToQuestion,
finishQuiz,
getAnswerForQuestion,
reset
}
}
```
## useCertificate Implementation
```typescript
// app/composables/useCertificate.ts
import type { CertificateData } from '~/data/types'
export function useCertificate() {
const isGenerating = ref(false)
const error = ref<string | null>(null)
function generateCertificateId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2, 8)
return `DEVOPS-${timestamp}-${random}`.toUpperCase()
}
function calculateTotalHours(completedLessons: number): number {
// Assuming average 15 minutes per lesson
return Math.round((completedLessons * 15) / 60)
}
async function generatePDF(data: CertificateData): Promise<Blob | null> {
if (typeof window === 'undefined') return null
isGenerating.value = true
error.value = null
try {
// Dynamic imports for client-side only
const [{ default: html2canvas }, { default: jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf')
])
const element = document.getElementById('certificate-preview')
if (!element) {
throw new Error('Certificate element not found')
}
const canvas = await html2canvas(element, {
scale: 2,
backgroundColor: '#1f2937'
})
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4'
})
const imgWidth = 297 // A4 landscape width
const imgHeight = (canvas.height * imgWidth) / canvas.width
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight)
return pdf.output('blob')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to generate PDF'
return null
} finally {
isGenerating.value = false
}
}
async function downloadCertificate(data: CertificateData) {
const blob = await generatePDF(data)
if (!blob) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `DevOps-Certificate-${data.certificateId}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
return {
isGenerating: readonly(isGenerating),
error: readonly(error),
generateCertificateId,
calculateTotalHours,
generatePDF,
downloadCertificate
}
}
```
## SSR Compatibility Patterns
```typescript
// Always check for window/client-side
function useClientSideFeature() {
const isClient = ref(false)
onMounted(() => {
isClient.value = true
// Now safe to access localStorage, window, etc.
})
return { isClient }
}
// Use useState for SSR-safe shared state
const sharedState = useState('key', () => defaultValue)
// Use useAsyncData for data fetching
const { data } = await useAsyncData('key', () => fetchData())
// Lazy load client-only modules
const loadClientLib = async () => {
if (typeof window === 'undefined') return null
const lib = await import('client-only-lib')
return lib
}
```
## Best Practices
1. **Always use `readonly()` for exposed state** - Prevents accidental mutations
2. **Check `typeof window` before localStorage** - Prevents SSR errors
3. **Use `useState` for cross-component state** - Nuxt's SSR-safe alternative to global refs
4. **Wrap localStorage in try/catch** - Handles quota errors and private browsing
5. **Provide TypeScript types for all returns** - Better DX and error catching
6. **Use `isRef` to handle both ref and raw values** - More flexible composable APIs
7. **Dynamic imports for browser-only libs** - html2canvas, jsPDF, etc.
This skill provides practical patterns for building Vue 3 composables in Nuxt 4 with localStorage persistence and SSR-safe behavior. It focuses on three ready-to-use composables—useProgress, useQuiz, and useCertificate—plus SSR compatibility tips and TypeScript-friendly conventions. Use it to standardize state management, persistence, and client-only features across learning or quiz-style apps.
Each composable encapsulates private reactive state, computed values, and actions, then returns a readonly public API to prevent external mutation. useProgress stores structured progress in useState and mirrors data to localStorage on the client. useQuiz manages question flow, answers, scoring, and completion. useCertificate generates IDs, calculates hours, and builds client-side PDFs using dynamic imports (html2canvas, jsPDF). All composables guard client-only APIs with typeof window checks or onMounted.
How do these composables avoid SSR errors when accessing localStorage or DOM?
They check typeof window or set a client flag in onMounted before using localStorage or DOM APIs, and use useState for SSR-safe shared state.
Can quiz data be passed as either a ref or a plain object?
Yes. The pattern uses isRef to accept either a Ref<Quiz> or a raw Quiz object by wrapping raw values with ref().