Building an Employee CRUD Application with React and TanStack Query


Introduction

Managing server state in a React application can quickly become complex. You need to handle loading indicators, error states, caching, background refetching, and cache invalidation after mutations. TanStack Query (formerly React Query) solves all of these problems with a declarative, hook-based API.

In this article, we will walk through a complete Employee CRUD application that demonstrates how to:

  • Read a list of employees and individual employee details using useQuery.
  • Create a new employee using useMutation and navigate to the new record.
  • Update an existing employee and refresh the cache automatically.
  • Delete an employee and remove it from the cache.

The project is built with React 19, TypeScript, Vite, React Router v7, React Hook Form, and Zod for schema validation.

Application Architecture

The diagram below illustrates how TanStack Query sits between the React UI and the backend APIs. Read operations flow through useQuery, which loads and caches responses. Write operations (create, update, delete) flow through useMutation, which then triggers a QueryClient refresh loop to keep the cached data in sync.

TanStack Query flow in the employee CRUD app — useQuery handles reads, useMutation handles writes, and QueryClient keeps the cache in sync

Figure 1 — TanStack Query flow: useQuery handles reads, useMutation handles writes, and QueryClient keeps the cache in sync.

Important Concept: TanStack Query separates server state from client state. Instead of storing API responses in a global Redux store, each query is identified by a unique query key and is automatically cached, refetched, and garbage-collected by TanStack Query.

Technology Stack USed for the Article

Library Version Purpose
React 19.2 UI library
TypeScript 6.0 Static typing
Vite 8.0 Dev server and bundler
@tanstack/react-query 5.100 Server-state management (queries & mutations)
react-router-dom 7.14 Client-side routing
react-hook-form 7.74 Performant form handling
@hookform/resolvers 5.2 Bridge between react-hook-form and Zod
zod 4.3 Schema declaration and validation

Project Structure Implemented

tanstackarticle/
├── index.html                    ← HTML entry point
├── vite.config.ts                ← Vite configuration
├── package.json                  ← Dependencies and scripts
├── src/
│   ├── main.tsx                  ← Application bootstrap
│   ├── App.tsx                   ← Route definitions
│   ├── index.css                 ← Global styles
│   ├── App.css                   ← Component styles
│   ├── lib/
│   │   ├── employee-schema.ts    ← Zod schema & TypeScript type
│   │   └── employee-api.ts       ← API functions & query keys
│   ├── components/
│   │   ├── app-layout.tsx        ← Shell layout with navigation
│   │   └── employee-form.tsx     ← Reusable form component
│   └── pages/
│       ├── employee-list-page.tsx    ← List all employees
│       ├── employee-create-page.tsx  ← Create employee
│       ├── employee-details-page.tsx ← View single employee
│       └── employee-edit-page.tsx    ← Edit employee

The index.html

The index.html file is the single HTML page that Vite serves. It contains a <div id="root"> mount point and loads src/main.tsx as a module script.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>tanstackarticle</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

The vite.config.ts

The Vite configuration is minimal. It uses the official @vitejs/plugin-react plugin, which enables JSX/TSX transformation and Fast Refresh during development.

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})

The main.tsx

The main.tsx file wires together the three foundational providers:

  1. QueryClientProvider — Creates a QueryClient instance and makes it available to every component via React context. This is the core of TanStack Query.
  2. BrowserRouter — Enables client-side routing with the HTML5 History API.
  3. StrictMode — Activates additional React development checks.
src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </QueryClientProvider>
  </StrictMode>,
)

How it works

  • new QueryClient() creates a single instance that holds the entire query cache. All default options (stale time, retry behaviour, refetch on window focus, etc.) can be configured here.
  • <QueryClientProvider> places the client into React context so any descendant component can call useQuery, useMutation, or useQueryClient.
  • <BrowserRouter> wraps the app so that <Routes>, <Route>, and navigation hooks work correctly.

Route Definitions in App.tsx

The App component defines the routing table. All employee routes are nested inside an AppLayout route, which provides the shared header and navigation bar.

src/App.tsx
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppLayout } from './components/app-layout'
import { EmployeeCreatePage } from './pages/employee-create-page'
import { EmployeeDetailsPage } from './pages/employee-details-page'
import { EmployeeEditPage } from './pages/employee-edit-page'
import { EmployeeListPage } from './pages/employee-list-page'
import './App.css'

function App() {
  return (
    <Routes>
      <Route element={<AppLayout />}>
        <Route index element={<Navigate to="/employees" replace />} />
        <Route path="/employees" element={<EmployeeListPage />} />
        <Route path="/employees/new" element={<EmployeeCreatePage />} />
        <Route path="/employees/:empno" element={<EmployeeDetailsPage />} />
        <Route path="/employees/:empno/edit" element={<EmployeeEditPage />} />
        <Route
          path="*"
          element={
            <section>
              <span>Not found</span>
              <h2>This route does not exist.</h2>
            </section>
          }
        />
      </Route>
    </Routes>
  )
}

export default App

Route table

Path Component Purpose
/ Redirect Redirects to /employees
/employees EmployeeListPage Lists all employees
/employees/new EmployeeCreatePage Form to create a new employee
/employees/:empno EmployeeDetailsPage Shows a single employee's details
/employees/:empno/edit EmployeeEditPage Form to edit an existing employee
* Inline 404 Catch-all for unknown routes

The layout route pattern (<Route element={<AppLayout />}> with nested children) means that AppLayout renders the shared header and navigation, and its <Outlet /> renders whichever child route matches the current URL.

The lib Folder for Schema and API Layer

The lib/ folder contains two files that form the data layer: a Zod validation schema and the API access functions. These are plain TypeScript modules with no React dependencies, making them easy to test and reuse.

employee-schema.ts for Zod Validation Schema

This file defines the shape of an employee record using Zod. The schema is used both for form validation (via @hookform/resolvers/zod) and as the single source of truth for the EmployeeFormValues TypeScript type.

src/lib/employee-schema.ts
import { z } from 'zod'

export const employeeSchema = z.object({
  Empno: z
    .number({ error: 'Employee number is required.' })
    .int('Employee number must be a whole number.')
    .positive('Employee number must be greater than zero.'),
  Empname: z
    .string()
    .trim()
    .min(2, 'Employee name must be at least 2 characters long.'),
  Designation: z
    .string()
    .trim()
    .min(2, 'Designation must be at least 2 characters long.'),
  Salary: z
    .number({ error: 'Salary is required.' })
    .nonnegative('Salary cannot be negative.'),
  Deptno: z
    .number({ error: 'Department number is required.' })
    .int('Department number must be a whole number.')
    .positive('Department number must be greater than zero.'),
})

export type EmployeeFormValues = z.infer<typeof employeeSchema>

Explanation

  • z.object({...}) defines an object schema with five fields: Empno, Empname, Designation, Salary, and Deptno.
  • Each field chains validation rules — for example, Empno must be a number, an integer, and positive. Custom error messages are provided for each constraint.
  • z.infer<typeof employeeSchema> extracts the TypeScript type automatically, so the schema and the type are always in sync.

employee-api.ts for API Functions and Query Keys

This file contains all the HTTP functions that communicate with the backend REST API, along with the query key factory used by TanStack Query. The application uses two separate API endpoints: a read API for GET requests and a write API for POST, PUT, and DELETE requests.

src/lib/employee-api.ts
export type Employee = {
  Empno: number
  Empname: string
  Designation: string
  Salary: number
  Deptno: number
}

type EmployeeListRecord = Partial<Employee> & {
  EmpNo?: number
  DepartmentNo?: number
}

type EmployeeDetailResponse = {
  Record?: EmployeeListRecord
}

const readApiUrl =
  'https://[READ-URL].net/api/Employee'
const writeApiUrl =
  'https://[WRITE-URL].net/api/Employee'

export const employeeKeys = {
  all: ['employees'] as const,
  detail: (empno: number) => ['employees', empno] as const,
}

async function request<T>(url: string, init?: RequestInit): Promise<T> {
  const response = await fetch(url, {
    ...init,
    headers: {
      Accept: 'application/json',
      ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
      ...init?.headers,
    },
  })

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}.`)
  }

  if (response.status === 204) {
    return undefined as T
  }

  const contentType = response.headers.get('content-type') ?? ''

  if (contentType.includes('application/json')) {
    return (await response.json()) as T
  }

  return (await response.text()) as T
}

function normalizeNumber(value: unknown): number {
  const parsed = Number(value)
  return Number.isFinite(parsed) ? parsed : 0
}

function normalizeText(value: unknown): string {
  return typeof value === 'string' ? value.trim() : ''
}

function normalizeEmployee(record: EmployeeListRecord): Employee {
  return {
    Empno: normalizeNumber(record.Empno ?? record.EmpNo),
    Empname: normalizeText(record.Empname),
    Designation: normalizeText(record.Designation),
    Salary: normalizeNumber(record.Salary),
    Deptno: normalizeNumber(record.Deptno ?? record.DepartmentNo),
  }
}

function hasRecordEnvelope(value: unknown): value is EmployeeDetailResponse {
  return typeof value === 'object' && value !== null && 'Record' in value
}

export async function getEmployees(): Promise<Employee[]> {
  const response = await request<unknown>(readApiUrl)

  if (!Array.isArray(response)) {
    throw new Error('Unexpected employee list response.')
  }

  return response.map((item) => normalizeEmployee(item as EmployeeListRecord))
}

export async function getEmployee(empno: number): Promise<Employee> {
  const response = await request<unknown>(`${readApiUrl}/${empno}`)

  if (hasRecordEnvelope(response) && response.Record) {
    return normalizeEmployee(response.Record)
  }

  if (typeof response === 'object' && response !== null) {
    return normalizeEmployee(response as EmployeeListRecord)
  }

  throw new Error('Unexpected employee detail response.')
}

export async function createEmployee(employee: Employee): Promise<Employee> {
  await request<unknown>(writeApiUrl, {
    method: 'POST',
    body: JSON.stringify(employee),
  })

  return employee
}

export async function updateEmployee(employee: Employee): Promise<Employee> {
  await request<unknown>(`${writeApiUrl}/${employee.Empno}`, {
    method: 'PUT',
    body: JSON.stringify(employee),
  })

  return employee
}

export async function deleteEmployee(empno: number): Promise<number> {
  await request<unknown>(`${writeApiUrl}/${empno}`, {
    method: 'DELETE',
  })

  return empno
}

Explanation

1. The Employee type

A plain TypeScript type that represents a normalized employee record with five fields. This is the shape every component in the app expects.

2. Query Key Factory — employeeKeys

TanStack Query identifies cached data by query keys. The employeeKeys object is a factory that produces consistent keys:

  • employeeKeys.all['employees'] — used for the employee list query.
  • employeeKeys.detail(empno)['employees', empno] — used for individual employee queries.

Because detail keys are prefixed with 'employees', invalidating employeeKeys.all also invalidates all detail queries.

3. The generic request helper

A thin wrapper around the Fetch API that automatically sets Accept and Content-Type headers, throws on non-OK responses, and handles 204 No Content and text responses gracefully.

4. Normalization functions

The backend may return fields with inconsistent casing (e.g., EmpNo vs. Empno). The normalizeEmployee function maps any variation into the canonical Employee shape.

5. CRUD functions

Function HTTP Method Description
getEmployees() GET Fetches all employees from the read API and normalizes the list.
getEmployee(empno) GET Fetches a single employee by number from the read API.
createEmployee(employee) POST Sends a new employee to the write API.
updateEmployee(employee) PUT Sends updated data to the write API.
deleteEmployee(empno) DELETE Removes an employee via the write API.

The components Folder — Shared UI Components

app-layout.tsx — Application Shell Layout

The AppLayout component provides the outer shell — a branded header section with a title, description, and navigation bar. It uses React Router's <Outlet /> to render whichever child route currently matches.

src/components/app-layout.tsx
import { NavLink, Outlet } from 'react-router-dom'

const getNavClassName = ({ isActive }: { isActive: boolean }) =>
  isActive ? 'nav-link nav-link-active' : 'nav-link'

export function AppLayout() {
  return (
    <div className="shell">
      <header className="app-header">
        <div className="brand-block">
          <span className="section-tag">Employee admin</span>
          <h1>TanStack Query CRUD with routed employee screens.</h1>
          <p>
            Read requests use the dedicated employee read API. Create, update,
            and delete actions flow through the write API with shared validation.
          </p>
        </div>

        <nav className="app-nav" aria-label="Primary">
          <NavLink className={getNavClassName} to="/employees" end>
            Employee list
          </NavLink>
          <NavLink className={getNavClassName} to="/employees/new">
            Add employee
          </NavLink>
        </nav>
      </header>

      <main className="page-shell">
        <Outlet />
      </main>
    </div>
  )
}

Explanation

  • NavLink from React Router automatically applies an isActive flag based on the current URL. The getNavClassName helper uses this to toggle the active CSS class.
  • The end prop on the “Employee list” link ensures it only shows as active on the exact /employees path (not on child routes).
  • <Outlet /> is the placeholder where React Router renders the matched child route.

employee-form.tsx — Reusable Employee Form

The EmployeeForm component is a reusable, controlled form used by both the create and edit pages. It uses React Hook Form for performant form state management and the zodResolver to validate inputs against the Zod schema.

src/components/employee-form.tsx
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
  type DefaultValues,
  useForm,
  useWatch,
} from 'react-hook-form'
import {
  type EmployeeFormValues,
  employeeSchema,
} from '../lib/employee-schema'

const emptyFormValues: DefaultValues<EmployeeFormValues> = {
  Empname: '',
  Designation: '',
}

type EmployeeFormProps = {
  title: string
  description: string
  submitLabel: string
  defaultValues?: DefaultValues<EmployeeFormValues>
  isSubmitting: boolean
  serverMessage?: string | null
  serverMessageTone?: 'error' | 'success'
  readOnlyEmpno?: boolean
  onSubmit: (values: EmployeeFormValues) => void
}

export function EmployeeForm({
  title,
  description,
  submitLabel,
  defaultValues,
  isSubmitting,
  serverMessage,
  serverMessageTone = 'success',
  readOnlyEmpno = false,
  onSubmit,
}: EmployeeFormProps) {
  const form = useForm<EmployeeFormValues>({
    resolver: zodResolver(employeeSchema),
    defaultValues: defaultValues ?? emptyFormValues,
    mode: 'onBlur',
  })

  useEffect(() => {
    form.reset(defaultValues ?? emptyFormValues)
  }, [defaultValues, form])

  const preview = useWatch({ control: form.control })

  return (
    <section className="split-grid">
      <section className="page-card">
        <div className="section-heading">
          <div>
            <span className="section-tag">Employee form</span>
            <h2>{title}</h2>
          </div>
          <p>{description}</p>
        </div>

        <form className="form-grid" onSubmit={form.handleSubmit(onSubmit)}>
          <label>
            <span>Employee number</span>
            <input
              type="number"
              placeholder="1001"
              readOnly={readOnlyEmpno}
              {...form.register('Empno', { valueAsNumber: true })}
            />
            {form.formState.errors.Empno ? (
              <small>{form.formState.errors.Empno.message}</small>
            ) : null}
          </label>

          <label>
            <span>Employee name</span>
            <input
              type="text"
              placeholder="Mahesh Sabnis"
              {...form.register('Empname')}
            />
            {form.formState.errors.Empname ? (
              <small>{form.formState.errors.Empname.message}</small>
            ) : null}
          </label>

          <label>
            <span>Designation</span>
            <input
              type="text"
              placeholder="Director"
              {...form.register('Designation')}
            />
            {form.formState.errors.Designation ? (
              <small>{form.formState.errors.Designation.message}</small>
            ) : null}
          </label>

          <label>
            <span>Salary</span>
            <input
              type="number"
              placeholder="90000"
              min="0"
              step="0.01"
              {...form.register('Salary', { valueAsNumber: true })}
            />
            {form.formState.errors.Salary ? (
              <small>{form.formState.errors.Salary.message}</small>
            ) : null}
          </label>

          <label>
            <span>Department number</span>
            <input
              type="number"
              placeholder="10"
              min="1"
              {...form.register('Deptno', { valueAsNumber: true })}
            />
            {form.formState.errors.Deptno ? (
              <small>{form.formState.errors.Deptno.message}</small>
            ) : null}
          </label>

          <button className="button-primary" type="submit"
                  disabled={isSubmitting}>
            {isSubmitting ? 'Saving...' : submitLabel}
          </button>

          {serverMessage ? (
            <p className={`status-banner ${serverMessageTone}`}>
              {serverMessage}
            </p>
          ) : null}
        </form>
      </section>

      <aside className="page-card">
        <div className="section-heading">
          <div>
            <span className="section-tag">Live preview</span>
            <h2>Current payload</h2>
          </div>
          <p>The form values below are typed and validated before
             submission.</p>
        </div>

        <dl className="info-grid">
          <div>
            <dt>Empno</dt>
            <dd>{preview.Empno ?? 'Pending input'}</dd>
          </div>
          <div>
            <dt>Empname</dt>
            <dd>{preview.Empname || 'Pending input'}</dd>
          </div>
          <div>
            <dt>Designation</dt>
            <dd>{preview.Designation || 'Pending input'}</dd>
          </div>
          <div>
            <dt>Salary</dt>
            <dd>{preview.Salary ?? 'Pending input'}</dd>
          </div>
          <div>
            <dt>Deptno</dt>
            <dd>{preview.Deptno ?? 'Pending input'}</dd>
          </div>
        </dl>
      </aside>
    </section>
  )
}

Explanation

  • useForm — Initializes form state with the Zod resolver. The mode: 'onBlur' setting means validation runs when a field loses focus.
  • useEffect with form.reset — When defaultValues change (e.g., after the edit page fetches the employee), the form resets to show the loaded data.
  • useWatch — Subscribes to all form field changes and returns the current values. This powers the Live Preview panel.
  • readOnlyEmpno — When editing, the employee number should not change, so the edit page passes readOnlyEmpno={true}.
  • serverMessage — After a mutation fails, the parent page passes an error message that appears as a styled banner.

The pages Folder — Routed Page Components

Each file in the pages/ folder corresponds to a route. These components combine TanStack Query hooks with the shared EmployeeForm and API functions to implement each CRUD screen.

employee-list-page.tsx — List All Employees (Read)

The list page is the main screen. It fetches all employees using useQuery and renders them as a card grid. Each card has View, Edit, and Delete actions.

src/pages/employee-list-page.tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
  deleteEmployee,
  employeeKeys,
  getEmployees,
} from '../lib/employee-api'

export function EmployeeListPage() {
  const queryClient = useQueryClient()

  const employeesQuery = useQuery({
    queryKey: employeeKeys.all,
    queryFn: getEmployees,
  })

  const deleteMutation = useMutation({
    mutationFn: deleteEmployee,
    onSuccess: async (deletedEmpno) => {
      queryClient.removeQueries({
        queryKey: employeeKeys.detail(deletedEmpno),
      })
      await queryClient.invalidateQueries({
        queryKey: employeeKeys.all,
      })
    },
  })

  const handleDelete = (empno: number) => {
    const confirmed = window.confirm(
      `Delete employee ${empno}? This calls the write API.`,
    )
    if (confirmed) {
      deleteMutation.mutate(empno)
    }
  }

  return (
    <section className="page-card">
      <div className="section-heading">
        <div>
          <span className="section-tag">Read employees</span>
          <h2>Employee directory</h2>
        </div>
        <p>Use the routed list to browse employees.</p>
      </div>

      {employeesQuery.isLoading ? <p>Loading employees...</p> : null}
      {employeesQuery.isError ? (
        <p>Unable to read employees from the API.</p>
      ) : null}

      {employeesQuery.data?.length ? (
        <div className="employee-grid">
          {employeesQuery.data.map((employee) => (
            <article className="employee-card" key={employee.Empno}>
              <h3>{employee.Empname}</h3>
              <p>{employee.Designation}</p>
              <div className="button-row">
                <Link to={`/employees/${employee.Empno}`}>View</Link>
                <Link to={`/employees/${employee.Empno}/edit`}>Edit</Link>
                <button onClick={() => handleDelete(employee.Empno)}>
                  Delete
                </button>
              </div>
            </article>
          ))}
        </div>
      ) : null}
    </section>
  )
}

Explanation

  • useQuery({ queryKey: employeeKeys.all, queryFn: getEmployees }) — The core TanStack Query call. It fetches the employee list, caches the result under the ['employees'] key, and provides reactive isLoading, isError, and data properties.
  • useMutation for delete — On success it: (1) removes the individual employee's cached detail query with removeQueries, and (2) invalidates the list query so it refetches automatically.
  • deleteMutation.variables — TanStack Query exposes the arguments passed to mutate(). This lets the component disable only the specific card's delete button that is in progress.

employee-create-page.tsx — Create Employee (Create)

The create page renders the shared EmployeeForm and wires it to a useMutation that POSTs a new employee. On success, the cache is updated and the user is navigated to the new employee's detail page.

src/pages/employee-create-page.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { EmployeeForm } from '../components/employee-form'
import {
  createEmployee,
  employeeKeys,
} from '../lib/employee-api'
import type { EmployeeFormValues } from '../lib/employee-schema'

export function EmployeeCreatePage() {
  const navigate = useNavigate()
  const queryClient = useQueryClient()

  const createMutation = useMutation({
    mutationFn: createEmployee,
    onSuccess: async (employee) => {
      queryClient.setQueryData(
        employeeKeys.detail(employee.Empno), employee)
      await queryClient.invalidateQueries({
        queryKey: employeeKeys.all,
      })
      navigate(`/employees/${employee.Empno}`)
    },
  })

  const handleSubmit = (values: EmployeeFormValues) => {
    createMutation.mutate(values)
  }

  return (
    <EmployeeForm
      title="Create a new employee"
      description="Submit a new employee through the write API and
                   refresh the routed employee views."
      submitLabel="Create employee"
      isSubmitting={createMutation.isPending}
      serverMessage={
        createMutation.isError
          ? 'The employee could not be created.'
          : null
      }
      serverMessageTone="error"
      onSubmit={handleSubmit}
    />
  )
}

Explanation

  • useMutation({ mutationFn: createEmployee }) — Wraps the API call. The isPending flag disables the submit button while the request is in flight.
  • onSuccess callback: (1) setQueryData seeds the new employee into the detail cache for instant display. (2) invalidateQueries marks the list as stale for refetch. (3) navigate redirects to the new employee's detail page.

employee-details-page.tsx — View Employee Details (Read)

The details page reads a single employee by its :empno route parameter and displays the full record. It also provides Edit and Delete actions.

src/pages/employee-details-page.tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
  deleteEmployee,
  employeeKeys,
  getEmployee,
} from '../lib/employee-api'

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
})

function parseEmpno(value: string | undefined): number | null {
  const parsed = Number(value)
  return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}

export function EmployeeDetailsPage() {
  const navigate = useNavigate()
  const queryClient = useQueryClient()
  const { empno } = useParams()
  const employeeNumber = parseEmpno(empno)

  const employeeQuery = useQuery({
    queryKey: employeeNumber
      ? employeeKeys.detail(employeeNumber)
      : employeeKeys.all,
    queryFn: () => getEmployee(employeeNumber ?? 0),
    enabled: employeeNumber !== null,
  })

  const deleteMutation = useMutation({
    mutationFn: deleteEmployee,
    onSuccess: async (deletedEmpno) => {
      queryClient.removeQueries({
        queryKey: employeeKeys.detail(deletedEmpno),
      })
      await queryClient.invalidateQueries({
        queryKey: employeeKeys.all,
      })
      navigate('/employees')
    },
  })

  if (employeeNumber === null) {
    return (
      <section>
        <span>Invalid employee</span>
        <h2>The employee number in the route is not valid.</h2>
      </section>
    )
  }

  const handleDelete = () => {
    const confirmed = window.confirm(
      `Delete employee ${employeeNumber}?`,
    )
    if (confirmed) {
      deleteMutation.mutate(employeeNumber)
    }
  }

  return (
    <section className="page-card">
      {employeeQuery.isLoading ? <p>Loading...</p> : null}
      {employeeQuery.isError ? <p>Unable to load employee.</p> : null}

      {employeeQuery.data ? (
        <>
          <dl className="info-grid">
            <div><dt>Empno</dt><dd>{employeeQuery.data.Empno}</dd></div>
            <div><dt>Empname</dt><dd>{employeeQuery.data.Empname}</dd></div>
            <div><dt>Designation</dt><dd>{employeeQuery.data.Designation}</dd></div>
            <div><dt>Salary</dt><dd>{currencyFormatter.format(employeeQuery.data.Salary)}</dd></div>
            <div><dt>Deptno</dt><dd>{employeeQuery.data.Deptno}</dd></div>
          </dl>

          <div className="button-row">
            <Link to="/employees">Back to list</Link>
            <Link to={`/employees/${employeeQuery.data.Empno}/edit`}>
              Edit employee
            </Link>
            <button onClick={handleDelete}
                    disabled={deleteMutation.isPending}>
              {deleteMutation.isPending ? 'Deleting...' : 'Delete employee'}
            </button>
          </div>
        </>
      ) : null}
    </section>
  )
}

Explanation

  • useParams() — Extracts the :empno parameter from the URL. The parseEmpno helper validates it.
  • useQuery with enabled — The enabled: employeeNumber !== null option prevents the query from firing when the route parameter is invalid.
  • Delete with navigation — After a successful delete, onSuccess removes the cached detail query, invalidates the list, and navigates back to /employees.
  • Intl.NumberFormat — The salary is formatted as USD currency using the built-in internationalization API.

employee-edit-page.tsx — Edit Employee (Update)

The edit page combines useQuery (to load current data) with useMutation (to send the update). It reuses the same EmployeeForm, passing loaded data as defaultValues and locking the employee number field.

src/pages/employee-edit-page.tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { EmployeeForm } from '../components/employee-form'
import {
  employeeKeys,
  getEmployee,
  updateEmployee,
} from '../lib/employee-api'
import type { EmployeeFormValues } from '../lib/employee-schema'

function parseEmpno(value: string | undefined): number | null {
  const parsed = Number(value)
  return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}

export function EmployeeEditPage() {
  const navigate = useNavigate()
  const queryClient = useQueryClient()
  const { empno } = useParams()
  const employeeNumber = parseEmpno(empno)

  const employeeQuery = useQuery({
    queryKey: employeeNumber
      ? employeeKeys.detail(employeeNumber)
      : employeeKeys.all,
    queryFn: () => getEmployee(employeeNumber ?? 0),
    enabled: employeeNumber !== null,
  })

  const updateMutation = useMutation({
    mutationFn: updateEmployee,
    onSuccess: async (employee) => {
      queryClient.setQueryData(
        employeeKeys.detail(employee.Empno), employee)
      await queryClient.invalidateQueries({
        queryKey: employeeKeys.all,
      })
      navigate(`/employees/${employee.Empno}`)
    },
  })

  if (employeeNumber === null) {
    return (
      <section>
        <span>Invalid employee</span>
        <h2>The employee number in the route is not valid.</h2>
      </section>
    )
  }

  if (employeeQuery.isLoading) {
    return <section><p>Loading employee details for editing...</p></section>
  }

  if (employeeQuery.isError || !employeeQuery.data) {
    return (
      <section>
        <span>Unable to edit</span>
        <h2>The selected employee could not be loaded.</h2>
      </section>
    )
  }

  const handleSubmit = (values: EmployeeFormValues) => {
    updateMutation.mutate(values)
  }

  return (
    <EmployeeForm
      title={`Edit employee ${employeeQuery.data.Empno}`}
      description="Load a single employee from the read API, then send
                   updates through the write API."
      submitLabel="Save changes"
      defaultValues={employeeQuery.data}
      isSubmitting={updateMutation.isPending}
      serverMessage={
        updateMutation.isError
          ? 'The employee could not be updated.'
          : null
      }
      serverMessageTone="error"
      readOnlyEmpno
      onSubmit={handleSubmit}
    />
  )
}

Explanation

  • Query then mutate pattern — The page first loads the employee with useQuery, then passes it into the form as defaultValues. When the user submits, the updateMutation sends modified data via PUT.
  • readOnlyEmpno — The employee number field is locked because the primary key should not change.
  • onSuccess cache updatesetQueryData immediately updates the detail cache so navigating back shows updated data instantly.
  • Guard clauses — Handles invalid empno, loading state, and fetch error before rendering the form.

TanStack Query Patterns Used in This Application

1. Query Key Factory

export const employeeKeys = {
  all:    ['employees'] as const,
  detail: (empno: number) => ['employees', empno] as const,
}

Centralized key management ensures consistency. Since detail keys extend the list key, invalidating ['employees'] also invalidates all ['employees', empno] queries.

2. Declarative Fetching with useQuery

const employeesQuery = useQuery({
  queryKey: employeeKeys.all,
  queryFn: getEmployees,
})

Components declare what data they need, not how to manage loading states or caching. TanStack Query handles all of that automatically.

3. Conditional Queries with enabled

const employeeQuery = useQuery({
  queryKey: employeeKeys.detail(employeeNumber),
  queryFn: () => getEmployee(employeeNumber ?? 0),
  enabled: employeeNumber !== null,
})

The enabled option prevents the query from running until the route parameter is validated.

4. Mutations with Cache Synchronization

const createMutation = useMutation({
  mutationFn: createEmployee,
  onSuccess: async (employee) => {
    queryClient.setQueryData(
      employeeKeys.detail(employee.Empno), employee)
    await queryClient.invalidateQueries({
      queryKey: employeeKeys.all,
    })
    navigate(`/employees/${employee.Empno}`)
  },
})

After a successful mutation, the onSuccess callback performs two cache operations:

  • setQueryData — Optimistically seeds the detail cache so the next page loads instantly.
  • invalidateQueries — Marks the list as stale so it refetches with the latest data.

5. Cache Removal on Delete

onSuccess: async (deletedEmpno) => {
  queryClient.removeQueries({
    queryKey: employeeKeys.detail(deletedEmpno),
  })
  await queryClient.invalidateQueries({
    queryKey: employeeKeys.all,
  })
}

When an employee is deleted, removeQueries completely removes the stale detail entry from the cache, and invalidateQueries triggers a list refresh.

Conclusion

In this article, we built a complete Employee CRUD application using React, TanStack Query, React Hook Form, Zod, and React Router. Here are the key takeaways:

  • TanStack Query eliminates boilerplate — Instead of manually managing loading flags, error states, and caching logic in Redux or Context, each component declaratively states what data it needs with useQuery and what writes it performs with useMutation.
  • Query keys are the backbone of caching — A well-designed key factory like employeeKeys makes it trivial to invalidate, update, or remove cached entries after mutations.
  • Separation of concerns — The API layer handles all HTTP logic and normalization. The schema layer handles validation. Page components only orchestrate queries, mutations, and navigation.
  • Reusable form components — The EmployeeForm component serves both create and edit workflows through props like defaultValues, readOnlyEmpno, and submitLabel.
  • Type safety end-to-end — From the Zod schema to the API functions to the form, TypeScript ensures full type checking throughout the entire application.
  • Instant UI feedback — Using setQueryData after mutations seeds the cache immediately, so navigating to a detail page after creating or editing shows the result without a loading spinner.

This architecture scales well as you add more entities and screens. Each new feature follows the same pattern: define a schema, write API functions with query keys, and compose page components that use useQuery and useMutation hooks. TanStack Query handles the rest.

Code for the article can be downloaded from this link.

Results

Below are screenshots of the running application demonstrating the three main views.

1. Employee List

The list page displays all employees fetched via useQuery in a responsive card grid with View, Edit, and Delete actions.

Employee List Page

2. Edit Employee

The edit page loads existing employee data into the form and sends updates through the write API using useMutation.

Edit Employee Page

3. Create Employee

The create page uses the same reusable form component to submit a new employee and automatically refreshes the cached list.

Create Employee Page

Popular posts from this blog

ASP.NET Core 8: Creating Custom Authentication Handler for Authenticating Users for the Minimal APIs

ASP.NET Core 7: Using PostgreSQL to store Identity Information

Blazor: Using QuickGrid to Perform the CRUD Operations in Blazor WebAssembly Application