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
useMutationand 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.
Figure 1 — TanStack Query flow: useQuery handles reads, useMutation handles writes, and QueryClient keeps the cache in sync.
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.
<!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.
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:
- QueryClientProvider — Creates a
QueryClientinstance and makes it available to every component via React context. This is the core of TanStack Query. - BrowserRouter — Enables client-side routing with the HTML5 History API.
- StrictMode — Activates additional React development checks.
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 calluseQuery,useMutation, oruseQueryClient.<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.
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.
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, andDeptno.- Each field chains validation rules — for example,
Empnomust 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.
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.
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
NavLinkfrom React Router automatically applies anisActiveflag based on the current URL. ThegetNavClassNamehelper uses this to toggle the active CSS class.- The
endprop on the “Employee list” link ensures it only shows as active on the exact/employeespath (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.
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. Themode: 'onBlur'setting means validation runs when a field loses focus.useEffectwithform.reset— WhendefaultValueschange (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 passesreadOnlyEmpno={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.
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 reactiveisLoading,isError, anddataproperties.useMutationfor delete — On success it: (1) removes the individual employee's cached detail query withremoveQueries, and (2) invalidates the list query so it refetches automatically.deleteMutation.variables— TanStack Query exposes the arguments passed tomutate(). 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.
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. TheisPendingflag disables the submit button while the request is in flight.- onSuccess callback: (1)
setQueryDataseeds the new employee into the detail cache for instant display. (2)invalidateQueriesmarks the list as stale for refetch. (3)navigateredirects 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.
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:empnoparameter from the URL. TheparseEmpnohelper validates it.useQuerywithenabled— Theenabled: employeeNumber !== nulloption prevents the query from firing when the route parameter is invalid.- Delete with navigation — After a successful delete,
onSuccessremoves 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.
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 asdefaultValues. When the user submits, theupdateMutationsends modified data via PUT. readOnlyEmpno— The employee number field is locked because the primary key should not change.- onSuccess cache update —
setQueryDataimmediately 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
useQueryand what writes it performs withuseMutation. - Query keys are the backbone of caching — A well-designed key factory like
employeeKeysmakes 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
EmployeeFormcomponent serves both create and edit workflows through props likedefaultValues,readOnlyEmpno, andsubmitLabel. - 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
setQueryDataafter 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.
2. Edit Employee
The edit page loads existing employee data into the form and sends updates through the write API using useMutation.
3. Create Employee
The create page uses the same reusable form component to submit a new employee and automatically refreshes the cached list.