src/
├── types/
│ └── user.ts
├── hooks/
│ └── useCreateUser.ts
├── components/
│ └── CreateUserForm.tsx
│ └── CreateUserForm.test.tsx
└── routes/
└── users/
└── create.tsx // TanStack Router page
export type CreateUserInput = {
name: string;
email: string;
};
export type UserResponse = {
id: string;
name: string;
email: string;
};Purpose: Shared types for frontend/backend sync. trpc
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CreateUserInput, UserResponse } from '@/types/user';
export const createUserMutationFn = async (input: CreateUserInput): Promise<UserResponse> => {
const response = await fetch('/api/createUser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
};
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUserMutationFn,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}Purpose: Reusable TanStack Query hook. trpc
used tanstack form
import React from 'react';
import { useForm } from '@tanstack/react-form';
import { useCreateUser } from '@/hooks/useCreateUser';
import type { CreateUserInput } from '@/types/user';
export function CreateUserForm(): React.JSX.Element {
const mutation = useCreateUser();
const form = useForm<CreateUserInput>({
defaultValues: { name: '', email: '' },
validator: {
onChange: ({ value, fieldApi }) => {
const errors: Record<string, string[]> = {};
// Name validation
if (!value.name.trim()) errors.name = ['Name is required'];
else if (value.name.length > 50) errors.name = ['Name must be ≤ 50 chars'];
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!value.email.trim()) errors.email = ['Email is required'];
else if (!emailRegex.test(value.email)) errors.email = ['Invalid email format'];
else if (value.email.length > 100) errors.email = ['Email too long'];
fieldApi.setFieldMeta('name', { errors: errors.name });
fieldApi.setFieldMeta('email', { errors: errors.email });
},
},
onSubmit: ({ value }) => {
mutation.mutate(value);
},
});
return (
<div className="p-8 max-w-lg mx-auto bg-white rounded-2xl shadow-xl border border-gray-100">
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">Create User</h2>
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }} className="space-y-6">
<form.Field
name="name"
children={(field) => (
<label className="block">
<span className="block text-sm font-semibold text-gray-700 mb-2">Full Name *</span>
<input
className={`w-full px-4 py-3 rounded-xl border-2 transition-all ${
field.state.meta.errors?.length
? 'border-red-300 bg-red-50'
: 'border-gray-200 hover:border-gray-300 focus:border-blue-400 focus:ring-4 focus:ring-blue-100'
} disabled:opacity-50`}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="John Doe"
disabled={mutation.isPending}
/>
{field.state.meta.errors?.[0] && (
<span className="mt-2 block text-sm text-red-600 font-medium">
• {field.state.meta.errors[0]}
</span>
)}
</label>
)}
/>
<form.Field
name="email"
children={(field) => (
<label className="block">
<span className="block text-sm font-semibold text-gray-700 mb-2">Email *</span>
<input
type="email"
className={`w-full px-4 py-3 rounded-xl border-2 transition-all ${
field.state.meta.errors?.length
? 'border-red-300 bg-red-50'
: 'border-gray-200 hover:border-gray-300 focus:border-blue-400 focus:ring-4 focus:ring-blue-100'
} disabled:opacity-50`}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="user@example.com"
disabled={mutation.isPending}
/>
{field.state.meta.errors?.[0] && (
<span className="mt-2 block text-sm text-red-600 font-medium">
• {field.state.meta.errors[0]}
</span>
)}
</label>
)}
/>
<button
type="submit"
disabled={!form.canSubmit || mutation.isPending}
className="w-full h-14 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold rounded-xl shadow-lg hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center"
>
{mutation.isPending ? (
<>
<svg className="animate-spin mr-3 h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12c0-3.042 1.135-5.824 3-7.938v4.55c0 .627-.191 1.451-.558 1.955A8.014 8.014 0 014 12zM8.54 16.945A8.012 8.012 0 0112 16c.563 0 1.116.083 1.64.243A6.978 6.978 0 0112 14c-1.418 0-2.733.422-3.86 1.145z" />
</svg>
Creating...
</>
) : (
'Create User'
)}
</button>
</form>
{/* States */}
{mutation.isSuccess && (
<div className="mt-8 p-6 bg-emerald-50 border-2 border-emerald-200 rounded-xl">
<div className="text-emerald-800">
<h3 className="text-xl font-bold mb-2">✅ User Created!</h3>
<div className="text-sm space-y-1">
<div>ID: <code className="bg-emerald-200 px-2 py-1 rounded font-mono">{mutation.data.id}</code></div>
<div>Name: {mutation.data.name}</div>
<div>Email: {mutation.data.email}</div>
</div>
</div>
</div>
)}
{mutation.isError && (
<div className="mt-8 p-6 bg-red-50 border-2 border-red-200 rounded-xl">
<div className="text-red-800">
<h3 className="text-xl font-bold mb-2">❌ Creation Failed</h3>
<p className="text-sm">{mutation.error.message}</p>
</div>
</div>
)}
</div>
);
}import { CreateUserForm } from '@/components/CreateUserForm';
export default function CreateUserPage() {
return (
<main className="min-h-screen py-12 bg-gradient-to-br from-slate-50 to-blue-50">
<div className="max-w-4xl mx-auto px-4">
<CreateUserForm />
</div>
</main>
);
}import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CreateUserForm } from './CreateUserForm';
import type { CreateUserInput, UserResponse } from '@/types/user';
// Mock fetch globally
global.fetch = vi.fn() as any;
describe('CreateUserForm', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Reset mocks
vi.clearAllMocks();
(global.fetch as any).mockReset();
});
const renderComponent = () =>
render(
<QueryClientProvider client={queryClient}>
<CreateUserForm />
</QueryClientProvider>
);
it('renders form with initial state', () => {
renderComponent();
expect(screen.getByText('Create User')).toBeInTheDocument();
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create user/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create user/i })).not.toBeDisabled();
});
it('shows validation errors on blur/invalid input', async () => {
renderComponent();
// Empty name error
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: '' } });
fireEvent.blur(screen.getByLabelText(/full name/i));
await waitFor(() => {
expect(screen.getByText('• Name is required')).toBeInTheDocument();
});
// Invalid email
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'invalid-email' } });
fireEvent.blur(screen.getByLabelText(/email/i));
await waitFor(() => {
expect(screen.getByText('• Invalid email format')).toBeInTheDocument();
});
});
it('submits successfully and shows success message', async () => {
// Mock successful API response
const mockUser: UserResponse = {
id: 'user-123',
name: 'Sadiq M',
email: 'sadiq@bengaluru.com',
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
} as Response);
renderComponent();
// Fill form
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Sadiq M' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'sadiq@bengaluru.com' } });
// Submit
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
// Loading state
await waitFor(() => {
expect(screen.getByRole('button', { name: /creating.../i })).toBeInTheDocument();
});
// Success state
await waitFor(() => {
expect(screen.getByText('✅ User Created!')).toBeInTheDocument();
expect(screen.getByText('ID: user-123')).toBeInTheDocument();
expect(screen.getByText('Name: Sadiq M')).toBeInTheDocument();
});
// API call verification
expect(global.fetch).toHaveBeenCalledWith(
'/api/createUser',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Sadiq M',
email: 'sadiq@bengaluru.com',
}),
})
);
});
it('handles API error and shows error message', async () => {
// Mock API error
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({ message: 'Email already exists' }),
} as Response);
renderComponent();
// Fill & submit
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Test User' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'test@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
// Error state
await waitFor(() => {
expect(screen.getByText('❌ Creation Failed')).toBeInTheDocument();
expect(screen.getByText('Email already exists')).toBeInTheDocument();
});
});
it('disables submit button during loading and invalid state', async () => {
renderComponent();
// Invalid form - button should be enabled but form invalid
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: '' } });
expect(screen.getByRole('button')).not.toBeDisabled(); // TanStack Form handles
// During API call
(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response);
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Valid' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'valid@test.com' } });
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('button', { name: /creating.../i })).toBeDisabled();
});
});
it('resets validation errors after successful submission', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '123', name: 'Test', email: 'test@test.com' }),
} as Response);
renderComponent();
// Trigger validation error first
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'invalid' } });
fireEvent.blur(screen.getByLabelText(/email/i));
await waitFor(() => expect(screen.getByText('• Invalid email format')).toBeInTheDocument());
// Fix and submit
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Test User' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'test@test.com' } });
fireEvent.click(screen.getByRole('button'));
// Error should be gone after success
await waitFor(() => {
expect(screen.queryByText('• Invalid email format')).not.toBeInTheDocument();
expect(screen.getByText('✅ User Created!')).toBeInTheDocument();
});
});
it('validates max length constraints', async () => {
renderComponent();
// Name too long
const longName = 'A'.repeat(51);
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: longName } });
fireEvent.blur(screen.getByLabelText(/full name/i));
await waitFor(() => {
expect(screen.getByText('• Name must be ≤ 50 chars')).toBeInTheDocument();
});
// Email too long
const longEmail = 'a'.repeat(101) + '@test.com';
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: longEmail } });
fireEvent.blur(screen.getByLabelText(/email/i));
await waitFor(() => {
expect(screen.getByText('• Email too long')).toBeInTheDocument();
});
});
});import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], // Global mocks
globals: true,
},
});npm test
# or
vitest components/CreateUserForm.test.tsx --watchRust Backend: Just POST /api/createUser expecting {name, email}. Scalable, type-safe, no tRPC