Savvi Studio

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

  1. MSW-tRPC Integration
  2. Basic Handler Patterns
  3. Query Handlers
  4. Mutation Handlers
  5. Error Handling
  6. Advanced Patterns
  7. 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' }];
        }),
      ],
    },
  },
};


For task-specific implementation details, see the Storybook integration task files in docs/storybook-integration/.