Skip to content

Instantly share code, notes, and snippets.

@MdSadiqMd
Last active April 6, 2026 07:41
Show Gist options
  • Select an option

  • Save MdSadiqMd/660d26672cc4f8bbfd7deacf0d9f702a to your computer and use it in GitHub Desktop.

Select an option

Save MdSadiqMd/660d26672cc4f8bbfd7deacf0d9f702a to your computer and use it in GitHub Desktop.

Folder Structure

src/
├── types/
│   └── user.ts
├── hooks/
│   └── useCreateUser.ts
├── components/
│   └── CreateUserForm.tsx
│   └── CreateUserForm.test.tsx
└── routes/
    └── users/
        └── create.tsx  // TanStack Router page

1. Types & Schema: types/user.ts

export type CreateUserInput = {
  name: string;
  email: string;
};

export type UserResponse = {
  id: string;
  name: string;
  email: string;
};

Purpose: Shared types for frontend/backend sync. trpc

2. API Mutation Hook: hooks/useCreateUser.ts

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

3. Form Component: components/CreateUserForm.tsx

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>
  );
}

4. Usage in Page: routes/users/create.tsx

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>
  );
}

Test File: components/CreateUserForm.test.tsx

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();
    });
  });
});

Vitest Config (vitest.config.ts)

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,
  },
});

Run Tests

npm test
# or
vitest components/CreateUserForm.test.tsx --watch

Rust Backend: Just POST /api/createUser expecting {name, email}. Scalable, type-safe, no tRPC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment