react-feature-frontend
>
Install
mkdir -p .claude/skills/react-feature-frontend && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16929" && unzip -o skill.zip -d .claude/skills/react-feature-frontend && rm skill.zipInstalls to .claude/skills/react-feature-frontend
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Use this skill whenever creating or modifying frontend code in the my-schedule project. Covers feature-based folder structure, React Query, Zustand, component patterns, and service layer. Trigger on: "create page", "create component", "create feature", "add hook", "add form", or any task touching apps/web/src/.About this skill
React Frontend — my-schedule
This skill defines how all frontend code must be written in this project. Stack: React 18 + TypeScript + Vite + Tailwind CSS + React Query + Zustand.
Folder Structure — Feature-based
All code lives inside a feature folder. Features map 1:1 to domain concepts.
src/
├── features/
│ ├── appointments/
│ │ ├── components/ ← componentes exclusivos desta feature
│ │ │ ├── AppointmentCard.tsx
│ │ │ ├── AppointmentStatusBadge.tsx
│ │ │ └── CancelAppointmentModal.tsx
│ │ ├── hooks/ ← React Query hooks desta feature
│ │ │ ├── useAppointments.ts
│ │ │ └── useCancelAppointment.ts
│ │ ├── services/ ← chamadas HTTP desta feature
│ │ │ └── appointments.service.ts
│ │ ├── types.ts ← tipos locais da feature
│ │ └── index.ts ← exporta apenas o que outras features precisam
│ │
│ ├── booking/ ← fluxo público de agendamento (multi-step)
│ │ ├── components/
│ │ │ ├── StepProfessional.tsx
│ │ │ ├── StepService.tsx
│ │ │ ├── StepSlot.tsx
│ │ │ └── StepIdentify.tsx
│ │ ├── hooks/
│ │ │ ├── useAvailableSlots.ts
│ │ │ └── useCreateBooking.ts
│ │ ├── services/
│ │ │ └── booking.service.ts
│ │ ├── store.ts ← Zustand store local da feature (multi-step state)
│ │ └── index.ts
│ │
│ ├── professionals/
│ ├── services/ ← (serviços do salão)
│ ├── availability/
│ └── auth/
│
├── pages/ ← apenas composição de features, sem lógica
│ ├── admin/
│ │ ├── AppointmentsPage.tsx
│ │ └── ProfessionalsPage.tsx
│ ├── public/
│ │ └── BookingPage.tsx
│ └── customer/
│ └── MyAppointmentsPage.tsx
│
├── shared/ ← código genuinamente compartilhado
│ ├── components/
│ │ ├── ui/ ← Button, Input, Modal, Badge, Avatar
│ │ └── layout/ ← AdminLayout, PublicLayout, Sidebar
│ ├── hooks/
│ │ └── useDebounce.ts
│ ├── lib/
│ │ ├── api.ts ← instância Axios configurada
│ │ └── formatters.ts ← moeda, data, telefone
│ └── types/
│ └── common.ts ← Paginated<T>, ApiError, etc.
│
└── stores/
└── auth.store.ts ← único store global: token + usuário
The rule: if a component, hook, or service is used by only one feature, it lives inside that feature. It only moves to shared/ when a second feature needs it.
Service Layer
Each feature has its own service file that wraps the API calls. No fetch or axios calls inside components or hooks directly.
// features/appointments/services/appointments.service.ts
import { api } from '@/shared/lib/api';
import type { Appointment, CreateAppointmentData } from '../types';
import type { Paginated } from '@/shared/types/common';
export const appointmentsService = {
getMany: async (params: { page?: number; limit?: number }): Promise<Paginated<Appointment>> => {
const { data } = await api.get('/appointments', { params });
return data;
},
getById: async (id: string): Promise<Appointment> => {
const { data } = await api.get(`/appointments/${id}`);
return data;
},
create: async (payload: CreateAppointmentData): Promise<Appointment> => {
const { data } = await api.post('/appointments', payload);
return data;
},
confirm: async (id: string): Promise<Appointment> => {
const { data } = await api.patch(`/appointments/${id}/confirm`);
return data;
},
cancel: async (id: string, reason?: string): Promise<Appointment> => {
const { data } = await api.patch(`/appointments/${id}/cancel`, { reason });
return data;
},
};
Rules:
- Services are plain objects with async methods — not classes, not hooks.
- Always destructure
{ data }from axios response — never return the full AxiosResponse. - URL paths match the backend exactly — no ad-hoc string interpolation outside service files.
React Query Hooks
Each feature has one file per query or mutation. Hooks wrap the service and configure caching.
// features/appointments/hooks/useAppointments.ts
import { useQuery } from '@tanstack/react-query';
import { appointmentsService } from '../services/appointments.service';
export const appointmentKeys = {
all: ['appointments'] as const,
lists: () => [...appointmentKeys.all, 'list'] as const,
list: (params: object) => [...appointmentKeys.lists(), params] as const,
detail: (id: string) => [...appointmentKeys.all, 'detail', id] as const,
};
export function useAppointments(params: { page?: number; limit?: number } = {}) {
return useQuery({
queryKey: appointmentKeys.list(params),
queryFn: () => appointmentsService.getMany(params),
});
}
export function useAppointment(id: string) {
return useQuery({
queryKey: appointmentKeys.detail(id),
queryFn: () => appointmentsService.getById(id),
enabled: !!id,
});
}
// features/appointments/hooks/useCancelAppointment.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { appointmentsService } from '../services/appointments.service';
import { appointmentKeys } from './useAppointments';
export function useCancelAppointment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
appointmentsService.cancel(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: appointmentKeys.lists() });
},
});
}
Rules:
- Always export a
keysobject from the hook file — used to invalidate queries correctly. enabled: !!idon queries that depend on a param that might be undefined.staleTimeis set explicitly when the data has a clear freshness requirement:- Slots disponíveis →
staleTime: 30_000(30s — mudam rápido) - Lista de profissionais →
staleTime: 5 * 60_000(5min — quase estática)
- Slots disponíveis →
- Mutations always invalidate the relevant list queries on
onSuccess. - One hook file per concern — don't mix queries and mutations in the same file.
Component Pattern
// features/appointments/components/AppointmentCard.tsx
import { formatDate, formatCurrency } from '@/shared/lib/formatters';
import { AppointmentStatusBadge } from './AppointmentStatusBadge';
import { useCancelAppointment } from '../hooks/useCancelAppointment';
import type { Appointment } from '../types';
interface AppointmentCardProps {
appointment: Appointment;
onCancelSuccess?: () => void;
}
export function AppointmentCard({ appointment, onCancelSuccess }: AppointmentCardProps) {
const { mutate: cancel, isPending } = useCancelAppointment();
function handleCancel() {
cancel(
{ id: appointment.id },
{ onSuccess: onCancelSuccess },
);
}
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-zinc-900">{appointment.professional.name}</p>
<p className="text-sm text-zinc-500">{appointment.service.name}</p>
</div>
<AppointmentStatusBadge status={appointment.status} />
</div>
<div className="mt-3 flex items-center justify-between">
<p className="text-sm text-zinc-600">{formatDate(appointment.startTime)}</p>
<p className="text-sm font-medium text-zinc-900">
{formatCurrency(appointment.service.price)}
</p>
</div>
{appointment.status === 'pending' || appointment.status === 'confirmed' ? (
<button
onClick={handleCancel}
disabled={isPending}
className="mt-3 w-full rounded-lg border border-red-200 py-1.5 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{isPending ? 'Cancelando...' : 'Cancelar agendamento'}
</button>
) : null}
</div>
);
}
Rules:
- Components are named exports — never default export.
- Props interface always named
<ComponentName>Props, defined in the same file. - Hooks are called at the top of the component — never inside conditionals.
- No
anytypes — use the feature'stypes.tsor@/shared/types/common.ts. - Tailwind only — no inline
style={{}}except for dynamic values Tailwind can't handle. - Conditional rendering uses ternary or
condition && <El />— never nestedif/elseinside JSX.
Feature-local Zustand Store
Only use a local Zustand store when a feature has multi-step or complex UI state that doesn't belong on the server. The booking flow is the main example.
// features/booking/store.ts
import { create } from 'zustand';
import type { Professional } from '@/features/professionals/types';
import type { Service } from '@/features/services/types';
type BookingStep = 'professional' | 'service' | 'slot' | 'identify' | 'confirm';
interface BookingSlot {
startTime: string;
endTime: string;
}
interface BookingState {
step: BookingStep;
professional: Professional | null;
service: Service | null;
slot: BookingSlot | null;
setStep: (step: BookingStep) => void;
selectProfessional: (professional: Professional) => void;
selectService: (service: Service) => void;
selectSlot: (slot: BookingSlot) => void;
reset: () => void;
}
const initialState = {
step: 'professional' as BookingStep,
professional: null,
service: null,
slot: null,
};
export const useBookingStore = create<BookingState>((set) => ({
...initialState,
setStep: (step) => set({ step }),
selectProfessional: (professional) => set({ professional, step: 'service' }),
selectService: (service) => set({ service, step: 'slot' }),
selectSlot: (slot) => set({ slot, step: 'identify' }),
reset: () => set(initialState),
}));
Rules:
- Zustand stores for UI state only — server data always in React Query.
- S
Content truncated.