A production-ready React template built with TypeScript, React Router, TanStack Query, and feature-based architecture.
This template follows a feature-based architecture with clear separation of concerns. See STRUCTURE.md for the complete folder structure.
- What: Group code by business domain (e.g.,
auth/,users/,products/) - Why: As your app grows, features are easier to locate, modify, and remove. New developers can understand one feature at a time without navigating the entire codebase.
- What: Each layer has a clear responsibility (API, components, hooks, state)
- Why: Changes in one layer don't cascade through the app. You can swap your API client, UI library, or state management without touching business logic.
- What: Common components, hooks, and utilities live in
shared/ - Why: Prevents code duplication and ensures consistency. Update a Button component once, it reflects everywhere.
- What: Add new features by creating new folders, not refactoring existing code
- Why: Teams can work in parallel on different features without merge conflicts. Onboarding is faster when structure is predictable.
- What: Tests live next to the code they test (
Component.tsx→Component.test.tsx) - Why: When you modify code, the test is right there. Reduces context switching and makes TDD natural.
- What: Import directly from source files, not from
index.tsre-exports - Why: Better tree-shaking, clearer dependencies, and no circular import issues. Your IDE can navigate to the actual file instantly.
We use React Router v6 with loaders and TanStack Query for data management.
// src/app/router/routes.tsx
const getRoutes = (queryClient: QueryClient) =>
createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
element: <ProtectedLayout />,
loader: requireAuthLoader, // ← Auth check happens here
children: [
{
index: true,
element: <ExamplePage />,
loader: examplePageLoader(queryClient), // ← Data prefetch
},
],
},
],
},
]);Flow:
- User navigates to
/ requireAuthLoaderruns → checks authentication, redirects if neededexamplePageLoaderruns → prefetches data<ExamplePage />renders with data already available
The recommended pattern is to centralize query definitions using queryOptions() from TanStack Query. This ensures type safety and reusability across loaders and components.
src/pages/ExamplePage/
├── ExamplePage.tsx # Component (page entry point)
├── ExamplePage.loader.ts # Route loader
src/features/example/ # Feature-specific logic
├── api/
│ └── example.queries.ts # ← Query definitions (NEW)
└── hooks/
└── useExampleMutation.ts # Mutation hook
// src/features/example/api/example.queries.ts
import { queryOptions } from '@tanstack/react-query';
export type ExampleData = {
message: string;
};
export const exampleQueryKey = ['exampleData'] as const;
export const exampleQueryOptions = queryOptions<ExampleData>({
queryKey: exampleQueryKey,
queryFn: async () => {
// Your API call here
return await apiClient.get('/example-data');
},
});- Type Safety: Types are inferred automatically in both loaders and components
- DRY: Define queryKey and queryFn once, use everywhere
- Consistency: Same query logic in prefetching and client-side fetching
- Easy Refactoring: Change the API call in one place
- Better Testing: Mock the query options, not individual functions
Loaders run before a route renders and prefetch data using the centralized query options.
// src/pages/ExamplePage/ExamplePage.loader.ts
import { QueryClient } from '@tanstack/react-query';
import { exampleQueryOptions, type ExampleData } from '../../features/example/api/example.queries';
export const examplePageLoader = (queryClient: QueryClient) => {
return async ({ request, params }): Promise<ExampleData> => {
const response = await queryClient.ensureQueryData(exampleQueryOptions);
return response;
};
};-
ensureQueryData: Returns cached data if available, only fetches if missing or stale- Use when you want to reuse cached data for better performance
-
fetchQuery: Always fetches fresh data, ignoring cache- Use when you need guaranteed fresh data on every navigation
- No loading spinners: Data is ready before the page renders
- Auth guards: Redirect unauthenticated users before they see protected content
- Better UX: Users see complete content immediately, not skeletons
- SSR-ready: Loaders can run on the server for true SSR/SSG
Use the same query options in your component to read from the cache and subscribe to updates.
// src/pages/ExamplePage/ExamplePage.tsx
import { useQuery } from '@tanstack/react-query';
import { useLoaderData } from 'react-router-dom';
import { exampleQueryOptions } from '../../features/example/api/example.queries';
function ExamplePage() {
const initialData = useLoaderData();
// Use the same query options - automatically typed!
const { data } = useQuery({
...exampleQueryOptions,
initialData, // Use loader data as initial data
});
return <div>{data.message}</div>;
}- Automatic caching: Fetch once, reuse everywhere
- Background refetching: Keep data fresh without user interaction
- Deduplication: Multiple components requesting the same data = one network request
- Built-in loading/error states: No need to manage
useStatefor every fetch - Optimistic updates: Update UI instantly, roll back on error
- Automatic updates: When cache is invalidated, component refetches automatically
The Full Picture: Loader prefetches → Component uses cached data → Mutation invalidates → Component automatically refetches. All with type safety!
Mutations handle data modifications (POST, PUT, DELETE) and trigger cache invalidation to keep data fresh.
// src/features/example/hooks/useExampleMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRevalidator } from 'react-router-dom';
import { exampleQueryKey } from '../api/example.queries';
export const useExampleMutation = () => {
const queryClient = useQueryClient();
const revalidator = useRevalidator();
return useMutation({
mutationFn: async (data: FormData) => {
return await apiClient.post('/submit', data);
},
onSuccess: async (data) => {
// Invalidate the query - marks it as stale
await queryClient.invalidateQueries({ queryKey: exampleQueryKey });
// Revalidate the route - re-runs the loader
revalidator.revalidate();
},
onError: (error) => {
console.error('Mutation error:', error);
},
});
};- Form Submission: User submits form
- Mutation Executes: API call completes successfully
- Invalidate Query:
invalidateQueriesmarks the query as stale (data still in cache) - Revalidate Route:
revalidator.revalidate()re-runs the loader - Loader Prefetches: Loader fetches fresh data
- Component Refetches:
useQueryautomatically refetches because query is stale - UI Updates: Component displays fresh data
- Automatic cache updates: Invalidate or update related queries
- Loading states: Track submission status without manual state
- Error handling: Centralized error management
- Retry logic: Built-in retry on failure
- Integration with loaders: Revalidate routes to prefetch fresh data
Here's a complete example showing how query options, loaders, queries, and mutations work together:
// src/features/example/api/example.queries.ts
import { queryOptions } from '@tanstack/react-query';
export type ExampleData = {
message: string;
count: number;
};
export const exampleQueryKey = ['exampleData'] as const;
export const exampleQueryOptions = queryOptions<ExampleData>({
queryKey: exampleQueryKey,
queryFn: async () => {
const response = await fetch('/api/example-data');
return response.json();
},
});// src/pages/ExamplePage/ExamplePage.loader.ts
import { QueryClient } from '@tanstack/react-query';
import { exampleQueryOptions, type ExampleData } from '../../features/example/api/example.queries';
export const examplePageLoader = (queryClient: QueryClient) => {
return async (): Promise<ExampleData> => {
// Prefetch data before page renders
return await queryClient.ensureQueryData(exampleQueryOptions);
};
};// src/pages/ExamplePage/ExamplePage.tsx
import { useQuery } from '@tanstack/react-query';
import { useLoaderData } from 'react-router-dom';
import { exampleQueryOptions } from '../../features/example/api/example.queries';
import { useExampleMutation } from '../../features/example/hooks/useExampleMutation';
function ExamplePage() {
const initialData = useLoaderData();
// Read from cache, subscribe to updates
const { data, isLoading } = useQuery({
...exampleQueryOptions,
initialData,
});
const mutation = useExampleMutation();
const handleSubmit = (formData: FormData) => {
mutation.mutate(formData, {
onSuccess: () => {
// Component-specific: show toast, navigate, etc.
console.log('Form submitted!');
},
});
};
return (
<div>
<h1>{data.message}</h1>
<p>Count: {data.count}</p>
{/* Form here */}
</div>
);
}// src/features/example/hooks/useExampleMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRevalidator } from 'react-router-dom';
import { exampleQueryKey } from '../api/example.queries';
export const useExampleMutation = () => {
const queryClient = useQueryClient();
const revalidator = useRevalidator();
return useMutation({
mutationFn: async (data: FormData) => {
return await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
}).then(r => r.json());
},
onSuccess: async () => {
// Default behavior: Invalidate and refetch
await queryClient.invalidateQueries({ queryKey: exampleQueryKey });
revalidator.revalidate();
},
});
};User visits / (example page)
↓
Loader runs (prefetch)
↓
exampleQueryOptions.queryFn() → Fetch from API
↓
Data cached in TanStack Query
↓
Component renders with initialData
↓
useQuery subscribes to cache updates
↓
User submits form
↓
Mutation executes
↓
onSuccess: invalidateQueries + revalidate
↓
Loader re-runs → ensureQueryData fetches fresh data
↓
useQuery refetches (because invalidated)
↓
Component automatically updates with fresh data ✨
Define mutations in hooks with sensible defaults (like cache invalidation), then override in components for specific behaviors:
// ✅ In hook: src/features/example/hooks/useExampleMutation.ts
import { exampleQueryKey } from '../api/example.queries';
export const useExampleMutation = () => {
const queryClient = useQueryClient();
const revalidator = useRevalidator();
return useMutation({
mutationFn: async (data: FormData) => {
return await apiClient.post('/submit', data);
},
onSuccess: async (data) => {
// Default: Invalidate cache and revalidate route
await queryClient.invalidateQueries({ queryKey: exampleQueryKey });
revalidator.revalidate();
},
onError: (error) => {
// Default: Log error
console.error('Error:', error);
},
});
};
// ✅ In component: src/pages/ExamplePage/ExamplePage.tsx
const mutation = useExampleMutation();
const onSubmit = (data: FormData) => {
mutation.mutate(data, {
onSuccess: () => {
// Override: Add component-specific behavior
// (Default invalidation still happens first)
reset(); // Clear form
navigate('/success'); // Navigate away
toast.success('Saved!'); // Show notification
},
onError: (error) => {
// Override: Show user-friendly error
setErrorMessage(error.message);
},
});
};-
Reusability: The same mutation hook can be used in multiple components with different success behaviors
// AdminPanel.tsx: Redirect to admin dashboard after cache update mutation.mutate(data, { onSuccess: () => navigate('/admin') }); // SettingsPage.tsx: Just show a toast after cache update mutation.mutate(data, { onSuccess: () => toast('Updated!') }); // Both cases: Cache is invalidated and data refetches automatically
-
Sensible defaults: Every mutation automatically updates the cache, even if you forget
-
Flexibility: Each component can customize behavior without duplicating API logic
-
Testing: Mock the mutation hook once, test component-specific behaviors separately
-
Single Source of Truth: API endpoint and base logic defined once, used everywhere
# Install dependencies
yarn install
# Start dev server
yarn dev
# Run tests
yarn test
# Build for production
yarn build- React 18 + TypeScript
- React Router v6 (with loaders)
- TanStack Query v5 (data fetching)
- React Hook Form + Yup (form management)
- Vite (build tool)
- Tailwind CSS (styling)
- Follow the folder structure in STRUCTURE.md
- Import directly from source files (no
index.tsbarrels) - Co-locate tests with components
- Use query options pattern: Centralize query definitions in
*.queries.tsfiles - Use loaders for prefetching: Use
ensureQueryDataorfetchQuerywith query options - Use queries in components: Reuse the same query options with
useQuery - Define mutations in hooks: Include default behaviors and invalidation logic
- Customize in components: Override
onSuccess/onErrorfor component-specific behavior