02 - State Management ​
Why This Matters for Glomopay ​
HR explicitly mentioned "clear idea for state management." They want you to understand trade-offs, not just pick one library. Glomopay builds checkout flows (multi-step client state) and dashboards (server state heavy) — different tools for different jobs.
The #1 Insight: Separate Server State from Client State ​
This is the most important thing to communicate in the interview.
| Server State | Client State | |
|---|---|---|
| What | Data from APIs (transactions, users) | UI state (modals, forms, steps) |
| Tool | TanStack Query / SWR | Context / Zustand / Redux |
| Why separate | Caching, deduplication, background refetch | Synchronous, predictable |
| Example | Payment history list | Current checkout step |
DON'T: Put API data in Redux/Zustand and manually manage loading/error/cache
DO: Use TanStack Query for API data + Zustand/Context for UI stateTool-by-Tool Breakdown ​
1. useState / useReducer (Local State) ​
When: State belongs to one component or a small subtree.
// useState for simple values
const [amount, setAmount] = useState(0);
// useReducer for complex state with multiple transitions
type CheckoutState = {
step: number;
paymentMethod: PaymentMethod | null;
amount: number;
error: string | null;
};
type CheckoutAction =
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'SET_PAYMENT_METHOD'; method: PaymentMethod }
| { type: 'SET_ERROR'; error: string }
| { type: 'RESET' };
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
switch (action.type) {
case 'NEXT_STEP':
return { ...state, step: state.step + 1, error: null };
case 'PREV_STEP':
return { ...state, step: Math.max(0, state.step - 1) };
case 'SET_PAYMENT_METHOD':
return { ...state, paymentMethod: action.method };
case 'SET_ERROR':
return { ...state, error: action.error };
case 'RESET':
return initialCheckoutState;
}
}Trade-off: Simple, no external dependency, but doesn't share state across distant components.
2. React Context ​
When: Shared state for a subtree (theme, auth, checkout flow).
interface CheckoutContextValue {
state: CheckoutState;
dispatch: React.Dispatch<CheckoutAction>;
// Derived values / convenience methods
canGoNext: boolean;
canGoPrev: boolean;
}
const CheckoutContext = createContext<CheckoutContextValue | null>(null);
// Custom hook with guard
function useCheckout(): CheckoutContextValue {
const ctx = useContext(CheckoutContext);
if (!ctx) throw new Error('useCheckout must be used within <CheckoutProvider>');
return ctx;
}
// Provider
function CheckoutProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(checkoutReducer, initialCheckoutState);
const value = useMemo(() => ({
state,
dispatch,
canGoNext: state.step < TOTAL_STEPS - 1,
canGoPrev: state.step > 0,
}), [state]);
return (
<CheckoutContext.Provider value={value}>
{children}
</CheckoutContext.Provider>
);
}Trade-off:
- Pro: No extra dependency, React-native solution, good for scoped state
- Con: ALL consumers re-render on ANY state change (no selectors)
- Mitigation: Split into multiple contexts or use
useMemoon value
When Context re-render is a problem:
// Split contexts to reduce re-renders
const CheckoutStateContext = createContext<CheckoutState>(/*...*/);
const CheckoutDispatchContext = createContext<React.Dispatch<CheckoutAction>>(/*...*/);
// Components that only dispatch won't re-render on state changes
function PayButton() {
const dispatch = useContext(CheckoutDispatchContext); // stable reference
return <button onClick={() => dispatch({ type: 'NEXT_STEP' })}>Pay</button>;
}3. Zustand ​
When: Global client state that needs selectors (to avoid re-renders).
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clear: () => void;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => {
const items = [...state.items, item];
return { items, total: items.reduce((sum, i) => sum + i.price, 0) };
}),
removeItem: (id) =>
set((state) => {
const items = state.items.filter((i) => i.id !== id);
return { items, total: items.reduce((sum, i) => sum + i.price, 0) };
}),
clear: () => set({ items: [], total: 0 }),
}));
// Selector — component only re-renders when `total` changes
function CartTotal() {
const total = useCartStore((state) => state.total);
return <p>Total: {total}</p>;
}Trade-off:
- Pro: Tiny (1KB), selectors prevent re-renders, no provider/boilerplate
- Pro: Works outside React (in utility functions, middleware)
- Con: Less ecosystem than Redux (devtools exist but smaller)
- Con: No built-in middleware pattern (but supports it)
4. Redux Toolkit ​
When: Large, complex apps with many developers. Predictable state with strong devtools.
import { createSlice, configureStore } from '@reduxjs/toolkit';
const paymentSlice = createSlice({
name: 'payment',
initialState: { method: null, status: 'idle' } as PaymentState,
reducers: {
setMethod: (state, action: PayloadAction<PaymentMethod>) => {
state.method = action.payload;
},
reset: () => initialState,
},
extraReducers: (builder) => {
builder
.addCase(processPayment.pending, (state) => { state.status = 'processing'; })
.addCase(processPayment.fulfilled, (state, action) => {
state.status = 'success';
state.transactionId = action.payload.id;
})
.addCase(processPayment.rejected, (state, action) => {
state.status = 'error';
state.error = action.error.message;
});
},
});Trade-off:
- Pro: Strong devtools, time-travel debugging, large ecosystem, predictable
- Con: More boilerplate (even with RTK), heavier bundle
- Con: Overkill for small-medium apps
5. TanStack Query (Server State) ​
When: Any data that comes from an API.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch
function useTransactions(filters: TransactionFilters) {
return useQuery({
queryKey: ['transactions', filters],
queryFn: () => api.getTransactions(filters),
staleTime: 30_000, // data is fresh for 30s
});
}
// Mutate + invalidate
function useCreatePayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createPayment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
},
});
}
// Usage in component
function TransactionList() {
const { data, isLoading, error } = useTransactions({ page: 1 });
if (isLoading) return <Skeleton />;
if (error) return <ErrorDisplay error={error} />;
return <List items={data.transactions} renderItem={TransactionRow} />;
}What it handles for free:
- Caching, deduplication, background refetch
- Loading/error states
- Pagination, infinite scroll
- Optimistic updates
- Retry logic
Decision Framework (Use This in the Interview) ​
Q: "How would you manage state for X feature?"
Step 1: Is it server data? → TanStack Query
Step 2: Is it local to one component? → useState / useReducer
Step 3: Is it shared in a subtree? → Context + useReducer
Step 4: Is it global with many selectors needed? → Zustand
Step 5: Is the app huge with many teams? → Redux ToolkitFor Glomopay Specifically ​
- Checkout flow: Context + useReducer (scoped to checkout, multi-step)
- Dashboard data: TanStack Query (API-heavy, needs caching)
- Global UI state (theme, sidebar): Zustand (simple, global)
- Form state: React Hook Form or just useState (don't overthink it)
Common Interview Questions ​
Q: Why not just put everything in Redux? A: Redux is great for complex client state, but for server state it reinvents what TanStack Query does better (caching, background refetch, deduplication). Mixing both leads to a lot of manual isLoading / isError boilerplate.
Q: When would you use Context vs Zustand? A: Context for scoped state (checkout flow, form wizard) where you want the state to live and die with a component subtree. Zustand for global state that persists across routes and needs selectors to prevent re-renders.
Q: How do you handle re-render issues with Context? A: Split into multiple contexts (state vs dispatch), memoize the value object, or if the problem persists, switch to Zustand which has built-in selectors.