# React Laravel Frontend

> 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: `hlibpanchenko/react-laravel-frontend` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add hlibpanchenko/react-laravel-frontend`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hlibpanchenko/react-laravel-frontend/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: HlibPanchenko (https://skillmd.com/u/hlibpanchenko)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hlibpanchenko/react-laravel-frontend

---


# 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/ui` knows nothing about your domain. `Button` does not know what a `User` is.
- 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`.

```ts
// 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

```ts
// 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:

```ts
// 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:

```ts
// 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

```ts
// 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

```ts
// 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).

```ts
// 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:

```tsx
// 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:
- `noValidate` to disable browser-level validation; we control UX.
- `isDirty` prevents pointless submissions of unchanged forms.
- `errors.root` is 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)

1. On app boot or before the first auth-changing request, call `GET /sanctum/csrf-cookie`.
2. `withCredentials: true` and `withXSRFToken: true` on the axios instance.
3. Login posts to `/login`; the server sets the session cookie. Then call `/api/me` and store the user in a Zustand store.
4. Logout posts to `/logout`; clear the store.

```ts
// 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.

```ts
// 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:
```ts
http.interceptors.request.use((config) => {
  const t = getToken();
  if (t) config.headers.Authorization = `Bearer ${t}`;
  return config;
});
```

### Route guards

```tsx
// 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.

1. **Pick the right defaults.** TanStack Query already deduplicates and caches. Code-split routes with `React.lazy`. Use `keepPreviousData` for paginated lists. These cost nothing.
2. **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.
3. **`useMemo` / `useCallback` rules:**
   - Use them when the value is a dependency of `useEffect`, `useMemo`, or a memoized child. That's it.
   - Wrapping every handler in `useCallback` is a code smell. The runtime cost of recreating a function is negligible compared to the cost of the dependency array.
4. **`React.memo` rules:**
   - 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.
5. **Lists:**
   - For >100 visible rows, virtualize with `@tanstack/react-virtual`.
   - Stable `key` from the entity id, never the array index.
6. **Code splitting:**
   ```tsx
   const UsersPage = lazy(() => import('@/features/users/pages/UsersListPage'));
   // <Suspense fallback={<PageSpinner />}><UsersPage /></Suspense>
   ```
   Split at route boundaries first, modal/heavy-widget boundaries second.
7. **Bundle:** check `vite build --report` (or `rollup-plugin-visualizer`). Large dependencies you didn't expect — replace or lazy-load.
8. **Images:** `loading="lazy"`, `width`/`height` to reserve space (CLS), modern formats (`<picture>` with `image/avif`).
9. **React 19 specifics:** the React Compiler removes most manual memoization. If the project uses it, **stop writing `useMemo`/`useCallback`** unless 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:

```tsx
// 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.

```ts
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.ts` for utilities and hooks (`use-users.ts`), `PascalCase.tsx` for components.
- One component per file unless trivially small subcomponents.

---

## 8. Error handling (three layers)

1. **Field-level** — Zod + Laravel 422 mapping (covered in §4).
2. **Component-level** — `<ErrorBoundary>` from `react-error-boundary` around feature areas. Provide `onReset` that calls `queryClient.resetQueries()` for the relevant keys.
3. **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`. Use `unknown` and 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:
  ```ts
  type AsyncResult<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: Error };
  ```
- Treat `as` casts 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 `useQuery` or 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:

1. **Restate the feature scope** in 1–2 lines so the user can correct you before code is written.
2. **Sketch the file tree** for the feature folder.
3. **Types first** (`features/<x>/types.ts`).
4. **API functions** (`features/<x>/api/*.ts` + `keys.ts`).
5. **Hooks** (`features/<x>/hooks/use-*.ts`).
6. **Components / pages** wiring it together.
7. **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.

