| name | state-and-data-fetching |
| description | State management and data fetching patterns for React apps migrated from Angular. Use when converting Angular services with RxJS Observables to React hooks (TanStack Query, Context, Redux Toolkit) and axios-based API clients. |
State Management & Data Fetching for Angular→React Migrations
Use this skill when migrating Angular services (RxJS + HttpClient) to React data-fetching and state patterns.
Decision Guide — Which Tool for What?
| What you're migrating | Recommended React approach |
|---|
@Injectable service that calls HttpClient and returns Observable<T> | Pure API function + TanStack Query hook (useQuery/useMutation) |
BehaviorSubject shared across components (auth user, theme) | React Context + useReducer for simple state |
BehaviorSubject-based global store with many slices | Redux Toolkit (createSlice) + react-redux |
| Local UI state (open/closed, form values) | useState / useReducer — do NOT use Context or Redux |
| Derived state from multiple queries | useMemo or query's select option |
| Optimistic updates | useMutation with onMutate + onError rollback |
| Real-time (WebSocket, SSE) | Keep RxJS OR use useEffect + subscription hook OR @tanstack/react-query's streamedQuery |
Golden rules:
- Server state ≠ client state. Data from the API is server state — put it in TanStack Query, not Redux or Context.
- Don't reinvent caching. TanStack Query already handles staleness, refetch, background updates.
- Reach for Redux only if you have complex cross-component client state (multi-step wizards, undo/redo, sync clients).
Pattern 1 — TanStack Query (server state) — DEFAULT
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
Angular Service → API module + Query hooks (three files)
src/services/api-client.ts — shared axios instance
src/services/employees.ts — pure API functions (no React)
src/hooks/useEmployees.ts — TanStack Query hooks wrapping those functions
See the templates directory for ready-to-copy files:
Query Key Hierarchy
Follow this pattern so mutations can invalidate the right keys:
export const employeeKeys = {
all: ['employees'] as const,
lists: () => [...employeeKeys.all, 'list'] as const,
list: (filters: Filters) => [...employeeKeys.lists(), filters] as const,
detail: (id: number) => [...employeeKeys.all, 'detail', id] as const,
};
onSuccess: () => queryClient.invalidateQueries({ queryKey: employeeKeys.all });
RxJS Operators → TanStack Query Options
| RxJS pattern | TanStack Query equivalent |
|---|
retry(3) | useQuery({ retry: 3 }) |
catchError | useQuery({ ..., onError }) (also error in result) |
debounceTime(300) on search | Debounce the input via a hook, then use the debounced value in queryKey |
switchMap cancellation | Query automatically cancels on queryKey change; use the signal parameter |
shareReplay(1) | Automatic — one query per queryKey, deduplicated |
refCount cleanup | Automatic — inactive queries are garbage-collected after gcTime |
interval + switchMap polling | refetchInterval: 5_000 |
Common Recipes
Cancellable fetch with AbortSignal:
export function useEmployeeSearch(term: string) {
return useQuery({
queryKey: ['employees', 'search', term],
queryFn: ({ signal }) =>
apiClient.get(`/employees?q=${encodeURIComponent(term)}`, { signal }).then(r => r.data),
enabled: term.length >= 2,
});
}
Optimistic update:
export function useToggleActive() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) =>
apiClient.patch(`/employees/${id}`, { isActive }),
onMutate: async ({ id, isActive }) => {
await qc.cancelQueries({ queryKey: employeeKeys.detail(id) });
const previous = qc.getQueryData<Employee>(employeeKeys.detail(id));
if (previous) {
qc.setQueryData(employeeKeys.detail(id), { ...previous, isActive });
}
return { previous };
},
onError: (_err, { id }, ctx) => {
if (ctx?.previous) qc.setQueryData(employeeKeys.detail(id), ctx.previous);
},
onSettled: (_data, _err, { id }) => {
qc.invalidateQueries({ queryKey: employeeKeys.detail(id) });
},
});
}
Pattern 2 — React Context (simple shared state)
Use for auth user, theme, feature flags, i18n locale — things that change rarely.
import { createContext, useContext, useReducer, type ReactNode } from 'react';
type User = { id: number; name: string; email: string };
type AuthState =
| { status: 'unauthenticated' }
| { status: 'authenticated'; user: User };
type Action =
| { type: 'LOGIN'; user: User }
| { type: 'LOGOUT' };
function reducer(state: AuthState, action: Action): AuthState {
switch (action.type) {
case 'LOGIN': return { status: 'authenticated', user: action.user };
case 'LOGOUT': return { status: 'unauthenticated' };
}
}
const AuthContext = createContext<{
state: AuthState;
dispatch: React.Dispatch<Action>;
} | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { status: 'unauthenticated' });
return (
<AuthContext.Provider value={{ state, dispatch }}>{children}</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
Angular equivalent:
becomes → AuthContext + useAuth() hook.
Pattern 3 — Redux Toolkit (complex client state)
Use when:
- Many components read/write the same client state (not server data)
- You need Redux DevTools' time-travel debugging
- You already have
@ngrx/store and want the closest analogue
Store Structure
src/store/
├── index.ts # configureStore + typed hooks
├── slices/
│ ├── authSlice.ts # local auth state
│ └── uiSlice.ts # theme, sidebar open, etc.
See redux-store.ts for a complete example.
@ngrx/store → Redux Toolkit Mapping
| @ngrx/store | Redux Toolkit |
|---|
createAction('[Auth] Login', props<{ user: User }>()) | createSlice({ reducers: { login(state, action: PayloadAction<{ user: User }>) { ... } } }) |
createReducer(initialState, on(login, (state, { user }) => ({ ...state, user }))) | Reducers inside createSlice (mutating with Immer OK) |
createSelector(...) | createSelector from @reduxjs/toolkit (re-exported from reselect) |
@Effect() login$ = createEffect(...) | createAsyncThunk OR RTK Query endpoint |
Store service + store.select(selector) | useSelector(selector) typed hook |
store.dispatch(loginAction) | useDispatch()() typed hook |
RxJS Migration Cheat Sheet
Most RxJS usage in an Angular app is API calls — those go to TanStack Query, not to a React equivalent of RxJS.
For the rare cases where RxJS is legitimately used (WebSocket, SSE, complex event streams):
Option A — Keep RxJS. Install rxjs and consume in a custom hook:
import { useEffect, useState } from 'react';
import type { Observable } from 'rxjs';
export function useObservable<T>(obs$: Observable<T>, initial: T): T {
const [value, setValue] = useState<T>(initial);
useEffect(() => {
const sub = obs$.subscribe(setValue);
return () => sub.unsubscribe();
}, [obs$]);
return value;
}
Option B — Replace with native EventTarget / EventSource / WebSocket in a useEffect.
Recommendation: Prefer Option B where possible — reduces bundle size and complexity. Keep RxJS only if the migration cost is high or the domain is inherently stream-heavy.
Forms State
Not covered by TanStack Query, Context, or Redux — use react-hook-form exclusively for form state.
See the angular-to-react-mapping skill's form-example.tsx for the full pattern.
Key idea: useForm({ resolver: zodResolver(schema) }) replaces FormBuilder, FormGroup, FormControl, and Validators — all in one call.
Environment Variables
| Angular | Vite | Next.js |
|---|
environment.apiUrl | import.meta.env.VITE_API_URL | process.env.NEXT_PUBLIC_API_URL |
environment.ts (dev) | .env.development (auto-loaded) | .env.development |
environment.prod.ts (prod) | .env.production | .env.production |
fileReplacements in angular.json | Vite handles via import.meta.env.MODE | Next.js handles via process.env.NODE_ENV |
Only VITE_* / NEXT_PUBLIC_* prefixed vars are exposed to the browser. Server-only secrets stay without a prefix (Next.js) or in the backend (Vite SPA).
Testing State & Data Fetching
Mocking API calls — use MSW (Mock Service Worker), not vi.mock on axios:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/employees', () => HttpResponse.json([])),
http.post('/api/employees', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 42, ...body });
}),
];
Rendering with providers — create a renderWithProviders helper that wraps in QueryClientProvider + MemoryRouter + Provider (Redux) so tests don't have to.
Angular HttpClientTestingModule + HttpTestingController → MSW + Vitest is the equivalent story.