Frontend Patterns Skill
Purpose
Build robust frontend applications with proper API integration and state management.
Data Fetching Patterns
TanStack Query (React Query)
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'] });
},
});
}
SWR Pattern
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'),
}
);
}
State Management
Zustand (Recommended for most cases)
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);
Redux Toolkit (Enterprise)
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;
Error Handling
// 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;
}
Optimistic Updates Pattern
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'] });
},
});
}
Unit Test Template
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();
});
});
});
Troubleshooting
| 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 |
Quality Checklist