State Management
Drop Frontend — State Management Architecture
CoversProject:auth,{{PROJECT_NAME}}featureVersion:flags,{{VERSION}}dataDate:fetching{{DATE}}patterns,Author:and{{AUTHOR}}client-sideStatus:stateDraftin|src/drop-app/.In Review | Approved Reviewers: {{REVIEWERS}}
Document Authentication — useAuth HookHistory
useAuthFile: 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
On mount: fetchesGET /api/auth/mewithcredentials: "include"If 401 andredirectIfUnauthenticatedis true: redirects to/loginlogout(): callsPOST /api/auth/logout, redirects to/loginrefreshUser(): re-fetches/api/auth/meto 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
| Changes | |||
|---|---|---|---|
0.1 |
{{DATE}} |
||
{{AUTHOR}} |
| Initial ||
| | ||
| | ||
| | ||
| | ||
| | ||
| |
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=10history/page.tsx— fetches/api/transactions?type={filter}&limit=50send/page.tsx— fetches/api/recipientsand/api/ratesmerchant/page.tsx— fetches/api/merchants/dashboard,/api/merchants/transactions,/api/merchants/qrcards/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— readsuser.bankAccountsprofile/page.tsx— readsuser.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/loginonboarding/page.tsx— POST/api/auth/registersend/page.tsx— POST/api/transactions/remittancescan/page.tsx— POST/api/transactions/qr-paymentcards/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:
| Scope | ||
|---|---|---|
|
||
|
||
Native |
||
| Form state | |
|
|
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:
| Resource | Stale Time | GC Time | Rationale |
|---|---|---|---|
| User profile | 5 min | 30 min | Changes infrequently |
sec |
5 min | Near-realtime | |
Config / enums |
24 hours | Rarely changes | |
| Notifications | 0 (always fresh) | 2 min | Time-sensitive |
API3.2 RoutesClient State (fromUI sourceState)
code)
All API routes are underLibrary: src/app/api/{{Zustand}}.
Store frontend calls these endpoints:slices:
|
src/stores/ui.store.ts |
|
|
src/stores/auth.store.ts |
|
|
src/stores/notification.store.ts |
|
|
src/stores/{{feature}}.store.ts |
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
| Data | Storage | Library | Encryption |
|---|---|---|---|
| Theme preference | localStorage |
Zustand persist middleware | No |
| Sidebar collapsed | |
||
| Language preference | |
||
| Auth token | |
||
| Refresh token | |
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
| Mutation | Invalidates | |
|---|---|---|
| Create user | ['users'] (list) |
|
Update user |
||
Delete user |
||
Update user role |
||
| ||
| ||
| ||
|
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:
Reconnectauth-middleware.ts—withJWT/sessionexponentialvalidationbackoff: 1s, 2s, 4s, 8s, 16s, max 30sShowerror-handler.ts—"reconnecting"CentralizedbannererrorinhandlingUI after 5s disconnectBatchvalidation.ts—updates:Requestmaxvalidation50ms 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)
Approach: useAuth{{Dehydrate/Hydrate (TanStack Query) | getServerSideProps | prefetchQuery}}
// 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
| Tool | Usage | Enabled In |
|---|---|---|
| TanStack Query Devtools | Inspect cache, queries, mutations | Dev only |
| Zustand devtools middleware | Redux DevTools integration | Dev only |
| React DevTools | Component state |
Dev only |
| Redux DevTools Extension | If using Redux | Dev 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
| Scenario | Tool | When to Use |
|---|---|---|
| Expensive derived data | |
Only |
| Stable callback refs | useCallback |
Only if passed to memoized child |
| Stable component output | React.memo |
Only 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
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| Frontend Lead | |||
| Tech Lead |