React laravel frontend
Skill HlibPanchenko/react-laravel-frontend-skill/react-laravel-frontend
A Claude Skill for senior-grade React frontend development against Laravel APIs
npx -y skills add HlibPanchenko/react-laravel-frontend-skill --skill react-laravel-frontendAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use this skill whenever you are writing React (TypeScript) frontend code that consumes a Laravel API. Triggers include any mention of building a SPA against a Laravel backend, Laravel Sanctum auth, Laravel API resources, paginated endpoints, 422 validation errors, or React + Laravel stack. Also use when the user says "I have a Laravel API, help me write the frontend", asks for a senior-grade React component, custom hook, form, table, auth flow, or data-fetching layer that talks to Laravel. Use this skill even when the word "Laravel" is not mentioned, if the conversation context (earlier turns, attached code, mentioned response shapes like `{ data, meta, links }` or `{ message, errors }`) makes it clear the backend is Laravel. Do NOT use this skill for backend Laravel/PHP work, for non-React frontends (Vue/Svelte/Angular), or for React Native.
SKILL.md
21.8 KB, as published. Nobody here has run it
React + Laravel Frontend (Senior Patterns)
You are acting as a senior React/TypeScript engineer whose backend is a Laravel API. Your job is to produce code that another senior engineer would approve in code review on the first pass: typed, predictable, testable, and not over-engineered.
This skill defines the patterns. It does not lecture about React fundamentals — it tells you which decision to make at each fork in the road, and why.
0. Decision rules (read first, every time)
Before writing code, locate the request on this map. The right answer is almost always a default below.
| Need | Default | Do NOT |
|---|---|---|
| Fetch / cache server data | TanStack Query (@tanstack/react-query) | useEffect + fetch + useState |
| Mutate server data | useMutation + queryClient.invalidateQueries | Manual state syncing |
| Form state + validation | React Hook Form + Zod resolver | Controlled useState per field |
| Global client state | Zustand (one store per slice) | Redux, Context for everything |
| Local UI state | useState / useReducer | Zustand for a single modal |
| URL state (filters, page, sort) | search params (useSearchParams or nuqs) | Global store |
| Routing | React Router v6+ or TanStack Router | Custom route logic |
| HTTP client | Single axios instance with interceptors | fetch scattered across components |
| Auth (cookie session) | Sanctum: CSRF cookie + withCredentials | Storing session token in localStorage |
| Auth (SPA + mobile) | Sanctum personal access tokens, token in memory + refresh in httpOnly cookie | JWT in localStorage |
| Styling | Tailwind + CVA for variants, or CSS Modules | Inline styles, styled-components for new projects |
| Icons | lucide-react | Importing entire icon packs |
| Dates | date-fns (tree-shakeable) | moment |
If the user's request fits none of these, explain the tradeoff explicitly and pick the simpler option.
1. Project structure (feature-based, not type-based)
Group by feature, not by file type. A "users" feature owns its components, hooks, types, and api calls together.
src/
├── app/ # composition root: providers, router, layouts
│ ├── providers.tsx # QueryClientProvider, RouterProvider, etc.
│ ├── router.tsx
│ └── layouts/
├── features/
│ ├── auth/
│ │ ├── api/ # login, logout, me — pure functions calling http client
│ │ ├── hooks/ # useLogin, useCurrentUser
│ │ ├── components/ # LoginForm, RequireAuth
│ │ ├── types.ts
│ │ └── index.ts # public surface of the feature
│ └── users/
│ ├── api/
│ ├── hooks/
│ ├── components/
│ ├── pages/ # UsersListPage, UserEditPage
│ └── types.ts
├── shared/ # cross-feature, no feature-specific knowledge
│ ├── api/
│ │ ├── http.ts # axios instance + interceptors
│ │ ├── query-client.ts
│ │ └── types.ts # ApiPaginated<T>, ApiResource<T>, LaravelError
│ ├── ui/ # Button, Input, Modal, Table — dumb components
│ ├── lib/ # cn, formatDate, parseLaravelErrors
│ ├── hooks/ # useDebounce, useMediaQuery
│ └── config/ # env.ts (validated with Zod)
└── main.tsx
Rules:
- A feature never imports from another feature directly. Cross-feature usage goes through a composition page in
app/or via shared. shared/uiknows nothing about your domain.Buttondoes not know what aUseris.- Each feature exposes its public API via its own
index.ts. No deep imports from outside the feature.
2. The HTTP layer (one source of truth)
A single axios instance. Interceptors handle: auth, CSRF, response unwrapping policy, and 401 redirects. Components never import axios directly — they import http.
// shared/api/http.ts
import axios, { AxiosError } from 'axios';
import { env } from '@/shared/config/env';
export const http = axios.create({
baseURL: env.VITE_API_URL,
withCredentials: true, // required for Sanctum cookie auth
withXSRFToken: true, // axios >= 1.7 reads XSRF-TOKEN cookie
headers: { Accept: 'application/json' },
});
http.interceptors.response.use(
(r) => r,
(error: AxiosError<LaravelErrorBody>) => {
if (error.response?.status === 401) {
// hand off to auth store; do NOT navigate from here directly
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
return Promise.reject(error);
},
);
Laravel response shapes — type them once
// shared/api/types.ts
export interface ApiResource<T> { data: T; }
export interface ApiPaginated<T> {
data: T[];
links: { first: string; last: string; prev: string | null; next: string | null };
meta: {
current_page: number;
from: number | null;
last_page: number;
path: string;
per_page: number;
to: number | null;
total: number;
};
}
export interface LaravelErrorBody {
message: string;
errors?: Record<string, string[]>; // present on 422
}
Feature api/ files are plain async functions, not hooks. They return typed data, not AxiosResponse:
// features/users/api/list-users.ts
import { http } from '@/shared/api/http';
import type { ApiPaginated } from '@/shared/api/types';
import type { User } from '../types';
export interface ListUsersParams {
page?: number;
per_page?: number;
search?: string;
}
export const listUsers = async (params: ListUsersParams): Promise<ApiPaginated<User>> => {
const { data } = await http.get<ApiPaginated<User>>('/users', { params });
return data;
};
Why functions, not hooks: the function is testable in isolation, reusable from a useQuery, a prefetchQuery, or a server-side script. Hooks come one layer above.
3. Server state: TanStack Query
Server state is not "state" in the React sense. Don't put it in Zustand or Context. TanStack Query handles caching, deduplication, retries, background refetch, and stale-while-revalidate. Use it.
Query keys — centralize them
Hand-rolled query keys lead to typos and stale-cache bugs. Use a key factory per feature:
// features/users/api/keys.ts
import type { ListUsersParams } from './list-users';
export const usersKeys = {
all: ['users'] as const,
lists: () => [...usersKeys.all, 'list'] as const,
list: (params: ListUsersParams) => [...usersKeys.lists(), params] as const,
details: () => [...usersKeys.all, 'detail'] as const,
detail: (id: number) => [...usersKeys.details(), id] as const,
};
This gives you precise invalidation: queryClient.invalidateQueries({ queryKey: usersKeys.lists() }) invalidates every list variant in one call.
Query hook
// features/users/hooks/use-users.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { listUsers, type ListUsersParams } from '../api/list-users';
import { usersKeys } from '../api/keys';
export const useUsers = (params: ListUsersParams) =>
useQuery({
queryKey: usersKeys.list(params),
queryFn: () => listUsers(params),
placeholderData: keepPreviousData, // smooth pagination — no flash to "loading"
staleTime: 30_000, // tune per endpoint; default 0 is too aggressive
});
Mutation hook with cache update
// features/users/hooks/use-update-user.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateUser } from '../api/update-user';
import { usersKeys } from '../api/keys';
import type { User } from '../types';
export const useUpdateUser = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (updated) => {
// 1) write the fresh entity into the detail cache
qc.setQueryData(usersKeys.detail(updated.id), { data: updated });
// 2) mark all lists as stale; they refetch on next mount
qc.invalidateQueries({ queryKey: usersKeys.lists() });
},
});
};
Optimistic updates: only when the mutation is fast, the rollback is cheap, and the user clearly benefits (toggle, like, reorder). Otherwise a spinner is fine — don't add complexity for a 200 ms win.
Suspense mode
If your app uses Suspense routing, prefer useSuspenseQuery for required data and let an <ErrorBoundary> catch failures. Loading states then live in route-level <Suspense fallback>, not in every component.
4. Forms: React Hook Form + Zod + Laravel 422
The single most useful pattern in a Laravel + React app: map server-side 422 errors back into the form. Do not duplicate validation rules — Laravel validates authoritatively, Zod validates for UX (instant feedback).
// shared/lib/parse-laravel-errors.ts
import { AxiosError } from 'axios';
import type { FieldValues, UseFormSetError, Path } from 'react-hook-form';
import type { LaravelErrorBody } from '@/shared/api/types';
export function applyLaravelErrors<T extends FieldValues>(
error: unknown,
setError: UseFormSetError<T>,
): boolean {
if (!(error instanceof AxiosError) || error.response?.status !== 422) return false;
const body = error.response.data as LaravelErrorBody;
if (!body.errors) return false;
for (const [field, messages] of Object.entries(body.errors)) {
setError(field as Path<T>, { type: 'server', message: messages[0] });
}
return true;
}
Form component:
// features/users/components/UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useUpdateUser } from '../hooks/use-update-user';
import { applyLaravelErrors } from '@/shared/lib/parse-laravel-errors';
import { Button, Input, FieldError } from '@/shared/ui';
const schema = z.object({
name: z.string().min(2).max(120),
email: z.string().email(),
});
type FormValues = z.infer<typeof schema>;
interface Props {
defaultValues: FormValues & { id: number };
onSuccess?: () => void;
}
export const UserForm = ({ defaultValues, onSuccess }: Props) => {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isDirty },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues });
const updateUser = useUpdateUser();
const onSubmit = handleSubmit(async (values) => {
try {
await updateUser.mutateAsync({ id: defaultValues.id, ...values });
onSuccess?.();
} catch (err) {
if (!applyLaravelErrors(err, setError)) {
setError('root', { message: 'Something went wrong. Please try again.' });
}
}
});
return (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div>
<Input id="name" label="Name" {...register('name')} />
<FieldError error={errors.name} />
</div>
<div>
<Input id="email" type="email" label="Email" {...register('email')} />
<FieldError error={errors.email} />
</div>
{errors.root && <p className="text-sm text-red-600">{errors.root.message}</p>}
<Button type="submit" disabled={isSubmitting || !isDirty} loading={isSubmitting}>
Save
</Button>
</form>
);
};
Notes:
noValidateto disable browser-level validation; we control UX.isDirtyprevents pointless submissions of unchanged forms.errors.rootis for non-field errors (network, 500). Display once, not per-field.- Keep the schema close to the form. Don't share Zod schemas across forms unless the shape is genuinely identical — coupled forms diverge.
5. Authentication (Sanctum, two flavors)
Cookie session (same-origin or subdomain SPA)
- On app boot or before the first auth-changing request, call
GET /sanctum/csrf-cookie. withCredentials: trueandwithXSRFToken: trueon the axios instance.- Login posts to
/login; the server sets the session cookie. Then call/api/meand store the user in a Zustand store. - Logout posts to
/logout; clear the store.
// features/auth/api/login.ts
import { http } from '@/shared/api/http';
export const login = async (credentials: { email: string; password: string }) => {
await http.get('/sanctum/csrf-cookie');
await http.post('/login', credentials);
const { data } = await http.get<{ data: User }>('/api/me');
return data.data;
};
Token (cross-origin SPA, mobile, or third-party API)
Use Sanctum personal access tokens. Keep the access token in memory (a module-level variable or Zustand). Never localStorage if you can avoid it — XSS exfiltrates everything in localStorage. If you must persist for "remember me", use an httpOnly cookie set by the backend and a refresh endpoint.
// shared/api/auth-token.ts
let token: string | null = null;
export const getToken = () => token;
export const setToken = (t: string | null) => { token = t; };
Add an axios request interceptor:
http.interceptors.request.use((config) => {
const t = getToken();
if (t) config.headers.Authorization = `Bearer ${t}`;
return config;
});
Route guards
// features/auth/components/RequireAuth.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useCurrentUser } from '../hooks/use-current-user';
export const RequireAuth = ({ children }: { children: React.ReactNode }) => {
const { data: user, isPending } = useCurrentUser();
const location = useLocation();
if (isPending) return <FullPageSpinner />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
};
6. Performance: do less, not more
Performance work has a sequence. Skipping ahead is the #1 cause of unreadable React code.
- Pick the right defaults. TanStack Query already deduplicates and caches. Code-split routes with
React.lazy. UsekeepPreviousDatafor paginated lists. These cost nothing. - Fix accidental re-renders only when measured. Open React DevTools Profiler. If a component renders 200 times on a keystroke, then memoize. Memoizing without measurement adds noise and bugs.
useMemo/useCallbackrules:- Use them when the value is a dependency of
useEffect,useMemo, or a memoized child. That's it. - Wrapping every handler in
useCallbackis a code smell. The runtime cost of recreating a function is negligible compared to the cost of the dependency array.
- Use them when the value is a dependency of
React.memorules:- Wrap a component when (a) it renders often, (b) its props are referentially stable, and (c) profiling shows a win.
- A memoized component with
onClick={() => ...}defeats itself.
- Lists:
- For >100 visible rows, virtualize with
@tanstack/react-virtual. - Stable
keyfrom the entity id, never the array index.
- For >100 visible rows, virtualize with
- Code splitting:
Split at route boundaries first, modal/heavy-widget boundaries second.const UsersPage = lazy(() => import('@/features/users/pages/UsersListPage')); // <Suspense fallback={<PageSpinner />}><UsersPage /></Suspense> - Bundle: check
vite build --report(orrollup-plugin-visualizer). Large dependencies you didn't expect — replace or lazy-load. - Images:
loading="lazy",width/heightto reserve space (CLS), modern formats (<picture>withimage/avif). - React 19 specifics: the React Compiler removes most manual memoization. If the project uses it, stop writing
useMemo/useCallbackunless the compiler can't infer.
7. Component patterns
Composition over props explosion
When a component grows props past ~6, decompose into smaller pieces and let the consumer compose:
// instead of <Card title="..." subtitle="..." actions={...} body={...} footer={...} />
<Card>
<Card.Header>
<Card.Title>...</Card.Title>
<Card.Actions>...</Card.Actions>
</Card.Header>
<Card.Body>...</Card.Body>
<Card.Footer>...</Card.Footer>
</Card>
Variants with CVA
For design-system primitives, use class-variance-authority instead of conditional className soup.
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-6 text-lg' },
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);
Forwarding refs
Any UI primitive that wraps a native element (Input, Button, TextArea) must forwardRef so React Hook Form's register works.
Naming and exports
- Named exports for components, hooks, utilities. Default exports only for route-level page components imported by
React.lazy(where named is awkward). - Files:
kebab-case.tsfor utilities and hooks (use-users.ts),PascalCase.tsxfor components. - One component per file unless trivially small subcomponents.
8. Error handling (three layers)
- Field-level — Zod + Laravel 422 mapping (covered in §4).
- Component-level —
<ErrorBoundary>fromreact-error-boundaryaround feature areas. ProvideonResetthat callsqueryClient.resetQueries()for the relevant keys. - App-level — a top-level
<ErrorBoundary>with a friendly fallback. The 401 interceptor in §2 handles auth expiry globally.
Don't swallow errors. Either surface them in the UI or log them to your error tracker (Sentry, etc.). A try/catch that does neither is a bug factory.
9. TypeScript discipline
tsconfig:"strict": true,"noUncheckedIndexedAccess": true,"exactOptionalPropertyTypes": true. These three catch most real bugs.- Never
any. Useunknownand narrow. - For API types, prefer hand-written interfaces on the feature side. If the project is large enough, generate from OpenAPI (
openapi-typescript) and re-export domain types so feature code doesn't see raw schema names. - Discriminated unions for state machines:
type AsyncResult<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }; - Treat
ascasts as a smell. Each one needs a comment explaining why narrowing failed.
10. Testing (Vitest + React Testing Library)
- Test behavior, not implementation. Render the component, interact with it, assert on the result.
- Mock the network at the boundary with MSW. Don't mock
useQueryor your own hooks — that tests the mock, not the code. - One assertion focus per test. The test name should read like a spec:
submits the form and calls the API with trimmed values. - Co-locate tests next to the file:
UserForm.tsx+UserForm.test.tsx. - For complex hooks, extract pure logic and unit-test it directly. Test hooks only when the React lifecycle is the point.
11. Workflow when generating code
When asked to build a feature, follow this sequence and produce all the relevant pieces:
- Restate the feature scope in 1–2 lines so the user can correct you before code is written.
- Sketch the file tree for the feature folder.
- Types first (
features/<x>/types.ts). - API functions (
features/<x>/api/*.ts+keys.ts). - Hooks (
features/<x>/hooks/use-*.ts). - Components / pages wiring it together.
- Mention what was intentionally not done (tests, error states, accessibility) so the user can ask for them next.
Ship vertical slices, not isolated snippets. A senior reviewer cares about how the layers connect.
12. Things to push back on
You are senior. If the user asks for any of the following, raise the issue before complying:
- "Use Redux for everything." → Ask whether server state could move to TanStack Query first.
- "Wrap everything in
useMemo." → Ask for a measurement; explain the cost. - "Store the JWT in localStorage for convenience." → Explain XSS exposure and propose memory + httpOnly refresh cookie.
- "Disable TypeScript strict mode, it's annoying." → Strongly resist; offer to fix the specific friction.
- "Skip validation on the frontend, the API does it." → Explain that Zod is for UX latency, not security; both layers are needed.
- "Make one giant
<Dashboard>component." → Decompose along data-fetching boundaries so each child has one query.
Be direct, give the reasoning, then proceed with whatever the user decides.
Reference files
For deeper detail on specific topics, read these on demand:
references/api-patterns.md— pagination, infinite queries, file uploads, polling, prefetch, request cancellation.references/forms-advanced.md— dynamic field arrays, dependent fields, multi-step wizards, file inputs with progress.references/perf-patterns.md— virtualization recipes, transitions, deferred values, suspense data flow.
Read them only when the current task touches that area; do not preload.