Skip to content

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 StateClient State
WhatData from APIs (transactions, users)UI state (modals, forms, steps)
ToolTanStack Query / SWRContext / Zustand / Redux
Why separateCaching, deduplication, background refetchSynchronous, predictable
ExamplePayment history listCurrent 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 state

Tool-by-Tool Breakdown ​

1. useState / useReducer (Local State) ​

When: State belongs to one component or a small subtree.

tsx
// 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).

tsx
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 useMemo on value

When Context re-render is a problem:

tsx
// 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).

tsx
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.

tsx
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.

tsx
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 Toolkit

For 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.

Frontend interview preparation reference.