Advanced Patterns ​
This page covers advanced usage patterns and techniques for building sophisticated applications with Nuxt Tauri composables.
Composable Composition ​
Creating Custom Composables ​
Build higher-level composables by combining the base ones:
typescript
// composables/useFileManager.ts
export function useFileManager() {
const {
data: files,
pending,
error,
execute: refreshFiles,
} = useTauriInvoke<FileItem[]>("list_files", {}, { immediate: true });
const { emit: emitFileEvent } =
useTauriEvent<FileSystemEvent>("file-event");
const selectedFiles = ref<string[]>([]);
const selectFile = (path: string) => {
if (!selectedFiles.value.includes(path)) {
selectedFiles.value.push(path);
}
};
const deselectFile = (path: string) => {
selectedFiles.value = selectedFiles.value.filter((f) => f !== path);
};
const deleteSelected = async () => {
for (const file of selectedFiles.value) {
await useTauriInvoke("delete_file", { path: file }).execute();
}
selectedFiles.value = [];
await refreshFiles();
};
const createFolder = async (name: string, parentPath?: string) => {
await useTauriInvoke("create_folder", { name, parentPath }).execute();
await refreshFiles();
await emitFileEvent({
type: "folder_created",
path: `${parentPath}/${name}`,
});
};
return {
files: readonly(files),
pending: readonly(pending),
error: readonly(error),
selectedFiles: readonly(selectedFiles),
selectFile,
deselectFile,
deleteSelected,
createFolder,
refreshFiles,
};
}State Management Pattern ​
typescript
// composables/useAppState.ts
interface AppState {
user: User | null;
settings: AppSettings;
notifications: Notification[];
}
export function useAppState() {
const state = ref<AppState>({
user: null,
settings: defaultSettings,
notifications: [],
});
// User management
const { execute: login } = useTauriInvoke("user_login");
const { execute: logout } = useTauriInvoke("user_logout");
const { data: userData, startListening: startUserEvents } =
useTauriEvent<UserEvent>("user-event");
// Settings management
const { execute: saveSettings } = useTauriInvoke("save_settings");
const { data: settingsData, startListening: startSettingsEvents } =
useTauriEvent<SettingsEvent>("settings-event");
// Notification management
const { data: notificationData, startListening: startNotificationEvents } =
useTauriEvent<Notification>("notification");
// Watchers
watch(userData, (event) => {
if (event?.type === "login") {
state.value.user = event.user;
} else if (event?.type === "logout") {
state.value.user = null;
}
});
watch(settingsData, (event) => {
if (event?.type === "updated") {
state.value.settings = {
...state.value.settings,
...event.settings,
};
}
});
watch(notificationData, (notification) => {
if (notification) {
state.value.notifications.unshift(notification);
// Keep last 100 notifications
if (state.value.notifications.length > 100) {
state.value.notifications = state.value.notifications.slice(
0,
100
);
}
}
});
// Actions
const loginUser = async (credentials: LoginCredentials) => {
await login(credentials);
};
const logoutUser = async () => {
await logout();
};
const updateSettings = async (newSettings: Partial<AppSettings>) => {
await saveSettings(newSettings);
};
const dismissNotification = (id: string) => {
state.value.notifications = state.value.notifications.filter(
(n) => n.id !== id
);
};
// Initialize
const initialize = async () => {
await Promise.all([
startUserEvents(),
startSettingsEvents(),
startNotificationEvents(),
]);
};
return {
state: readonly(state),
loginUser,
logoutUser,
updateSettings,
dismissNotification,
initialize,
};
}Advanced Data Patterns ​
Pagination with Infinite Loading ​
vue
<template>
<div class="infinite-list">
<div class="search-bar">
<input
v-model="searchQuery"
placeholder="Search items..."
@input="debouncedSearch"
/>
</div>
<div class="items-container" @scroll="handleScroll" ref="container">
<div v-for="item in allItems" :key="item.id" class="item-card">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<span class="timestamp">{{ formatDate(item.createdAt) }}</span>
</div>
<div v-if="loadingMore" class="loading-more">
Loading more items...
</div>
<div v-if="hasError" class="error">
Failed to load more items
<button @click="loadMore">Retry</button>
</div>
<div v-if="hasReachedEnd" class="end-message">
No more items to load
</div>
</div>
</div>
</template>
<script setup>
import { debounce } from 'lodash-es'
interface ListItem {
id: string
title: string
description: string
createdAt: number
}
interface PaginatedResponse {
items: ListItem[]
hasMore: boolean
nextCursor?: string
}
const searchQuery = ref('')
const allItems = ref<ListItem[]>([])
const currentCursor = ref<string>()
const hasReachedEnd = ref(false)
const loadingMore = ref(false)
const hasError = ref(false)
const container = ref<HTMLElement>()
// Search with debouncing
const {
data: searchResults,
pending: searchPending,
execute: executeSearch
} = useTauriInvoke<PaginatedResponse>('search_items')
// Load more items
const {
data: moreItems,
pending: loadMorePending,
error: loadMoreError,
execute: executeLoadMore
} = useTauriInvoke<PaginatedResponse>('load_more_items')
const debouncedSearch = debounce(async () => {
// Reset state for new search
allItems.value = []
currentCursor.value = undefined
hasReachedEnd.value = false
hasError.value = false
await executeSearch({
query: searchQuery.value,
limit: 20
})
}, 300)
// Handle search results
watch(searchResults, (response) => {
if (response) {
allItems.value = response.items
currentCursor.value = response.nextCursor
hasReachedEnd.value = !response.hasMore
}
})
// Handle load more results
watch(moreItems, (response) => {
if (response) {
allItems.value.push(...response.items)
currentCursor.value = response.nextCursor
hasReachedEnd.value = !response.hasMore
loadingMore.value = false
}
})
// Handle load more errors
watch(loadMoreError, (error) => {
if (error) {
hasError.value = true
loadingMore.value = false
}
})
const loadMore = async () => {
if (loadingMore.value || hasReachedEnd.value) return
loadingMore.value = true
hasError.value = false
await executeLoadMore({
query: searchQuery.value,
cursor: currentCursor.value,
limit: 20
})
}
const handleScroll = () => {
if (!container.value) return
const { scrollTop, scrollHeight, clientHeight } = container.value
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100
if (isNearBottom && !loadingMore.value && !hasReachedEnd.value && !hasError.value) {
loadMore()
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString()
}
// Initial load
onMounted(() => {
debouncedSearch()
})
</script>
<style scoped>
.infinite-list {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.search-bar {
margin-bottom: 2rem;
}
.search-bar input {
width: 100%;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
.items-container {
max-height: 600px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 8px;
}
.item-card {
padding: 1.5rem;
border-bottom: 1px solid #f0f0f0;
}
.item-card:last-child {
border-bottom: none;
}
.item-card h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.item-card p {
margin: 0 0 1rem 0;
color: #666;
}
.timestamp {
font-size: 0.875rem;
color: #999;
}
.loading-more,
.end-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error {
text-align: center;
padding: 2rem;
color: #e74c3c;
}
.error button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>Real-time Collaboration ​
vue
<template>
<div class="collaborative-editor">
<div class="editor-header">
<h2>Collaborative Document Editor</h2>
<div class="connection-status" :class="{ connected: isConnected }">
{{ isConnected ? "Connected" : "Disconnected" }}
</div>
</div>
<div class="users-online">
<h4>Online Users ({{ onlineUsers.length }})</h4>
<div class="user-list">
<div
v-for="user in onlineUsers"
:key="user.id"
class="user-badge"
:style="{ backgroundColor: user.color }"
>
{{ user.name }}
</div>
</div>
</div>
<div class="editor-container">
<textarea
v-model="documentContent"
@input="handleContentChange"
@selectionchange="handleSelectionChange"
class="editor"
placeholder="Start typing..."
/>
<div class="cursors-overlay">
<div
v-for="cursor in otherUserCursors"
:key="cursor.userId"
class="remote-cursor"
:style="{
top: cursor.line * 20 + 'px',
left: cursor.column * 8 + 'px',
borderColor: cursor.color,
}"
>
<span
class="cursor-label"
:style="{ backgroundColor: cursor.color }"
>
{{ cursor.userName }}
</span>
</div>
</div>
</div>
<div class="status-bar">
<span>Lines: {{ lineCount }}</span>
<span>Characters: {{ characterCount }}</span>
<span
>Last saved:
{{ lastSaved ? formatTime(lastSaved) : "Never" }}</span
>
</div>
</div>
</template>
<script setup>
import { debounce } from 'lodash-es'
interface User {
id: string
name: string
color: string
}
interface CursorPosition {
userId: string
userName: string
color: string
line: number
column: number
}
interface DocumentOperation {
type: 'insert' | 'delete' | 'replace'
position: number
content?: string
length?: number
userId: string
timestamp: number
}
interface CollaborationEvent {
type: 'user_joined' | 'user_left' | 'document_operation' | 'cursor_moved' | 'document_saved'
data: any
}
const documentContent = ref('')
const onlineUsers = ref<User[]>([])
const otherUserCursors = ref<CursorPosition[]>([])
const isConnected = ref(false)
const lastSaved = ref<number>()
// Document operations
const { execute: sendOperation } = useTauriInvoke('send_document_operation')
const { execute: saveDocument } = useTauriInvoke('save_document')
// Real-time collaboration events
const {
data: collaborationEvent,
startListening,
stopListening,
emit: emitEvent
} = useTauriEvent<CollaborationEvent>('collaboration')
// Load initial document
const {
data: initialDocument,
execute: loadDocument
} = useTauriInvoke<{ content: string, users: User[] }>('load_document', { documentId: 'doc1' }, { immediate: true })
// Watch for document load
watch(initialDocument, (doc) => {
if (doc) {
documentContent.value = doc.content
onlineUsers.value = doc.users
isConnected.value = true
}
})
// Handle collaboration events
watch(collaborationEvent, (event) => {
if (!event) return
switch (event.type) {
case 'user_joined':
if (!onlineUsers.value.find(u => u.id === event.data.id)) {
onlineUsers.value.push(event.data)
}
break
case 'user_left':
onlineUsers.value = onlineUsers.value.filter(u => u.id !== event.data.userId)
otherUserCursors.value = otherUserCursors.value.filter(c => c.userId !== event.data.userId)
break
case 'document_operation':
applyOperation(event.data)
break
case 'cursor_moved':
updateUserCursor(event.data)
break
case 'document_saved':
lastSaved.value = event.data.timestamp
break
}
})
const applyOperation = (operation: DocumentOperation) => {
// Don't apply our own operations
if (operation.userId === getCurrentUserId()) return
const content = documentContent.value
switch (operation.type) {
case 'insert':
documentContent.value =
content.slice(0, operation.position) +
operation.content +
content.slice(operation.position)
break
case 'delete':
documentContent.value =
content.slice(0, operation.position) +
content.slice(operation.position + (operation.length || 0))
break
case 'replace':
documentContent.value =
content.slice(0, operation.position) +
operation.content +
content.slice(operation.position + (operation.length || 0))
break
}
}
const updateUserCursor = (cursorData: CursorPosition) => {
const existingIndex = otherUserCursors.value.findIndex(c => c.userId === cursorData.userId)
if (existingIndex !== -1) {
otherUserCursors.value[existingIndex] = cursorData
} else {
otherUserCursors.value.push(cursorData)
}
}
const debouncedContentChange = debounce(async (newContent: string, oldContent: string) => {
// Calculate the operation
const operation = calculateOperation(oldContent, newContent)
if (operation) {
await sendOperation(operation)
}
}, 200)
const handleContentChange = (event: Event) => {
const target = event.target as HTMLTextAreaElement
const oldContent = documentContent.value
const newContent = target.value
documentContent.value = newContent
debouncedContentChange(newContent, oldContent)
}
const handleSelectionChange = debounce(async (event: Event) => {
const target = event.target as HTMLTextAreaElement
const { selectionStart } = target
const content = target.value
// Calculate line and column
const beforeCursor = content.slice(0, selectionStart)
const line = beforeCursor.split('\n').length - 1
const column = beforeCursor.split('\n').pop()?.length || 0
await emitEvent({
type: 'cursor_moved',
data: {
userId: getCurrentUserId(),
userName: getCurrentUserName(),
color: getCurrentUserColor(),
line,
column
}
})
}, 100)
const calculateOperation = (oldContent: string, newContent: string): DocumentOperation | null => {
// Simple diff algorithm - in production, use a proper diff library
if (newContent.length > oldContent.length) {
// Content was inserted
for (let i = 0; i < Math.min(oldContent.length, newContent.length); i++) {
if (oldContent[i] !== newContent[i]) {
return {
type: 'insert',
position: i,
content: newContent.slice(i, i + (newContent.length - oldContent.length)),
userId: getCurrentUserId(),
timestamp: Date.now()
}
}
}
// Insertion at the end
return {
type: 'insert',
position: oldContent.length,
content: newContent.slice(oldContent.length),
userId: getCurrentUserId(),
timestamp: Date.now()
}
} else if (newContent.length < oldContent.length) {
// Content was deleted
for (let i = 0; i < Math.min(oldContent.length, newContent.length); i++) {
if (oldContent[i] !== newContent[i]) {
return {
type: 'delete',
position: i,
length: oldContent.length - newContent.length,
userId: getCurrentUserId(),
timestamp: Date.now()
}
}
}
// Deletion at the end
return {
type: 'delete',
position: newContent.length,
length: oldContent.length - newContent.length,
userId: getCurrentUserId(),
timestamp: Date.now()
}
}
return null
}
const lineCount = computed(() => documentContent.value.split('\n').length)
const characterCount = computed(() => documentContent.value.length)
// Auto-save
const debouncedSave = debounce(async () => {
await saveDocument({ content: documentContent.value })
}, 2000)
watch(documentContent, debouncedSave)
// Helper functions (these would come from your auth system)
const getCurrentUserId = () => 'current-user-id'
const getCurrentUserName = () => 'Current User'
const getCurrentUserColor = () => '#3498db'
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString()
}
// Lifecycle
onMounted(async () => {
await startListening()
})
onUnmounted(() => {
stopListening()
})
</script>
<style scoped>
.collaborative-editor {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.connection-status {
padding: 0.5rem 1rem;
border-radius: 20px;
background: #e74c3c;
color: white;
font-size: 0.875rem;
}
.connection-status.connected {
background: #27ae60;
}
.users-online {
margin-bottom: 1rem;
}
.user-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.user-badge {
padding: 0.25rem 0.75rem;
border-radius: 15px;
color: white;
font-size: 0.875rem;
font-weight: bold;
}
.editor-container {
position: relative;
margin-bottom: 1rem;
}
.editor {
width: 100%;
height: 400px;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
font-family: "Courier New", monospace;
font-size: 14px;
line-height: 20px;
resize: vertical;
}
.cursors-overlay {
position: absolute;
top: 1rem;
left: 1rem;
pointer-events: none;
z-index: 10;
}
.remote-cursor {
position: absolute;
width: 2px;
height: 20px;
border-left: 2px solid;
animation: blink 1s infinite;
}
.cursor-label {
position: absolute;
top: -20px;
left: -5px;
padding: 2px 6px;
font-size: 10px;
color: white;
border-radius: 3px;
white-space: nowrap;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.status-bar {
display: flex;
gap: 2rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.875rem;
color: #666;
}
</style>Performance Optimization ​
Command Caching ​
typescript
// composables/useCachedInvoke.ts
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
const cache = new Map<string, CacheEntry<any>>();
export function useCachedInvoke<T>(
command: string,
args?: Record<string, unknown>,
options: { immediate?: boolean; ttl?: number } = {}
) {
const { ttl = 300000 } = options; // Default 5 minutes
const cacheKey = `${command}:${JSON.stringify(args)}`;
const getCachedData = (): T | null => {
const entry = cache.get(cacheKey);
if (entry && Date.now() - entry.timestamp < entry.ttl) {
return entry.data;
}
return null;
};
const setCachedData = (data: T) => {
cache.set(cacheKey, {
data,
timestamp: Date.now(),
ttl,
});
};
const cachedData = getCachedData();
const initialData = ref<T | null>(cachedData);
const {
data: fetchedData,
pending,
error,
execute,
} = useTauriInvoke<T>(command, args, {
immediate: options.immediate && !cachedData,
});
// Update cache when new data is fetched
watch(fetchedData, (newData) => {
if (newData !== null) {
setCachedData(newData);
initialData.value = newData;
}
});
const refresh = async () => {
cache.delete(cacheKey);
await execute();
};
return {
data: computed(() => fetchedData.value ?? initialData.value),
pending,
error,
execute,
refresh,
};
}Batch Operations ​
typescript
// composables/useBatchOperations.ts
export function useBatchOperations() {
const pendingOperations = ref<
Array<{
command: string;
args: any;
resolve: Function;
reject: Function;
}>
>([]);
const isProcessing = ref(false);
const { execute: executeBatch } = useTauriInvoke(
"execute_batch_operations"
);
const addOperation = <T>(command: string, args?: any): Promise<T> => {
return new Promise((resolve, reject) => {
pendingOperations.value.push({ command, args, resolve, reject });
processBatch();
});
};
const processBatch = debounce(async () => {
if (isProcessing.value || pendingOperations.value.length === 0) return;
isProcessing.value = true;
const operations = [...pendingOperations.value];
pendingOperations.value = [];
try {
const results = await executeBatch({
operations: operations.map((op) => ({
command: op.command,
args: op.args,
})),
});
operations.forEach((op, index) => {
op.resolve(results[index]);
});
} catch (error) {
operations.forEach((op) => {
op.reject(error);
});
} finally {
isProcessing.value = false;
}
}, 50);
return {
addOperation,
isProcessing: readonly(isProcessing),
};
}Testing Patterns ​
Mocking for Unit Tests ​
typescript
// tests/mocks/tauriMocks.ts
export const mockUseTauriInvoke = <T>(mockData: T, mockError?: Error) => {
const data = ref<T | null>(mockData);
const pending = ref(false);
const error = ref<Error | null>(mockError || null);
const execute = vi.fn().mockImplementation(async () => {
pending.value = true;
await new Promise((resolve) => setTimeout(resolve, 100));
pending.value = false;
if (mockError) {
error.value = mockError;
} else {
data.value = mockData;
}
});
return {
data: readonly(data),
pending: readonly(pending),
error: readonly(error),
execute,
refresh: execute,
};
};
export const mockUseTauriEvent = <T>() => {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const startListening = vi.fn();
const stopListening = vi.fn();
const emit = vi.fn();
// Simulate receiving an event
const simulateEvent = (eventData: T) => {
data.value = eventData;
};
return {
data: readonly(data),
error: readonly(error),
startListening,
stopListening,
emit,
simulateEvent,
};
};These advanced patterns demonstrate how to build sophisticated, production-ready applications using Nuxt Tauri composables. They cover state management, real-time collaboration, performance optimization, and testing strategies.
Next Steps ​
- Error Handling - Comprehensive error handling strategies
- API Reference - Detailed API documentation