agentsclimarketplace

React laravel frontend

Skill HlibPanchenko/react-laravel-frontend-skill/react-laravel-frontend

A Claude Skill for senior-grade React frontend development against Laravel APIs

Install
npx -y skills add HlibPanchenko/react-laravel-frontend-skill --skill react-laravel-frontend

Assembled 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.

NeedDefaultDo NOT
Fetch / cache server dataTanStack Query (@tanstack/react-query)useEffect + fetch + useState
Mutate server datauseMutation + queryClient.invalidateQueriesManual state syncing
Form state + validationReact Hook Form + Zod resolverControlled useState per field
Global client stateZustand (one store per slice)Redux, Context for everything
Local UI stateuseState / useReducerZustand for a single modal
URL state (filters, page, sort)search params (useSearchParams or nuqs)Global store
RoutingReact Router v6+ or TanStack RouterCustom route logic
HTTP clientSingle axios instance with interceptorsfetch scattered across components
Auth (cookie session)Sanctum: CSRF cookie + withCredentialsStoring session token in localStorage
Auth (SPA + mobile)Sanctum personal access tokens, token in memory + refresh in httpOnly cookieJWT in localStorage
StylingTailwind + CVA for variants, or CSS ModulesInline styles, styled-components for new projects
Iconslucide-reactImporting entire icon packs
Datesdate-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.

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

  • 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.
// 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.

  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:
    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:

// 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.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:
    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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.