MSW Handlers Reference
Last Updated: November 23, 2025
This document covers Mock Service Worker (MSW) handler patterns, including the msw-trpc integration for type-safe tRPC mocking.
Table of Contents
- MSW-tRPC Integration
- Basic Handler Patterns
- Query Handlers
- Mutation Handlers
- Error Handling
- Advanced Patterns
- Best Practices
MSW-tRPC Integration
Benefits
- ✅ Full TypeScript type safety from AppRouter
- ✅ No manual tRPC response format
- ✅ Cleaner syntax
- ✅ Automatic procedure inference
- ✅ Refactoring-safe
Setup
Install:
pnpm add --dev msw-trpc
Configure:
// tests/mocks/handlers/trpc.ts
import { createTRPCMsw } from 'msw-trpc';
import type { AppRouter } from '@/lib/api/server/router';
export const trpcMsw = createTRPCMsw<AppRouter>();
Migration from Manual MSW
Before (Manual MSW):
import { http, HttpResponse } from 'msw';
parameters: {
msw: {
handlers: [
http.get('/api/trpc/user.profile.get', () => {
return HttpResponse.json({
result: {
data: {
id: 'user-123',
name: 'John Doe'
}
},
});
}),
],
},
}
After (msw-trpc):
import { trpcMsw } from '@/tests/mocks/handlers/trpc';
parameters: {
msw: {
handlers: [
trpcMsw.user.profile.get.query(() => ({
id: 'user-123',
name: 'John Doe',
})),
],
},
}
Basic Handler Patterns
Story-Level Handlers
export const WithData: Story = {
parameters: {
msw: {
handlers: [
trpcMsw.user.profile.get.query(() => ({
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
})),
],
},
},
};
Multiple Handlers
export const CompleteProfile: Story = {
parameters: {
msw: {
handlers: [
// User profile
trpcMsw.user.profile.get.query(() => ({
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
})),
// User preferences
trpcMsw.user.preferences.get.query(() => ({
theme: 'dark',
notifications: true,
})),
// Organizations
trpcMsw.organization.list.query(() => [
{ id: 'org-1', name: 'Acme Corp' },
{ id: 'org-2', name: 'Tech Inc' },
]),
],
},
},
};
Query Handlers
Simple Query
trpcMsw.user.profile.get.query(() => ({
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
}))
Query with Input Parameters
trpcMsw.organization.get.query((input) => ({
id: input.id,
name: `Organization ${input.id}`,
createdAt: new Date().toISOString(),
}))
Query Returning Array
trpcMsw.team.list.query(() => [
{ id: 'team-1', name: 'Engineering' },
{ id: 'team-2', name: 'Design' },
{ id: 'team-3', name: 'Product' },
])
Empty Results
trpcMsw.team.list.query(() => [])
Paginated Query
trpcMsw.user.list.query((input) => ({
items: [
{ id: 'user-1', name: 'Alice' },
{ id: 'user-2', name: 'Bob' },
],
nextCursor: input.cursor ? null : 'next-page-cursor',
hasMore: input.cursor ? false : true,
}))
Async/Delayed Response
trpcMsw.data.fetch.query(async () => {
await new Promise(resolve => setTimeout(resolve, 2000));
return { data: 'loaded' };
})
Mutation Handlers
Simple Mutation
trpcMsw.user.profile.update.mutation((input) => ({
success: true,
...input,
}))
Create with Generated ID
trpcMsw.organization.create.mutation((input) => ({
id: `org-${Date.now()}`,
...input,
createdAt: new Date().toISOString(),
}))
Delete Mutation
trpcMsw.team.delete.mutation(() => ({
success: true,
}))
Mutation with Side Effects
trpcMsw.user.profile.update.mutation((input) => {
console.log('Profile updated:', input);
return {
id: input.id,
...input,
updatedAt: new Date().toISOString(),
};
})
Error Handling
Throw Error
trpcMsw.user.profile.get.query(() => {
throw new Error('User not found');
})
TRPCError
import { TRPCError } from '@trpc/server';
trpcMsw.user.profile.get.query(() => {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
})
Conditional Errors
trpcMsw.organization.get.query((input) => {
if (input.id === 'invalid') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid organization ID',
});
}
return {
id: input.id,
name: 'Valid Organization',
};
})
Authentication Errors
trpcMsw.admin.settings.get.query(() => {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Admin access required',
});
})
Validation Errors
trpcMsw.user.profile.update.mutation((input) => {
if (!input.email?.includes('@')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid email address',
});
}
return { success: true, ...input };
})
Advanced Patterns
Stateful Handlers
let callCount = 0;
trpcMsw.data.fetch.query(() => {
callCount++;
return {
data: `Request #${callCount}`,
timestamp: Date.now(),
};
})
Conditional Response
trpcMsw.user.profile.get.query((input) => {
if (input.includeDetails) {
return {
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
bio: 'Software engineer',
location: 'San Francisco',
};
}
return {
id: 'user-123',
name: 'John Doe',
};
})
Random Data
trpcMsw.team.list.query(() => {
const teamCount = Math.floor(Math.random() * 5) + 1;
return Array.from({ length: teamCount }, (_, i) => ({
id: `team-${i}`,
name: `Team ${i + 1}`,
}));
})
Handler Override in Story
export const WithError: Story = {
parameters: {
msw: {
handlers: [
// Override default handler
trpcMsw.user.profile.get.query(() => {
throw new Error('Network error');
}),
],
},
},
};
Realistic Delays
trpcMsw.organization.list.query(async () => {
// Simulate network latency
await new Promise(resolve =>
setTimeout(resolve, 500 + Math.random() * 1000)
);
return [
{ id: 'org-1', name: 'Acme Corp' },
{ id: 'org-2', name: 'Tech Inc' },
];
})
Best Practices
1. Use Realistic Mock Data
// ✅ Good - Realistic data
trpcMsw.user.profile.get.query(() => ({
id: 'user-550e8400',
name: 'Alice Johnson',
email: 'alice.johnson@example.com',
role: 'engineer',
joinedAt: '2024-01-15T10:30:00Z',
}))
// ❌ Avoid - Minimal/unrealistic data
trpcMsw.user.profile.get.query(() => ({
id: '1',
name: 'User',
}))
2. Match Input Types
// ✅ Good - Uses input parameter
trpcMsw.organization.get.query((input) => ({
id: input.id,
name: `Organization ${input.id}`,
}))
// ❌ Avoid - Ignores input
trpcMsw.organization.get.query(() => ({
id: 'org-1',
name: 'Organization',
}))
3. Test Error States
// ✅ Good - Test error handling
export const WithError: Story = {
parameters: {
msw: {
handlers: [
trpcMsw.user.profile.get.query(() => {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}),
],
},
},
};
4. Organize Handlers by Feature
// ✅ Good - Grouped by feature
const userHandlers = [
trpcMsw.user.profile.get.query(() => ({ /* ... */ })),
trpcMsw.user.preferences.get.query(() => ({ /* ... */ })),
];
const orgHandlers = [
trpcMsw.organization.list.query(() => [/* ... */]),
trpcMsw.organization.get.query(() => ({ /* ... */ })),
];
export const CompleteSetup: Story = {
parameters: {
msw: {
handlers: [...userHandlers, ...orgHandlers],
},
},
};
5. Document Complex Handlers
// ✅ Good - Documented behavior
trpcMsw.team.list.query(() => {
// Returns empty array to test empty state UI
return [];
})
// ✅ Good - Complex logic explained
trpcMsw.user.search.query((input) => {
// Simulates search with basic filtering
// Returns max 5 results to test pagination
const allUsers = [/* ... */];
return allUsers
.filter(u => u.name.includes(input.query))
.slice(0, 5);
})
6. Use Type-Safe Responses
// ✅ Good - TypeScript will validate
trpcMsw.user.profile.get.query(() => ({
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
// TypeScript ensures all required fields are present
}))
// ❌ Avoid - Manual typing loses safety
const mockUser: any = {
id: 'user-123',
// Missing required fields
};
7. Reset State Between Tests
// Use lifecycle hooks to reset handler state
export const WithCounter: Story = {
beforeEach() {
callCount = 0; // Reset stateful handler
},
parameters: {
msw: {
handlers: [/* ... */],
},
},
};
Common Patterns
Loading States
export const Loading: Story = {
parameters: {
msw: {
handlers: [
trpcMsw.data.fetch.query(async () => {
// Long delay to show loading state
await new Promise(resolve => setTimeout(resolve, 5000));
return { data: 'loaded' };
}),
],
},
},
};
Empty States
export const NoData: Story = {
parameters: {
msw: {
handlers: [
trpcMsw.team.list.query(() => []),
trpcMsw.user.list.query(() => []),
],
},
},
};
Multiple States in One Story
export const ProgressiveLoading: Story = {
parameters: {
msw: {
handlers: [
// Fast response
trpcMsw.user.profile.get.query(() => ({
id: 'user-123',
name: 'John Doe',
})),
// Slower response
trpcMsw.user.preferences.get.query(async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
return { theme: 'dark' };
}),
// Slowest response
trpcMsw.organization.list.query(async () => {
await new Promise(resolve => setTimeout(resolve, 2000));
return [{ id: 'org-1', name: 'Acme' }];
}),
],
},
},
};
Related Documentation
- Story Structure - Story file structure and configuration
- Testing Patterns - Testing Library and interaction patterns
- Decorators - Story decorator patterns
- Helpers - Reusable story utilities
For task-specific implementation details, see the Storybook integration task files in docs/storybook-integration/.