Skip to main content

State Management

Drop Frontend — State Management Architecture

CoversProject: auth,{{PROJECT_NAME}} featureVersion: flags,{{VERSION}} dataDate: fetching{{DATE}} patterns,Author: and{{AUTHOR}} client-sideStatus: stateDraft in| src/drop-app/.In Review | Approved Reviewers: {{REVIEWERS}}


Document

Authentication — useAuth HookHistory

File: src/lib/use-auth.ts

Interface

function useAuth(redirectIfUnauthenticated?: boolean): {
  user: User | null;
  loading: boolean;
  logout: () => Promise<void>;
  refreshUser: () => Promise<void>;
}

Default: redirectIfUnauthenticated = true

User Model

interface User {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  totalBalance: number;
  bankAccounts: BankAccount[];
  kycStatus: string;
}

interface BankAccount {
  id: string;
  bankName: string;
  accountNumber: string;
  balance: number;
  currency: string;
  isPrimary: boolean;
}

Behavior

  1. On mount: fetches GET /api/auth/me with credentials: "include"
  2. If 401 and redirectIfUnauthenticated is true: redirects to /login
  3. logout(): calls POST /api/auth/logout, redirects to /login
  4. refreshUser(): re-fetches /api/auth/me to update user state

Usage Pattern

// Standard protected page
const { user, loading } = useAuth();
if (loading) return <Skeleton />;
// user is guaranteed non-null after loading

// Page that checks auth without redirect
const { user } = useAuth(false);

Auth Flow

Login page → POST /api/auth/login → cookie set → router.push("/dashboard")
Dashboard → useAuth() → GET /api/auth/me → User object
Logout → POST /api/auth/logout → cookie cleared → redirect /login

Feature Flags

File: src/lib/feature-flags.ts

Available Flags

In Initial
Flag NameVersion DefaultDate UsedAuthor Changes
virtualCards0.1 false{{DATE}} cards page (gate)
physicalCards{{AUTHOR}} false cards page (order physical)
cardDetailsfalsecards page (show details)
cardFreezefalsecards page (freeze/unfreeze)
cardPinfalsecards page (change PIN)
spendingLimitsfalsecards page (spending limits)
notificationstruenotification features
merchantDashboardtruemerchant page (gate)draft

Environment Variable Pattern

NEXT_PUBLIC_FF_VIRTUAL_CARDS=true
NEXT_PUBLIC_FF_PHYSICAL_CARDS=false

Convention: NEXT_PUBLIC_FF_ + SCREAMING_SNAKE_CASE version of flag name.

API

// Server-side
isEnabled(flagName: string): boolean
getAllFlags(): Record<string, boolean>
featureGate(flagName: string): middleware  // Returns 404 if flag disabled

// Client-side (React hooks)
useFeatureFlag(flagName: string): boolean
useFeatureFlags(): Record<string, boolean>

Usage Pattern

// Page-level gate (redirects if feature disabled)
const cardsEnabled = useFeatureFlag("virtualCards");
if (!cardsEnabled) return <div>Feature not available</div>;

// Conditional rendering
const physicalEnabled = useFeatureFlag("physicalCards");
{physicalEnabled && <OrderPhysicalCard />}

Data1. FetchingState PatternsArchitecture Overview

Pattern 1: Page-Level Fetch on Mount

Most{{PROJECT_NAME}} pages fetch data inuses a useEffect on mount. No SWR, React Query, or other caching library is used.

const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  fetch("/api/endpoint", { credentials: "include" })
    .then(res => res.json())
    .then(json => setData(json.data))
    .catch(() => {})
    .finally(() => setLoading(false));
}, []);

Pages using this pattern:

  • dashboard/page.tsx — fetches /api/transactions?limit=10
  • history/page.tsx — fetches /api/transactions?type={filter}&limit=50
  • send/page.tsx — fetches /api/recipients and /api/rates
  • merchant/page.tsx — fetches /api/merchants/dashboard, /api/merchants/transactions, /api/merchants/qr
  • cards/page.tsx — fetches /api/cards (FUTURE — feature-flagged)

Pattern 2: User Data from Auth Hook

Some pages rely entirely on the useAuth() hook for their data, with no additional fetches.

Pages using this pattern:

  • accounts/page.tsx — reads user.bankAccounts
  • profile/page.tsx — reads user.firstName, user.lastName, user.email

Pattern 3: Form Submission

Form pages use async handlers that POST data and handle success/error states.

const handleSubmit = async () => {
  setLoading(true);
  const res = await fetch("/api/endpoint", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(formData),
  });
  if (res.ok) { /* success state */ }
  else { /* error state */ }
  setLoading(false);
};

Pages using this pattern:

  • login/page.tsx — POST /api/auth/login
  • onboarding/page.tsx — POST /api/auth/register
  • send/page.tsx — POST /api/transactions/remittance
  • scan/page.tsx — POST /api/transactions/qr-payment
  • cards/page.tsx — POST/PATCH/DELETE /api/cards/* (FUTURE — feature-flagged)

Pattern 4: Filter-Driven Refetch

History page refetches when filter changes via useEffect dependency.

const [filter, setFilter] = useState("all");

useEffect(() => {
  // refetch with new filter
  fetch(`/api/transactions?type=${filter}&limit=50`, ...)
}, [filter]);

Client State Summary

No globallayered state management library (Redux, Zustand, Jotai, etc.) is used. All state is local component state via useState.approach:

State TypeLayer MechanismLibrary Scope
Auth/UserServer state useAuth(){{TanStack Query / SWR / Apollo}} custom hook Per-componentAPI (re-fetchesdata, oncaching, each mount)synchronization
FeatureClient flags/ UI state useFeatureFlag(){{Zustand / Redux Toolkit / Pinia}} hook Per-componentApplication-wide (readsUI env vars)state
PageURL datastate useStateNative +router useEffect fetchAPIs Component-localFilters, pagination, search
Form state useState{{React Hook Form / Formik / VeeValidate}} per field Component-localForm data, validation
UIPersistent state (modals, tabs) useState{{localStorage / cookies}} Component-localUser preferences, tokens

Guiding principle: Server state is NOT stored in client state. API data lives in the query cache — client state holds only UI concerns (sidebar open, selected theme, modal visibility).


2. Data Flow Diagram

flowchart TD
    User["User Interaction"] --> Component["Component"]

    Component -->|"API call"| QueryCache["Query Cache\n(TanStack Query)"]
    Component -->|"UI action"| ClientStore["Client Store\n(Zustand)"]
    Component -->|"Navigate"| URLState["URL State\n(Router)"]
    Component -->|"Form input"| FormState["Form State\n(RHF)"]

    QueryCache -->|"fetch / mutation"| API["Backend API"]
    QueryCache -->|"cached data"| Component
    ClientStore -->|"state slice"| Component
    URLState -->|"params"| Component
    FormState -->|"values / errors"| Component

    ClientStore -->|"user prefs"| LocalStorage["localStorage\n(Persistent)"]
    LocalStorage -->|"hydrate on load"| ClientStore

3. State Categories

3.1 Server State (API Data)

Library: {{TanStack Query v5}}

Configuration:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,        // 5 minutes
      gcTime: 1000 * 60 * 30,          // 30 minutes garbage collection
      retry: 2,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 0,
    },
  },
});

Query key convention:

// Hierarchical keys for precise invalidation
['users']                          // all user queries
['users', { page: 1, search: '' }] // paginated user list
['users', userId]                  // single user
['users', userId, 'posts']         // user's posts

Stale time per resource type:

ResourceStale TimeGC TimeRationale
User profile5 min30 minChanges infrequently
NavigationDashboard stats Next.js30 useRoutersec5 minNear-realtime
Config / usePathnameenums Framework-provided1 hour24 hoursRarely changes
Notifications0 (always fresh)2 minTime-sensitive

API

3.2 RoutesClient State (fromUI sourceState)

code)

All API routes are underLibrary: src/app/api/{{Zustand}}.

The

Store frontend calls these endpoints:slices:

EndpointSlice MethodFile Called FromResponsibility
/api/auth/loginuiStore POSTsrc/stores/ui.store.ts loginSidebar pageopen, active modal, theme
/api/auth/logoutauthStore POSTsrc/stores/auth.store.ts useAuthCurrent hook,user, profileroles, pagesession token
/api/auth/menotificationStore GETsrc/stores/notification.store.ts useAuthToast hookqueue, unread count
/api/auth/register{{featureStore}} POSTsrc/stores/{{feature}}.store.ts onboarding{{Feature-specific pageUI state}}

Slice template:

// src/stores/ui.store.ts
import { create } from 'zustand';

interface UIState {
  sidebarOpen: boolean;
  activeModal: string | null;
  theme: 'light' | 'dark' | 'system';
}

interface UIActions {
  setSidebarOpen: (open: boolean) => void;
  openModal: (id: string) => void;
  closeModal: () => void;
  setTheme: (theme: UIState['theme']) => void;
}

export const useUIStore = create<UIState & UIActions>((set) => ({
  sidebarOpen: true,
  activeModal: null,
  theme: 'system',

  setSidebarOpen: (open) => set({ sidebarOpen: open }),
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
  setTheme: (theme) => set({ theme }),
}));

3.3 URL State

URL state is the source of truth for:

  • Search / filter queries
  • Pagination (page, pageSize)
  • Sort column and direction
  • Active tab / view mode
  • Modal ID (when deep-linkable)

Convention:

/users?page=2&pageSize=25&search=john&sort=name&dir=asc&status=active

Library: Native URLSearchParams + router useSearchParams hook

Serialization helper: src/lib/url-state.ts

// Type-safe URL param parsing with fallbacks
export function parseListParams(params: URLSearchParams): ListParams {
  return {
    page: Number(params.get('page') ?? 1),
    pageSize: Number(params.get('pageSize') ?? 25),
    search: params.get('search') ?? '',
    sort: params.get('sort') ?? 'createdAt',
    dir: (params.get('dir') as 'asc' | 'desc') ?? 'desc',
  };
}

3.4 Form State

Library: {{React Hook Form v7}} Validation: {{Zod}}

Pattern:

const schema = z.object({
  email: z.string().email('Invalid email'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
});

const form = useForm<z.infer<typeof schema>>({
  resolver: zodResolver(schema),
  defaultValues: { email: '', name: '' },
});

Rules:

  • Form state NEVER leaks into global store
  • Schema validation lives in src/schemas/ — reused for API validation
  • Complex multi-step forms use form context + Stepper component

3.5 Persistent State

DataStorageLibraryEncryption
Theme preferencelocalStorageZustand persist middlewareNo
Sidebar collapsed/api/transactionslocalStorage GETZustand persist middleware dashboard, history pagesNo
Language preference/api/transactions/remittancelocalStorage POSTNative send pageNo
Auth token/api/transactions/qr-paymenthttpOnly cookie POSTServer-set scanYes page(TLS)
Refresh token/api/recipientshttpOnly cookie GETServer-set sendYes page(TLS)

RULE: Auth tokens NEVER in localStorage. HttpOnly cookies only.

Zustand persistence example:

import { persist } from 'zustand/middleware';

export const usePrefsStore = create(
  persist<PrefsState>(
    (set) => ({ theme: 'system', /* ... */ }),
    { name: 'user-preferences' }
  )
);

4. Caching Strategy

4.1 Optimistic Updates

// Optimistic update pattern with rollback
const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newData) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['users', userId] });
    // Snapshot current state for rollback
    const snapshot = queryClient.getQueryData(['users', userId]);
    // Optimistically update cache
    queryClient.setQueryData(['users', userId], (old) => ({ ...old, ...newData }));
    return { snapshot };
  },
  onError: (err, vars, context) => {
    // Rollback on error
    queryClient.setQueryData(['users', userId], context?.snapshot);
  },
  onSettled: () => {
    // Always refetch to sync with server
    queryClient.invalidateQueries({ queryKey: ['users', userId] });
  },
});

4.2 Cache Invalidation Rules

['users', ['users'] ['users',
MutationInvalidates
Create user['users'] (list)
/api/ratesUpdate user GETsend pageuserId]
/api/cardsDelete user GET/POSTcards page (FUTURE)list)
/api/cards/{id}Update user role PATCHcardsuserId], page (freeze/unfreeze) (FUTURE)
/api/cards/{id}['permissions']DELETEcards page (FUTURE)
/api/merchants/dashboardGETmerchant page
/api/merchants/transactionsGETmerchant page
/api/merchants/qrGETmerchant page

Middleware5. Real-Time State

File:Protocol: src/middleware.ts{{WebSocket | Server-Sent Events | None}} (Next.jsLibrary: middleware){{Socket.io | native WebSocket | @microsoft/signalr}}

AdditionalPattern middleware modulesWebSocket into Query Cache:

src/lib/middleware/// On incoming WS event, update query cache directly
socket.on('user.updated', (user: User) => {
  queryClient.setQueryData(['users', user.id], user);
  queryClient.invalidateQueries({ queryKey: ['users'], exact: false });
});
:

Connection management:

  • auth-middleware.tsReconnect with JWT/sessionexponential validationbackoff: 1s, 2s, 4s, 8s, 16s, max 30s
  • error-handler.tsShow "reconnecting" Centralizedbanner errorin handlingUI after 5s disconnect
  • validation.tsBatch updates: Requestmax validation50ms batching window to prevent UI thrashing

AuthTODO: isDocument cookie-basedspecific WebSocket event schema — reference event-schema-documentation.md.


6. Hydration Strategy (httpOnly,SSR set byClient)

API routes). The

Approach: useAuth{{Dehydrate/Hydrate (TanStack Query) | getServerSideProps | prefetchQuery}}

hook
// readsServer: authprefetch and dehydrate
export async function getServerSideProps() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });
  return {
    props: { dehydratedState: dehydrate(queryClient) },
  };
}

// Client: hydrate — no refetch until staleTime expires
export default function Page({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <UserList />
    </HydrationBoundary>
  );
}

Rule: Prefetch all critical page data on server. No loading spinners on initial navigation.


7. State Debugging Tools

ToolUsageEnabled In
TanStack Query DevtoolsInspect cache, queries, mutationsDev only
Zustand devtools middlewareRedux DevTools integrationDev only
React DevToolsComponent state viatreeDev only
Redux DevTools ExtensionIf using ReduxDev only

Setup:

// Devtools enabled only in development
const devtools = process.env.NODE_ENV === 'development'
  ? (await import('zustand/middleware')).devtools
  : (f: any) => f;

8. Performance Considerations

8.1 Selector Pattern (Zustand)

// BAD — subscribes to entire store, re-renders on any change
const { sidebarOpen, theme } = useUIStore();

// GOOD — subscribe to only what the component needs
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const theme = useUIStore((s) => s.theme);

8.2 Memoization Rules

endpoint,
ScenarioToolWhen to Use
Expensive derived data/api/auth/meuseMemo Only notif directlyprofiling fromshows cookies.issue
Stable callback refsuseCallbackOnly if passed to memoized child
Stable component outputReact.memoOnly if parent re-renders frequently

Rule: Do NOT pre-emptively memoize. Profile first, optimize second.

8.3 Query Deduplication

TanStack Query automatically deduplicates identical queries rendered simultaneously. No additional work needed.

TODO: Run React Profiler on critical paths and document findings.


Approval

RoleNameDateSignature
Author
Frontend Lead
Tech Lead