Skip to main content

RFC 1007: Advanced Frontend Patterns

  • Status: Partially Implemented
  • Date: 2026-01-15
  • Authors: arolariu
  • Related Components: sites/arolariu.ro/src/stores/, sites/arolariu.ro/src/lib/actions/, sites/arolariu.ro/src/app/domains/invoices/_contexts/

Abstract

This RFC documents advanced frontend patterns implemented in the arolariu.ro Next.js application that go beyond basic React patterns. These patterns provide type-safe, reusable abstractions for common concerns: entity state management with persistence, type-safe server action results, and modal dialog state management. Some patterns are production-adopted, while others are available as utilities pending broader migration.


1. Motivation

1.1 Problem Statement

As the application grew, several recurring patterns emerged that required standardization:

  1. Entity Store Duplication: Multiple stores (invoices, merchants, scans) still have similar CRUD logic and can be migrated to a shared factory
  2. Server Action Error Handling: Inconsistent error handling across server actions
  3. Dialog Management Complexity: Modal dialogs needed mode/payload support beyond simple open/close

1.2 Design Goals

  • DRY Principle: Eliminate code duplication through generic abstractions
  • Type Safety: Leverage TypeScript generics for compile-time validation
  • Consistency: Enforce uniform patterns across the codebase
  • Developer Experience: Provide clear, well-documented APIs

2. Generic Entity Store Factory (Available, Partial Adoption)

2.1 Overview

The createEntityStore<E> factory function provides a reusable way to create Zustand stores with IndexedDB persistence. It is implemented and tested, but the production invoice/merchant/scan stores are currently still hand-rolled.

Location: sites/arolariu.ro/src/stores/createEntityStore.ts

2.2 Architecture

+-------------------+ +------------------+ +---------------+
| Component | --> | Entity Store | --> | IndexedDB |
| (useStore) | | (Zustand) | | (Dexie) |
+-------------------+ +------------------+ +---------------+
^
|
+---------------+
| DevTools |
| (Dev only) |
+---------------+

2.3 Core Types

/**
* Base entity interface requiring an id field.
*/
export interface BaseEntity {
readonly id: string;
}

/**
* Persisted state (saved to IndexedDB).
*/
export interface EntityPersistedState<E extends BaseEntity> {
readonly entities: ReadonlyArray<E>;
}

/**
* Full state including in-memory only fields.
*/
export interface EntityState<E extends BaseEntity> extends EntityPersistedState<E> {
selectedEntities: E[];
hasHydrated: boolean;
}

/**
* Actions available on all entity stores.
*/
export interface EntityActions<E extends BaseEntity> {
setEntities: (entities: ReadonlyArray<E>) => void;
setSelectedEntities: (selectedEntities: E[]) => void;
upsertEntity: (entity: E) => void;
removeEntity: (entityId: string) => void;
updateEntity: (entityId: string, updates: Partial<E>) => void;
toggleEntitySelection: (entity: E) => void;
clearSelectedEntities: () => void;
clearEntities: () => void;
getEntityById: (entityId: string) => E | undefined;
setHasHydrated: (hasHydrated: boolean) => void;
}

/**
* Combined store type.
*/
export type EntityStore<E extends BaseEntity> = EntityState<E> & EntityActions<E>;

2.4 Usage Pattern

// Example usage for future migrations (not yet used by current invoice/merchant/scan stores)
// 1. Define entity type
interface Invoice extends BaseEntity {
name: string;
createdAt: Date;
totalAmount: number;
}

// 2. Create store with configuration
export const useInvoicesStore = createEntityStore<Invoice>({
tableName: "invoices", // IndexedDB table name
storeName: "InvoicesStore", // DevTools display name
persistName: "invoices-store", // Persist middleware key
});

// 3. Use in components
function InvoicesList() {
const {entities, upsertEntity, hasHydrated} = useInvoicesStore(
useShallow((state) => ({
entities: state.entities,
upsertEntity: state.upsertEntity,
hasHydrated: state.hasHydrated,
}))
);

if (!hasHydrated) return <Loading />;

return (
<ul>
{entities.map((invoice) => (
<li key={invoice.id}>{invoice.name}</li>
))}
</ul>
);
}

2.5 Key Features

FeatureDescription
IndexedDB PersistenceEntities survive page refresh via Dexie storage adapter
Hydration TrackinghasHydrated flag prevents flash of empty content
DevTools IntegrationFull Redux DevTools support in development
Selection ManagementBuilt-in multi-select with toggle/clear
Upsert OperationSingle method handles both insert and update
Partial UpdatesupdateEntity accepts partial entity data

2.6 Current Adoption Status

  • createEntityStore utility: implemented (sites/arolariu.ro/src/stores/createEntityStore.ts)
  • Utility tests: implemented (createEntityStore.test.ts)
  • useInvoicesStore, useMerchantsStore, useScansStore: currently hand-rolled stores

3. Server Action Result Pattern

3.1 Overview

A discriminated union type that provides type-safe error handling for server actions, ensuring consistent error structures across the application.

Location: sites/arolariu.ro/src/lib/utils.server.ts

3.2 Type Definitions

/**
* Standardized error codes for server actions.
*/
export type ServerActionErrorCode =
| "NETWORK_ERROR"
| "TIMEOUT_ERROR"
| "AUTH_ERROR"
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "SERVER_ERROR"
| "UNKNOWN_ERROR";

/**
* Discriminated union result type.
* Either success with data, or failure with error details.
*/
export type ServerActionResult<T> =
| {success: true; data: T}
| {success: false; error: {code: ServerActionErrorCode; message: string; status?: number}};

3.3 Helper Functions

/**
* Maps HTTP status codes to error codes.
*/
export function mapHttpStatusToErrorCode(status: number): ServerActionErrorCode {
if (status === 401 || status === 403) return "AUTH_ERROR";
if (status === 404) return "NOT_FOUND";
if (status === 400 || status === 422) return "VALIDATION_ERROR";
if (status >= 500) return "SERVER_ERROR";
return "UNKNOWN_ERROR";
}

/**
* Creates error result from caught exception.
*/
export function createErrorResult<T>(error: unknown, defaultMessage: string): ServerActionResult<T> {
if (error instanceof Error) {
const isTimeout = error.message.includes("timed out");
return {
success: false,
error: {
code: isTimeout ? "TIMEOUT_ERROR" : "NETWORK_ERROR",
message: error.message,
},
};
}
return {
success: false,
error: {
code: "UNKNOWN_ERROR",
message: defaultMessage,
},
};
}

3.4 Server Action Pattern

"use server";

import {createErrorResult, fetchWithTimeout, mapHttpStatusToErrorCode, type ServerActionResult} from "@/lib/utils.server";

type ServerActionOutputType = Promise<ServerActionResult<ReadonlyArray<Invoice>>>;

export default async function fetchInvoices(): ServerActionOutputType {
return withSpan("api.actions.invoices.fetchInvoices", async () => {
try {
const {userJwt} = await fetchBFFUserFromAuthService();

const response = await fetchWithTimeout(`${API_URL}/invoices/`, {
headers: {Authorization: `Bearer ${userJwt}`},
});

if (response.ok) {
const data = await response.json() as Invoice[];
return {success: true, data};
}

return {
success: false,
error: {
code: mapHttpStatusToErrorCode(response.status),
message: `Failed to fetch invoices: ${response.statusText}`,
status: response.status,
},
};
} catch (error) {
return createErrorResult<ReadonlyArray<Invoice>>(error, "Failed to fetch invoices");
}
});
}

3.5 Client-Side Consumption

"use client";

import fetchInvoices from "@/lib/actions/invoices/fetchInvoices";

export function useInvoices() {
const [isError, setIsError] = useState(false);
const {invoices, setInvoices, hasHydrated} = useInvoicesStore(useShallow(...));

useEffect(() => {
const fetchData = async () => {
const result = await fetchInvoices();

if (result.success) {
setInvoices([...result.data]);
} else {
console.error(`[${result.error.code}] ${result.error.message}`);
setIsError(true);
}
};
fetchData();
}, [setInvoices]);

return {invoices, isLoading: !hasHydrated, isError};
}

3.6 Benefits

BenefitDescription
Type-Safe Error HandlingTypeScript narrows type after result.success check
Consistent Error StructureAll errors have code, message, and optional status
Meaningful Error CodesSemantic codes enable proper UI error messaging
OpenTelemetry IntegrationAll actions wrapped in spans for tracing
Timeout Protection30-second default timeout prevents hanging requests

4. Dialog Context with Mode and Payload

4.1 Overview

A context-based dialog management system that supports multiple dialog types with operation modes and data payloads, preventing multiple simultaneous dialogs.

Location: sites/arolariu.ro/src/app/domains/invoices/_contexts/DialogContext.tsx

4.2 Type Definitions

/**
* Dialog type enumeration - exhaustive list of dialog identifiers.
*/
export type DialogType = Readonly<
| "EDIT_INVOICE__ANALYSIS"
| "EDIT_INVOICE__IMAGE"
| "EDIT_INVOICE__SCAN"
| "EDIT_INVOICE__MERCHANT"
| "EDIT_INVOICE__MERCHANT_INVOICES"
| "EDIT_INVOICE__RECIPE"
| "EDIT_INVOICE__METADATA"
| "EDIT_INVOICE__ITEMS"
| "EDIT_INVOICE__FEEDBACK"
| "VIEW_INVOICE__SHARE_ANALYTICS"
| "VIEW_INVOICES__IMPORT"
| "VIEW_INVOICES__EXPORT"
| "SHARED__INVOICE_DELETE"
| "SHARED__INVOICE_SHARE"
> | null;

/**
* Operation mode for the dialog.
*/
export type DialogMode = Readonly<"view" | "add" | "edit" | "delete" | "share"> | null;

/**
* Payload data for the dialog (entity ID, entity data, etc.).
*/
export type DialogPayload = unknown;

/**
* Current dialog state structure.
*/
type DialogCurrent = {
type: DialogType;
mode: DialogMode;
payload: DialogPayload;
};

4.3 Context API

interface DialogContextValue {
currentDialog: DialogCurrent;
isOpen: (dialog: DialogType) => boolean;
openDialog: (dialog: DialogType, mode?: DialogMode, payload?: DialogPayload) => void;
closeDialog: () => void;
}

4.4 Provider Implementation

export function DialogProvider({children}: {children: ReactNode}) {
const [dialogState, setDialogState] = useState<DialogCurrent>({
type: null,
mode: null,
payload: null,
});

const currentDialog = useRef<DialogCurrent>({
type: null,
mode: null,
payload: null,
});

const isOpen = useCallback(
(dialog: DialogType) => currentDialog.current.type === dialog,
[]
);

const openDialog = useCallback(
(dialog: DialogType, mode: DialogMode = "view", payload: DialogPayload = null) => {
// Only open if no dialog is currently open (prevents stacking)
if (currentDialog.current.type === null) {
currentDialog.current = {type: dialog, mode, payload};
setDialogState(currentDialog.current);
}
},
[]
);

const closeDialog = useCallback(() => {
currentDialog.current = {type: null, mode: null, payload: null};
setDialogState(currentDialog.current);
}, []);

const value = useMemo(
() => ({currentDialog: currentDialog.current, isOpen, openDialog, closeDialog}),
[dialogState] // Re-create when state changes
);

return <DialogContext value={value}>{children}</DialogContext>;
}

4.5 Usage Pattern

// 1. Wrap components with provider
<DialogProvider>
<InvoiceEditor />
<DialogContainer />
</DialogProvider>

// 2. Use in components
function InvoiceEditor() {
const {openDialog} = useDialogs();

return (
<Button onClick={() => openDialog("EDIT_INVOICE__MERCHANT", "edit", {merchantId: "123"})}>
Edit Merchant
</Button>
);
}

// 3. Render dialogs conditionally
function DialogContainer() {
const {currentDialog, closeDialog} = useDialogs();

if (currentDialog.type === "EDIT_INVOICE__MERCHANT") {
return (
<MerchantDialog
mode={currentDialog.mode}
merchantId={currentDialog.payload?.merchantId}
onClose={closeDialog}
/>
);
}

return null;
}

4.6 Key Features

FeatureDescription
Single Dialog ConstraintopenDialog no-ops if dialog already open
Mode-Aware DialogsSame dialog can behave differently in view/edit/delete modes
Payload SupportPass entity IDs, data, or configuration to dialogs
Ref + State HybridRef for immediate reads, state for React re-renders
Memoized ContextPrevents unnecessary re-renders of consuming components

5. Integration Example

5.1 Complete Flow: Edit Invoice Merchant

// 1. User clicks edit merchant button
const {openDialog} = useDialogs();
openDialog("EDIT_INVOICE__MERCHANT", "edit", {invoiceId: invoice.id});

// 2. DialogContainer renders MerchantEditDialog
function MerchantEditDialog({mode, payload, onClose}) {
const {entities: merchants} = useMerchantsStore(useShallow(...));

const handleSave = async (merchantData) => {
const result = await updateMerchant(merchantData);

if (result.success) {
// Update local store immediately
upsertMerchant(result.data);
onClose();
} else {
toast.error(result.error.message);
}
};

return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<MerchantForm onSubmit={handleSave} />
</DialogContent>
</Dialog>
);
}

// 3. Server action handles the update
async function updateMerchant(data): ServerActionOutputType {
// Returns ServerActionResult<Merchant>
}

// 4. Store updates and UI re-renders

6. Testing Strategies

6.1 Entity Store Testing

import {renderHook, act} from "@testing-library/react";
import {useInvoicesStore} from "./invoicesStore";

describe("useInvoicesStore", () => {
beforeEach(() => {
// Reset store state between tests
useInvoicesStore.getState().clearEntities();
});

it("upserts new entity", () => {
const {result} = renderHook(() => useInvoicesStore());

act(() => {
result.current.upsertEntity({id: "1", name: "Test"});
});

expect(result.current.entities).toHaveLength(1);
expect(result.current.entities[0].name).toBe("Test");
});

it("updates existing entity", () => {
const {result} = renderHook(() => useInvoicesStore());

act(() => {
result.current.upsertEntity({id: "1", name: "Original"});
result.current.upsertEntity({id: "1", name: "Updated"});
});

expect(result.current.entities).toHaveLength(1);
expect(result.current.entities[0].name).toBe("Updated");
});
});

6.2 Server Action Testing

import fetchInvoices from "./fetchInvoices";

describe("fetchInvoices", () => {
it("returns success result on 200", async () => {
// Mock fetch to return success
const result = await fetchInvoices();

expect(result.success).toBe(true);
if (result.success) {
expect(Array.isArray(result.data)).toBe(true);
}
});

it("returns error result on 401", async () => {
// Mock fetch to return 401
const result = await fetchInvoices();

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("AUTH_ERROR");
}
});
});

6.3 Dialog Context Testing

import {renderHook, act} from "@testing-library/react";
import {DialogProvider, useDialogs} from "./DialogContext";

describe("DialogContext", () => {
const wrapper = ({children}) => <DialogProvider>{children}</DialogProvider>;

it("opens dialog with mode and payload", () => {
const {result} = renderHook(() => useDialogs(), {wrapper});

act(() => {
result.current.openDialog("EDIT_INVOICE__MERCHANT", "edit", {id: "123"});
});

expect(result.current.currentDialog.type).toBe("EDIT_INVOICE__MERCHANT");
expect(result.current.currentDialog.mode).toBe("edit");
expect(result.current.currentDialog.payload).toEqual({id: "123"});
});

it("prevents opening second dialog", () => {
const {result} = renderHook(() => useDialogs(), {wrapper});

act(() => {
result.current.openDialog("EDIT_INVOICE__MERCHANT", "edit");
result.current.openDialog("EDIT_INVOICE__IMAGE", "view"); // Should no-op
});

expect(result.current.currentDialog.type).toBe("EDIT_INVOICE__MERCHANT");
});
});

7. Best Practices

7.1 Entity Store Usage

  • Do use useShallow to prevent unnecessary re-renders
  • Do check hasHydrated before displaying content
  • Do use upsertEntity instead of manual add/update logic
  • Don't store derived data - compute it in components

7.2 Server Actions

  • Do wrap all API calls in OpenTelemetry spans
  • Do use fetchWithTimeout for resilience
  • Do map HTTP status codes to semantic error codes
  • Don't throw exceptions - return error results

7.3 Dialog Management

  • Do use semantic dialog type names (feature__action format)
  • Do pass minimal payload (IDs over full objects)
  • Do centralize dialog rendering in DialogContainer
  • Don't open dialogs from within dialogs

8. References

  • RFC 1005: State Management (Zustand) - Foundation for entity stores
  • RFC 1001: Frontend OpenTelemetry - Tracing integration

8.2 External Documentation

8.3 Internal Resources

  • Entity Store Factory: sites/arolariu.ro/src/stores/createEntityStore.ts
  • Server Utilities: sites/arolariu.ro/src/lib/utils.server.ts
  • Dialog Context: sites/arolariu.ro/src/app/domains/invoices/_contexts/DialogContext.tsx

9. Conclusion

These advanced patterns provide the foundation for scalable, maintainable frontend code:

  • Generic Entity Store Factory: Eliminates ~80% of boilerplate for new entity types
  • Server Action Result Pattern: Ensures type-safe, consistent error handling
  • Dialog Context with Mode/Payload: Enables complex modal workflows

The server action result and dialog context patterns are production-tested across active domains. The entity store factory is implemented and validated through tests, and is ready for incremental adoption in production stores.


Document Version: 1.0 Last Updated: 2026-01-15 Status: Partially Implemented

// was this page useful?