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| ApprovedReviewers:{{REVIEWERS}}Alem Bašić (CEO)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 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.
| Layer | Scope | |
|---|---|---|
|
/api/auth/me |
|
|
NEXT_PUBLIC_FF_* env vars) |
|
useState useEffect fetch |
||
| Form state | |
|
|
||
| Navigation | Next.js useRouter / usePathname |
Framework-provided |
| Auth token (web) | httpOnly cookie | Server-set, never in JS |
| Auth token (mobile) | Bearer token in-memory (let token) + AsyncStorage |
Module-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:
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:
- On mount: fetches
GET /api/auth/mewithcredentials: "include"(httpOnly cookie auth) - If 401 and
redirectIfUnauthenticatedis true: redirects to/login logout(): callsPOST /api/auth/logout, clears cookie server-side, redirects to/loginrefreshUser(): re-fetches/api/auth/meto 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— readsuser.bankAccounts(no separate fetch)profile/page.tsx— readsuser.firstName,user.lastName,user.email
3.2 Feature Flags — useFeatureFlag() Hook
File: src/lib/feature-flags.ts
Available Flags:
3.2 Client State (UI State)
Library: {{Zustand}}
Store slices:
|
|
|
|
|
|
|
|
|
|
|
|
cardPin |
false |
cards page (change PIN) |
spendingLimits |
false |
cards page (spending limits) |
notifications |
true |
notification features |
merchantDashboard |
true |
merchant 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;
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 queriesPagination (page, pageSize)Sort column and directionActive tab / view modeModal 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:
dashboard/page.tsx— fetchesURLSearchParamsGET /api/transactions?limit=10transactions/page.tsx+—routerfetchesuseSearchParamsGET /api/transactions?type={filter}&limit=50send/page.tsxhook—Serialization helper:fetchessrc/lib/url-state.tsGET /api/recipientsand
GET /api/ratesnotifications/page.tsx— fetchesGET /api/notificationsprofile/notifications/page.tsx— fetchesGET /api/settingsprofile/language/page.tsx— fetchesGET /api/settingsmerchant/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/dashboardpageSize: Number(params.get('pageSize') ?? 25)/api/merchants/transactions,search:/api/merchants/qrcards/page.tsx??—'',fetchessort:GET(params.get('sort') ?? 'createdAt', dir:/api/cardsparams.get('dir')feature-flagged)
3.4 Form State — useState Per Field (Pattern 3)
Library: pages {{React Hook Form v7}}Validation:use {{Zod}}
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 storeSchema validation lives in—src/schemas/login/page.tsxreusedPOSTfor API validation/api/auth/loginComplexregister/page.tsxmulti-step—formsPOSTuse/api/auth/registerform(4-step:contextinfo,+OTP,StepperPIN,componentsuccess)send/page.tsx— POST/api/transactions/remittancescan/page.tsx— POST/api/transactions/qr-paymentcomplaints/page.tsx— POST/api/complaintswithdrawal/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).
| Endpoint | Method | Called From |
|---|---|---|
/api/auth/login |
POST | login page |
/api/auth/logout |
POST | useAuth hook, profile page |
/api/auth/me |
GET | useAuth hook (every protected page mount) |
/api/auth/register |
POST | register page |
/api/transactions |
GET | dashboard, transactions pages |
/api/transactions/remittance |
POST | send page |
/api/transactions/qr-payment |
POST | scan page |
/api/recipients |
GET | send page |
/api/rates |
GET | send page |
/api/notifications |
GET | notifications page |
/api/notifications |
PATCH | notifications page (mark read) |
/api/settings |
GET | profile/notifications, profile/language |
/api/settings |
PATCH | profile/notifications, profile/language |
/api/complaints |
POST | complaints page |
/api/consents |
POST | cookie-consent component |
/api/cards |
GET/POST | cards page (feature-flagged) |
/api/cards/{id} |
PATCH | cards page (freeze/unfreeze, feature-flagged) |
/api/cards/{id} |
DELETE | cards page (delete card, feature-flagged) |
/api/merchants/dashboard |
GET | merchant page |
/api/merchants/transactions |
GET | merchant page |
/api/merchants/qr |
GET | merchant 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:
RefreshControlonFlatListcomponents - No global state library — same philosophy as web
6. Persistent State
| Data | Storage | ||
|---|---|---|---|
| httpOnly cookie | Server-set, 7-day lifetime, never in JS | ||
| Auth token (mobile) | Bearer token, AsyncStorage |
7-day lifetime, device-bound | |
| Cookie preferences | localStorage |
||
| |||
| Language preference | Server-side () |
||
prefs |
Server-/api/settings) |
||
|
RULE: Auth tokens NEVER in localStorage.localStorage. HttpOnlyhttpOnly cookies only.
Zustandweb, persistence example:
(import { persist } from 'zustand/middleware'; export const usePrefsStore = create( persist<PrefsState>(AsyncStorageset) => ({ 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
| |
| |
| |
|
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 30sShow "reconnecting" banner in UI after 5s disconnectBatch 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 |
|---|---|---|
| Dev only | ||
__DEV__ |
Dev only | |
/api/auth/me |
Dev only | |
| Dev only |
Setup:
//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
useEffecthas explicit dependency arrays useStateupdates 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/meBADon every page mount —subscribesensurestofreshentireuserstore,data- Page data re-
rendersfetches onanyeverychange const { sidebarOpen, theme } = useUIStore(); // GOODmount —subscribenotostaleonlycachewhatissues - Trade-off:
componentmoreneedsnetworkconstrequests,sidebarOpenbut=alwaysuseUIStore((s)fresh=>datas.sidebarOpen);forconstfinancialthemeapp=accuracy
8.2Future MemoizationOptimization RulesPath (if needed)
| ||
| ||
|
Rule: Do NOT pre-emptively memoize. Profile first, optimize second.
8.3 Query Deduplication
TanStack Query automaticallyfor deduplicatescaching identicalwith queriesstale-while-revalidate
rendered
TODO: Run React Profilerre-fetches on criticaleach pathsvisit)
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | John (AI Director) | 2026-02-23 | |
| Frontend Lead | |||
| Tech Lead |