Introduction: Why Vue.js Composables Changed Everything
When I first set out to build a real-time speech-to-speech translation service frontend, I had already implemented a React version using hooks. "How different could Vue be?" I thought. Turns out, Vue's Composition API offered a refreshingly elegant approach to managing WebSocket connections, audio streams, and reactive state that made me rethink how I structure real-time applications.
In this article, I'll share the lessons I learned building Vue.js composables for real-time speech services, the challenges I faced, and the patterns that emerged from managing reactive WebSocket connections and audio playback. Whether you're coming from React, Angular, or are a Vue veteran, there's something here for everyone building real-time audio applications.
The Challenge: Real-Time Audio Streaming in the Browser
Before we dive into the code, let's understand what we're building:
- WebSocket Connection Management: Establish and maintain a persistent connection to our FastAPI backend
- Audio Capture: Access the user's microphone and stream audio chunks
- Reactive State: Keep the UI in sync with connection status, transcription results, and errors
- Lifecycle Management: Properly clean up resources when components unmount
- Error Handling: Gracefully handle network failures, permission denials, and API errors
The beauty of Vue's Composition API is that we can encapsulate each of these concerns into reusable, testable composables.
Lesson 1: Composables Are More Than React Hooks with Different Syntax
Coming from React, I initially thought composables were just hooks with a different name. I was wrong. Here's what makes them different:
Vue Composables Have Direct Reactive References
// React Hook - needs useState
const [isConnected, setIsConnected] = useState(false);
// Vue Composable - direct reactive reference
const isConnected = ref(false);
This might seem like a small difference, but it has profound implications:
- No dependency arrays: Vue's reactivity system tracks dependencies automatically
- No stale closures: You're always working with the current value
-
Simpler mental model: Just mutate the
.valueproperty
Lifecycle is More Explicit
// React needs useEffect with cleanup
useEffect(() => {
// Setup
return () => {
// Cleanup
};
}, []);
// Vue has dedicated lifecycle hooks
onUnmounted(() => {
// Cleanup
});
Lesson 2: Building the WebSocket Composable
Let me show you the core useTranscription composable I built. This handles all WebSocket communication with our FastAPI backend.
The Basic Structure
// composables/useTranscription.ts
import { ref, onUnmounted } from 'vue';
import { v4 as uuidv4 } from 'uuid';
export function useTranscription(apiUrl = 'ws://localhost:8000') {
// Reactive state
const ws = ref<WebSocket | null>(null);
const sessionId = ref<string>('');
const isConnected = ref(false);
const isTranscribing = ref(false);
const partialTranscript = ref('');
const finalTranscript = ref('');
const error = ref<string | null>(null);
// Methods will go here...
// Cleanup on unmount
onUnmounted(() => {
disconnect();
});
return {
// State
isConnected,
isTranscribing,
partialTranscript,
finalTranscript,
error,
sessionId,
// Methods
connect,
startTranscription,
sendAudioChunk,
stopTranscription,
disconnect,
};
}
Key Design Decision: Promise-Based Connection
One mistake I made initially was making the connection synchronous. When the UI tried to start recording immediately after connecting, the WebSocket wasn't ready. Here's how I solved it:
const connect = async (): Promise<void> => {
return new Promise((resolve, reject) => {
try {
sessionId.value = uuidv4();
const url = `${apiUrl}/transcribe/${sessionId.value}`;
ws.value = new WebSocket(url);
ws.value.onopen = () => {
console.log('WebSocket connected');
};
ws.value.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.event === 'connected') {
isConnected.value = true;
resolve(); // β
Resolve when truly ready
}
// Handle other messages...
};
ws.value.onerror = (err) => {
error.value = 'WebSocket connection error';
reject(err);
};
// Timeout after 10 seconds
setTimeout(() => {
if (!isConnected.value) {
reject(new Error('Connection timeout'));
}
}, 10000);
} catch (err) {
reject(err);
}
});
};
Why this matters: The UI can now reliably wait for the connection:
// In your component
const handleConnect = async () => {
try {
await connect(); // β
Waits for actual connection
await startTranscription(); // β
Safe to start now
} catch (err) {
console.error('Connection failed:', err);
}
};
Lesson 3: Reactive Message Handling
The beauty of Vue's reactivity shines when handling WebSocket messages. Here's how I structured message handling:
const handleTranscript = (message: TranscriptResult) => {
if (message.type === 'partial') {
partialTranscript.value = message.transcript || '';
} else if (message.type === 'final') {
finalTranscript.value += (message.transcript || '') + ' ';
partialTranscript.value = ''; // Clear partial
}
};
In the template, this just works:
<template>
<div class="transcripts">
<!-- Updates automatically when partialTranscript changes -->
<p class="partial">{{ partialTranscript }}</p>
<!-- Updates automatically when finalTranscript changes -->
<p class="final">{{ finalTranscript }}</p>
</div>
</template>
No manual updates, no forceUpdate, no setState callbacks. Vue's reactivity system handles everything.
Lesson 4: Audio Capture Composable
The second major composable handles microphone access. Here's where browser APIs meet Vue's reactivity:
// composables/useAudioCapture.ts
import { ref, onUnmounted } from 'vue';
export function useAudioCapture() {
const isRecording = ref(false);
const mediaRecorder = ref<MediaRecorder | null>(null);
const audioStream = ref<MediaStream | null>(null);
const startRecording = async (
onAudioData: (data: Blob) => void
): Promise<void> => {
try {
// Request microphone access
audioStream.value = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1, // Mono
sampleRate: 16000, // 16kHz for speech
echoCancellation: true,
noiseSuppression: true,
},
});
mediaRecorder.value = new MediaRecorder(audioStream.value, {
mimeType: 'audio/webm',
audioBitsPerSecond: 16000,
});
// Stream audio chunks
mediaRecorder.value.ondataavailable = (event) => {
if (event.data.size > 0) {
onAudioData(event.data); // Callback to send via WebSocket
}
};
// Capture in 100ms chunks for real-time streaming
mediaRecorder.value.start(100);
isRecording.value = true;
} catch (err) {
console.error('Microphone access denied:', err);
throw err;
}
};
const stopRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop();
isRecording.value = false;
}
// Important: Stop all tracks to release microphone
if (audioStream.value) {
audioStream.value.getTracks().forEach((track) => track.stop());
audioStream.value = null;
}
};
// Automatic cleanup
onUnmounted(() => {
stopRecording();
});
return {
isRecording,
startRecording,
stopRecording,
};
}
Critical Lesson: Resource Cleanup
I learned this the hard way: failing to stop media tracks keeps the microphone active even after the component unmounts. Users see that scary "microphone is active" indicator in their browser, and they panic.
Always stop media tracks:
audioStream.value.getTracks().forEach((track) => track.stop());
Lesson 5: Bringing It Together in a Component
Here's how beautifully these composables work together:
<template>
<div class="transcription-app">
<div class="status">
<span :class="{ active: isConnected }">
{{ isConnected ? 'π’ Connected' : 'π΄ Disconnected' }}
</span>
<span :class="{ active: isRecording }">
{{ isRecording ? 'π€ Recording' : 'βΈοΈ Stopped' }}
</span>
</div>
<div class="controls">
<button @click="handleStart" :disabled="isRecording">
Start Recording
</button>
<button @click="handleStop" :disabled="!isRecording">
Stop Recording
</button>
</div>
<div v-if="error" class="error">β οΈ {{ error }}</div>
<div class="transcripts">
<p v-if="partialTranscript" class="partial">
{{ partialTranscript }}
</p>
<p class="final">{{ finalTranscript }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useTranscription } from '../composables/useTranscription';
import { useAudioCapture } from '../composables/useAudioCapture';
// Composables
const {
isConnected,
isTranscribing,
partialTranscript,
finalTranscript,
error,
connect,
startTranscription,
sendAudioChunk,
stopTranscription,
} = useTranscription('ws://localhost:8000');
const { isRecording, startRecording, stopRecording } = useAudioCapture();
// Auto-connect on mount
onMounted(async () => {
try {
await connect();
} catch (err) {
console.error('Failed to connect:', err);
}
});
// Start recording and transcription
const handleStart = async () => {
try {
await startTranscription({
language_code: 'en-US',
enable_automatic_punctuation: true,
});
await startRecording((audioBlob) => {
sendAudioChunk(audioBlob);
});
} catch (err) {
console.error('Failed to start:', err);
}
};
// Stop everything
const handleStop = async () => {
stopRecording();
await stopTranscription();
};
</script>
<style scoped>
.status span.active {
color: #00ff00;
}
.partial {
color: #888;
font-style: italic;
}
.final {
color: #000;
font-weight: bold;
}
.error {
color: red;
padding: 10px;
background: #ffeeee;
}
</style>
Lesson 6: Composition API vs Options API
You might be wondering: "Why use the Composition API instead of the Options API?"
Here's what I discovered:
Composition API Wins:
- Better code organization: Related logic stays together
- Easier to test: Composables are just functions
- TypeScript support: Inference works beautifully
- Reusability: Share logic across components effortlessly
Options API was Better For:
-
Small, simple components: Sometimes
data()andmethodsare clearer - Learning Vue: The structure is more explicit
- Team familiarity: If your team knows Options API well
For real-time applications with complex state management, the Composition API is the clear winner.
Lesson 7: Error Handling and Edge Cases
Real-world applications need robust error handling. Here are the edge cases I encountered:
1. User Denies Microphone Permission
const handleStart = async () => {
try {
await startRecording(onAudioData);
} catch (err) {
if (err.name === 'NotAllowedError') {
error.value = 'Microphone permission denied. Please allow access.';
} else {
error.value = 'Failed to start recording';
}
}
};
2. WebSocket Connection Lost
ws.value.onclose = (event) => {
isConnected.value = false;
isTranscribing.value = false;
if (!event.wasClean) {
error.value = 'Connection lost. Please refresh and try again.';
}
};
3. Browser Compatibility
const checkBrowserSupport = () => {
if (!('MediaRecorder' in window)) {
throw new Error('MediaRecorder API not supported');
}
if (!('WebSocket' in window)) {
throw new Error('WebSocket API not supported');
}
};
Lesson 8: Performance Optimization
Real-time applications demand excellent performance. Here's what worked:
1. Debounce UI Updates for Partial Transcripts
import { ref, watch } from 'vue';
import { debounce } from 'lodash-es';
const debouncedPartial = ref('');
watch(partialTranscript, debounce((newValue) => {
debouncedPartial.value = newValue;
}, 50));
2. Use shallowRef for Large Objects
import { shallowRef } from 'vue';
// Don't make every property reactive
const allTranscripts = shallowRef<TranscriptResult[]>([]);
3. Lazy Load Audio Components
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
const AudioVisualizer = defineAsyncComponent(
() => import('./AudioVisualizer.vue')
);
</script>
Lesson 9: Testing Composables
The Composition API makes testing a joy. Here's how I test the WebSocket composable:
import { describe, it, expect, vi } from 'vitest';
import { useTranscription } from '../useTranscription';
describe('useTranscription', () => {
it('should connect to WebSocket', async () => {
const { connect, isConnected } = useTranscription();
await connect();
expect(isConnected.value).toBe(true);
});
it('should handle transcript messages', async () => {
const { connect, partialTranscript } = useTranscription();
await connect();
// Simulate WebSocket message
const message = {
event: 'transcript',
type: 'partial',
transcript: 'Hello world',
};
// Emit message...
expect(partialTranscript.value).toBe('Hello world');
});
});
Lesson 10: What I'd Do Differently
Looking back, here's what I'd change:
1. Add Retry Logic Earlier
Instead of failing on connection errors, implement exponential backoff:
const connectWithRetry = async (maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
await connect();
return;
} catch (err) {
if (i === maxRetries - 1) throw err;
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, i) * 1000)
);
}
}
};
2. Use TypeScript from Day One
The type safety caught so many bugs during refactoring.
3. Implement Audio Queueing
For speech-to-speech translation, I should have queued audio output to prevent overlapping playback.
4. Add Connection State Machine
Managing connection states (connecting, connected, disconnecting, disconnected, error) would have simplified the logic.
Comparison: Vue Composables vs React Hooks
Having implemented both, here's my honest comparison:
| Aspect | Vue Composables | React Hooks |
|---|---|---|
| Learning Curve | Gentler for beginners | Steeper (dependency arrays, closures) |
| Reactivity | Automatic, magical β¨ | Manual via setState |
| Ref Management | Explicit .value access |
Implicit |
| Lifecycle | Dedicated hooks | useEffect with deps |
| Code Reuse | Natural and intuitive | Requires careful hook design |
| TypeScript | Excellent inference | Good, but needs more annotations |
| Performance | Fine-grained reactivity | Virtual DOM diffing |
| Debugging | Vue DevTools is amazing | React DevTools is great |
My verdict: For real-time applications, Vue's automatic reactivity saves significant mental overhead. React hooks are more explicit and give you more control, but require more boilerplate.
Real-World Performance Results
Here's what I achieved with this Vue implementation:
- Connection establishment: ~200ms average
- Audio chunk transmission: 100ms intervals, <10ms latency
- UI updates: 60fps even with rapid transcript updates
- Memory footprint: ~15MB for the entire app
- Bundle size: 45KB (gzipped, excluding Vue)
Conclusion: The Joy of Vue Composables
Building real-time speech applications with Vue.js composables has been a revelation. The Composition API's elegant reactivity system, combined with TypeScript's type safety, creates a developer experience that's hard to beat.
Key Takeaways:
- Composables promote clean separation of concerns - each composable handles one responsibility
- Reactivity just works - no manual state updates needed
-
Lifecycle management is explicit -
onUnmountedmakes cleanup obvious - Promise-based patterns integrate seamlessly with async/await
- Testing is straightforward - composables are just functions
If you're building real-time applications in Vue, embrace the Composition API. Your future self (and your team) will thank you.
Top comments (0)