Skip to main content

State Management

State Management Architecture

Project: {{PROJECT_NAME}}Drop — Fintech Payment App Version: {{VERSION}}0.1.0 Date: {{DATE}}2026-02-23 Author: {{AUTHOR}}John (AI Director, ALAI) Status: Draft | In Review | Approved Reviewers: {{REVIEWERS}}Alem Bašić (CEO)

Document History

Version Date Author Changes
0.1 {{DATE}}2026-02-23 {{AUTHOR}}John Initial draft from source code analysis

1. State Architecture Overview

{{PROJECT_NAME}}Drop uses a layereddeliberately simple, lightweight state management approach:approach. There are no global state libraries (Redux, Zustand, Jotai, etc.). All state is local component state.

preferences,tokens
Layer LibraryMechanism Scope
ServerAuth / user state {{TanStackuseAuth() Querycustom / SWR / Apollo}}hook APIPer-component data,(fetches caching,/api/auth/me synchronizationon each mount)
ClientFeature / UI stateflags {{ZustanduseFeatureFlag() / Redux Toolkit / Pinia}}hook Application-widePer-component UI(reads stateNEXT_PUBLIC_FF_* env vars)
URLPage state/ API data NativeuseState router+ APIsuseEffect fetch Filters, pagination, searchComponent-local
Form state {{ReactuseState Hookper Form / Formik / VeeValidate}}field Form data, validationComponent-local
PersistentUI state (modals, tabs, steps) {{localStorage / cookies}}useState UserComponent-local
NavigationNext.js useRouter / usePathnameFramework-provided
Auth token (web)httpOnly cookieServer-set, never in JS
Auth token (mobile)Bearer token in-memory (let token) + AsyncStorageModule-level in lib/api.js

Guiding principle: ServerKeep it simple. No global state is= NOTno storedaccidental inshared clientstate state.bugs. API data livesis infetched fresh on each page mount. User authentication is the query cache — client state holds only UIcross-cutting concernsconcern, (sidebarhandled open,by selectedthe theme,useAuth() modal visibility).hook.


2. Data Flow Diagram

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

    Component -->|"APIAuth call"check (every mount)"| QueryCache[AuthHook["QueryuseAuth() Cache\n(TanStackHook\nGET Query)"/api/auth/me"]
    Component -->|"UIData action"fetch (useEffect)"| ClientStore[LocalState["ClientuseState Store\n(Zustand)"]+ ComponentuseEffect\nLocal -->|"Navigate"|component URLState["URL State\n(Router)"state"]
    Component -->|"Form input"| FormState["FormuseState State\n(RHF)per field\nComponent-local"]
    Component -->|"Navigate"| Router["Next.js Router\nuseRouter / usePathname"]
    Component -->|"Feature check"| FlagHook["useFeatureFlag()\nReads NEXT_PUBLIC_FF_*"]

    QueryCacheAuthHook -->|"cookie auth"| BFF["BFF API Routes\n/api/auth/me"]
    LocalState -->|"fetch /with mutation"credentials"| API["BackendBFF
    API"]
    QueryCacheBFF -->|"cachedUser object + data"| Component

    ClientStoreFlagHook -->|"stateenv slice"var read"| Component
    URLState -->|EnvVars["params"| Component
    FormState -->|"values / errors"| Component

    ClientStore -->|"user prefs"| LocalStorage["localStorage\n(Persistent)process.env\nNEXT_PUBLIC_FF_*"]
    LocalStorage -->|"hydrate on load"| ClientStore

3. State Categories

3.1 ServerAuth State (API Data)useAuth() Hook

Library:File: {{TanStack Query v5}}src/lib/use-auth.ts

Configuration:Interface:

constfunction queryClientuseAuth(redirectIfUnauthenticated?: = new QueryClient({
  defaultOptions:boolean): {
  queries:user: {User staleTime:| 1000null;
  *loading: 60boolean;
  *logout: 5,() => Promise<void>;
  refreshUser: () => Promise<void>;
}
// 5Default: minutesredirectIfUnauthenticated gcTime:= 1000 * 60 * 30,          // 30 minutes garbage collection
      retry: 2,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 0,
    },
  },
});true

QueryUser keyModel:

convention:
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" (httpOnly cookie auth)
  2. If 401 and redirectIfUnauthenticated is true: redirects to /login
  3. logout(): calls POST /api/auth/logout, clears cookie server-side, redirects to /login
  4. refreshUser(): re-fetches /api/auth/me to update user state

Auth flow:

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

Usage patterns:

// HierarchicalStandard keysprotected forpage precise(redirects invalidationif ['users']unauthenticated)
const { user, loading } = useAuth();
if (loading) return <Skeleton />;
// all user queriesis ['users',guaranteed {non-null page:after 1, search: '' }]loading

// paginatedPage that checks auth without redirect
const { user list} ['users',= userId]                  // single user
['users', userId, 'posts']         // user's postsuseAuth(false);

StalePages timeusing perthis resourcehook type:(data source):

  • accounts/page.tsx — reads user.bankAccounts (no separate fetch)
  • profile/page.tsx — reads user.firstName, user.lastName, user.email

3.2 Feature Flags — useFeatureFlag() Hook

File: src/lib/feature-flags.ts

Available Flags:

ResourceFlag Name Stale TimeDefault GCUsed TimeRationale
User profile5 min30 minChanges infrequently
Dashboard stats30 sec5 minNear-realtime
Config / enums1 hour24 hoursRarely changes
Notifications0 (always fresh)2 minTime-sensitive

3.2 Client State (UI State)

Library: {{Zustand}}

Store slices:

SliceFileResponsibilityIn
uiStorevirtualCards src/stores/ui.store.tsfalse Sidebarcards open,page active(gate modal, themeshows "not available" if false)
authStorephysicalCards src/stores/auth.store.tsfalse Currentcards user,page roles,(order sessionphysical tokencard option)
notificationStorecardDetails src/stores/notification.store.tsfalse Toastcards queue,page unread(show countfull card details)
{{featureStore}}cardFreeze src/stores/{{feature}}.store.tsfalse {{Feature-specificcards UIpage state}}(freeze/unfreeze)
cardPinfalsecards page (change PIN)
spendingLimitsfalsecards page (spending limits)
notificationstruenotification features
merchantDashboardtruemerchant page (gate)

SliceEnvironment template:Variable Pattern:

NEXT_PUBLIC_FF_VIRTUAL_CARDS=true
NEXT_PUBLIC_FF_PHYSICAL_CARDS=false
NEXT_PUBLIC_FF_NOTIFICATIONS=true
NEXT_PUBLIC_FF_MERCHANT_DASHBOARD=true

Convention: NEXT_PUBLIC_FF_ + SCREAMING_SNAKE_CASE version of flag name.

API:

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

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

interface UIActions {
  setSidebarOpen:Server-side (open:API boolean)routes =/ middleware)
isEnabled(flagName: string): boolean
getAllFlags(): Record<string, boolean>
void;featureGate(flagName: openModal:string): middleware  // Returns 404 if flag disabled

// Client-side (id:React hooks)
useFeatureFlag(flagName: string): =boolean
useFeatureFlags(): Record<string, boolean>
void;
closeModal:

Usage ()pattern:

=>
// void;Page-level setTheme: (theme: UIState['theme']) => void;
}

exportgate
const useUIStorecardsEnabled = createuseFeatureFlag("virtualCards");
if (!cardsEnabled) return <UIStatediv>Feature ikke tilgjengelig</div>;

// Conditional rendering
const physicalEnabled = useFeatureFlag("physicalCards");
{physicalEnabled && UIActions<OrderPhysicalCard />((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 URLPage StateData — useState + useEffect Fetch (Pattern 1)

Most

URLpages statefetch data in a useEffect on mount. No SWR, React Query, or other caching library 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:used.

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

useEffect(() => {
  fetch("/users?page=2&pageSize=25&search=john&sort=name&dir=asc&status=activeapi/endpoint", { credentials: "include" })
    .then(res => res.json())
    .then(json => setData(json.data))
    .catch(() => {})  // Silent error — empty state
    .finally(() => setLoading(false));
}, []);

Library:Pages using this pattern:

Native
  • dashboard/page.tsx — fetches URLSearchParamsGET /api/transactions?limit=10
  • transactions/page.tsx + routerfetches useSearchParamsGET /api/transactions?type={filter}&limit=50
  • send/page.tsx hook

    Serialization helper:fetches src/lib/url-state.tsGET /api/recipients

    and GET /api/rates
  • notifications/page.tsx — fetches GET /api/notifications
  • profile/notifications/page.tsx — fetches GET /api/settings
  • profile/language/page.tsx — fetches GET /api/settings
  • merchant/page.tsx — fetches // Type-safe URL param parsing with fallbacks export function parseListParams(params: URLSearchParams): ListParams { return { page: Number(params.get('page') ?? 1)api/merchants/dashboard, pageSize: Number(params.get('pageSize') ?? 25)/api/merchants/transactions, search:/api/merchants/qr
  • params.get('search')
  • cards/page.tsx ?? '',fetches sort:GET params.get('sort') ?? 'createdAt', dir:/api/cards (params.get('dir')feature-flagged)
  • as 'asc' | 'desc') ?? 'desc', }; }

3.4 Form State — useState Per Field (Pattern 3)

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

async

Pattern:handlers that POST data and handle success/error states. No form library.

const schema[email, setEmail] = z.object(useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

const handleSubmit = async () => {
  email:setLoading(true);
  z.string(setError("").email('Invalid;
  email')const res = await fetch("/api/auth/login", name:{
    z.string().min(2,method: 'Name"POST",
    mustheaders: be{ at"Content-Type": least"application/json" 2},
    characters'credentials: "include",
    body: JSON.stringify({ email, password }),
  });
  if (res.ok) {
    router.push("/dashboard");
  } else {
    const formdata = useForm<z.infer<typeofawait schema>>({res.json();
    resolver:setError(data.message zodResolver(schema),|| defaultValues:"Noe {gikk email: '', name: ''galt");
  },
  setLoading(false);
});

Rules:Pages using this pattern:

  • Form state NEVER leaks into global store
  • Schema validation lives in src/schemas/login/page.tsxreusedPOST for API validation/api/auth/login
  • Complexregister/page.tsx multi-step formsPOST use/api/auth/register form(4-step: contextinfo, +OTP, StepperPIN, componentsuccess)
  • send/page.tsx — POST /api/transactions/remittance
  • scan/page.tsx — POST /api/transactions/qr-payment
  • complaints/page.tsx — POST /api/complaints
  • withdrawal/page.tsx — POST (withdrawal request)
  • cards/page.tsx — POST/PATCH/DELETE /api/cards/* (feature-flagged)

3.5 Filter-Driven Refetch (Pattern 4)

Transactions and notification pages refetch when filter changes via useEffect dependency.

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

useEffect(() => {
  fetch(`/api/transactions?type=${filter}&limit=50`, { credentials: "include" })
    .then(res => res.json())
    .then(json => setData(json.transactions))
    .catch(() => {})
    .finally(() => setLoading(false));
}, [filter]);

Pages using this pattern:

  • transactions/page.tsx — filters: "all", "remittance", "qr_payment"
  • notifications/page.tsx — auto-marks read via PATCH on mount

4. API Routes (BFF Layer)

All API routes are under src/app/api/. The frontend calls these endpoints via the Next.js BFF (which translates httpOnly cookie auth to Bearer token for the Hono backend).

EndpointMethodCalled From
/api/auth/loginPOSTlogin page
/api/auth/logoutPOSTuseAuth hook, profile page
/api/auth/meGETuseAuth hook (every protected page mount)
/api/auth/registerPOSTregister page
/api/transactionsGETdashboard, transactions pages
/api/transactions/remittancePOSTsend page
/api/transactions/qr-paymentPOSTscan page
/api/recipientsGETsend page
/api/ratesGETsend page
/api/notificationsGETnotifications page
/api/notificationsPATCHnotifications page (mark read)
/api/settingsGETprofile/notifications, profile/language
/api/settingsPATCHprofile/notifications, profile/language
/api/complaintsPOSTcomplaints page
/api/consentsPOSTcookie-consent component
/api/cardsGET/POSTcards page (feature-flagged)
/api/cards/{id}PATCHcards page (freeze/unfreeze, feature-flagged)
/api/cards/{id}DELETEcards page (delete card, feature-flagged)
/api/merchants/dashboardGETmerchant page
/api/merchants/transactionsGETmerchant page
/api/merchants/qrGETmerchant page

5. Mobile State Management

File: src/drop-mobile/lib/api.js

The mobile app uses a different auth mechanism — Bearer token instead of httpOnly cookie.

// Token stored at module level (in-memory)
let token = null;

export const setToken = (t) => { token = t; };
export const getToken = () => token;

// All requests include auth header
const request = async (endpoint, options = {}) => {
  const headers = {
    "Content-Type": "application/json",
    ...(token && { Authorization: `Bearer ${token}` }),
    ...options.headers,
  };
  // ...
};

Token persistence: On login, token stored in AsyncStorage for 7-day lifetime. On app launch, token loaded from AsyncStorage and set via setToken().

Mobile state patterns:

  • All screens use useState (same as web)
  • Auth data read from api.getMe() on dashboard mount
  • Pull-to-refresh: RefreshControl on FlatList components
  • No global state library — same philosophy as web

6. Persistent State

Syncednotificationonpage
Data Storage LibraryEncryptionNotes
ThemeAuth preferencesession (web)httpOnly cookieServer-set, 7-day lifetime, never in JS
Auth token (mobile)Bearer token, AsyncStorage7-day lifetime, device-bound
Cookie preferences localStorage ZustandCookieConsent persist middlewareNo
Sidebar collapsedlocalStorageZustand persist middlewareNocomponent
Language preference Server-side (localStorage/api/settings) Native Noon login
AuthPush token httpOnly cookieprefs Server-setside (/api/settings) YesSynced (TLS)
Refresh tokenhttpOnly cookieServer-setYes (TLS)load

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

on

Zustandweb, persistence example:

import { persist } from 'zustand/middleware';

export const usePrefsStore = create(
  persist<PrefsState>(AsyncStorage (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) => {
    // Rollbackmodule-level) 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

MutationInvalidates
Create user['users'] (list)
Update user['users', userId]
Delete user['users'] (list)
Update user role['users', userId], ['permissions']

5. Real-Time State

Protocol: {{WebSocket | Server-Sent Events | None}} Library: {{Socket.io | native WebSocket | @microsoft/signalr}}

Pattern — WebSocket to Query Cache:

// 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:

  • Reconnect with exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
  • Show "reconnecting" banner in UI after 5s disconnect
  • Batch updates: max 50ms batching window to prevent UI thrashing

TODO: Document specific WebSocket event schema — reference event-schema-documentation.md.


6. Hydration Strategy (SSR ↔ Client)

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

// Server: prefetch 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.mobile.


7. State Debugging Tools

Tool Usage Enabled In
TanStackReact Query DevtoolsDevTools InspectComponent cache,state queries,tree mutationsinspection Dev only
Zustand__DEV__ devtools middlewareflag ReduxEnables DevToolsdemo integrationcredentials on login Dev only
ReactNetwork DevToolstab ComponentInspect state/api/auth/me treeresponse Dev only
ReduxConsole DevTools Extensionlogs IfAPI usingclient Reduxlogs (mobile) Dev only

Setup:

No
//state Devtools enabled only in development
constmanagement devtools = process.env.NODE_ENVnot ===needed 'development'without ?a (awaitglobal import('zustand/middleware')).devtools
  : (f: any) => f;
store.


8. Performance Considerations

No Unnecessary Re-renders

  • Each useEffect has explicit dependency arrays
  • useState updates are batched by React 19
  • No derived state that needs memoization in current implementation

8.1Fresh SelectorData Patternon (Zustand)Navigation

  • useAuth() re-fetches //api/auth/me BADon every page mountsubscribesensures tofresh entireuser store,data
  • Page data re-rendersfetches on anyevery change const { sidebarOpen, theme } = useUIStore(); // GOODmountsubscribeno tostale onlycache whatissues
  • the
  • Trade-off: componentmore needsnetwork constrequests, sidebarOpenbut =always useUIStore((s)fresh =>data s.sidebarOpen);for constfinancial themeapp =accuracy
  • useUIStore((s) => s.theme);

8.2Future MemoizationOptimization RulesPath (if needed)

  • Add
  • SWRor
    Scenario ToolWhen to Use
    Expensive derived datauseMemoOnly if profiling shows 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 automaticallyfor deduplicatescaching identicalwith queriesstale-while-revalidate rendered

  • Persist simultaneously.query Nocache additionalacross worknavigation needed.

    (currently

    TODO: Run React Profilerre-fetches on criticaleach pathsvisit)

  • and
  • Optimistic documentupdates findings.

    for settings toggles (partially implemented in notification settings)

  • Approval

    Role Name Date Signature
    Author John (AI Director) 2026-02-23
    Frontend Lead
    Tech Lead