Frontend development and API integration patterns for React, TypeScript, and state management
Provides patterns for React data fetching with TanStack Query/SWR, state management with Zustand/Redux Toolkit, and optimistic updates. Use when building API-integrated React apps requiring robust state handling.
/plugin marketplace add pluginagentmarketplace/custom-plugin-api-design/plugin install custom-plugin-api-design@pluginagentmarketplace-api-designThis skill inherits all available tools. When active, it can use any tool Claude has access to.
assets/config.yamlassets/frontend_config.yamlassets/schema.jsonreferences/FRONTEND_GUIDE.mdreferences/GUIDE.mdreferences/PATTERNS.mdscripts/validate.pyBuild robust frontend applications with proper API integration and state management.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Query configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
refetchOnWindowFocus: false,
},
},
});
// Type-safe API client
const api = {
users: {
list: async (params: { page: number; limit: number }) => {
const res = await fetch(`/api/users?${new URLSearchParams(params as any)}`);
if (!res.ok) throw new ApiError(res);
return res.json() as Promise<PaginatedResponse<User>>;
},
get: async (id: string) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new ApiError(res);
return res.json() as Promise<User>;
},
create: async (data: CreateUserInput) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new ApiError(res);
return res.json() as Promise<User>;
},
},
};
// Query hook with pagination
function useUsers(page: number) {
return useQuery({
queryKey: ['users', 'list', { page }],
queryFn: () => api.users.list({ page, limit: 20 }),
placeholderData: (prev) => prev, // Keep previous data while loading
});
}
// Single user query
function useUser(id: string) {
return useQuery({
queryKey: ['users', 'detail', id],
queryFn: () => api.users.get(id),
enabled: !!id,
});
}
// Mutation with optimistic update
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.users.create,
onMutate: async (newUser) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users', 'list'] });
// Snapshot previous value
const previous = queryClient.getQueryData(['users', 'list']);
// Optimistically update
queryClient.setQueryData(['users', 'list'], (old: any) => ({
...old,
data: [...(old?.data || []), { ...newUser, id: 'temp-id' }],
}));
return { previous };
},
onError: (err, newUser, context) => {
// Rollback on error
queryClient.setQueryData(['users', 'list'], context?.previous);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['users', 'list'] });
},
});
}
import useSWR, { mutate } from 'swr';
import useSWRMutation from 'swr/mutation';
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
};
function useUsers() {
const { data, error, isLoading, isValidating } = useSWR<User[]>(
'/api/users',
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000,
}
);
return {
users: data,
isLoading,
isRefreshing: isValidating && data,
error,
};
}
// SWR Mutation
function useCreateUser() {
return useSWRMutation(
'/api/users',
async (url: string, { arg }: { arg: CreateUserInput }) => {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(arg),
});
return res.json();
},
{
onSuccess: () => mutate('/api/users'),
}
);
}
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
updateUser: (updates: Partial<User>) => void;
}
const useAuthStore = create<AuthState>()(
devtools(
persist(
immer((set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (credentials) => {
const response = await api.auth.login(credentials);
set((state) => {
state.user = response.user;
state.token = response.token;
state.isAuthenticated = true;
});
},
logout: () => {
set((state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
});
},
updateUser: (updates) => {
set((state) => {
if (state.user) {
Object.assign(state.user, updates);
}
});
},
})),
{ name: 'auth-store' }
),
{ name: 'Auth' }
)
);
// Selectors (prevent unnecessary re-renders)
const useUser = () => useAuthStore((state) => state.user);
const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// Async thunk
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async (params: { page: number }, { rejectWithValue }) => {
try {
return await api.users.list(params);
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Slice
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [] as User[],
status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed',
error: null as string | null,
pagination: { page: 1, total: 0 },
},
reducers: {
userAdded: (state, action: PayloadAction<User>) => {
state.items.push(action.payload);
},
userUpdated: (state, action: PayloadAction<User>) => {
const index = state.items.findIndex((u) => u.id === action.payload.id);
if (index !== -1) {
state.items[index] = action.payload;
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload.data;
state.pagination = action.payload.pagination;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});
export const { userAdded, userUpdated } = usersSlice.actions;
export default usersSlice.reducer;
// Custom error class
class ApiError extends Error {
constructor(
public response: Response,
public data?: { type: string; title: string; detail?: string }
) {
super(data?.title || 'API Error');
this.name = 'ApiError';
}
static async fromResponse(response: Response): Promise<ApiError> {
const data = await response.json().catch(() => null);
return new ApiError(response, data);
}
}
// Error boundary component
function QueryErrorBoundary({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="error-container">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
// Hook with error handling
function useUsersSafe(page: number) {
const query = useUsers(page);
useEffect(() => {
if (query.error instanceof ApiError) {
if (query.error.response.status === 401) {
// Redirect to login
router.push('/login');
} else if (query.error.response.status >= 500) {
// Show toast
toast.error('Server error. Please try again later.');
}
}
}, [query.error]);
return query;
}
function useTodoToggle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
api.todos.update(todo.id, { completed: !todo.completed }),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);
return { previous };
},
onError: (err, todo, context) => {
queryClient.setQueryData(['todos'], context?.previous);
toast.error('Failed to update todo');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Frontend Patterns', () => {
describe('useUsers hook', () => {
it('should fetch and return users', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: '1', name: 'Test' }] }),
} as Response);
const { result } = renderHook(() => useUsers(1), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.data).toHaveLength(1);
});
it('should handle errors', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
} as Response);
const { result } = renderHook(() => useUsers(1), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('Zustand store', () => {
it('should update auth state on login', async () => {
const { result } = renderHook(() => useAuthStore());
await result.current.login({ email: 'test@test.com', password: 'pass' });
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBeDefined();
});
it('should clear state on logout', () => {
const { result } = renderHook(() => useAuthStore());
result.current.logout();
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
});
});
| Issue | Cause | Solution |
|---|---|---|
| Infinite refetching | Missing dependency array | Use stable queryKey |
| Stale data shown | staleTime too high | Reduce staleTime or invalidate |
| Memory leak | Unmounted component | Use cleanup in useEffect |
| Too many re-renders | Non-memoized selectors | Use shallow comparison |
| Optimistic rollback fails | Missing previous snapshot | Always capture previous state |
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.