Back to Blog
ReactArchitectureTypeScriptEnterpriseState Management

Structuring a Complex React Frontend for a Multi-Module App

Umut Korkmaz2025-08-2013 min

the internal platform is not a typical CRUD application. It is a document workflow platform with multiple workflow modules — each with its own forms, validation rules, approval chains, dashboards, and business logic. Managing this complexity in a single React application without it turning into an unmaintainable mess required deliberate architectural choices from day one.

Here is how I structured a React 19 + TypeScript 5.9 application that serves internal users across many workflow modules, and why I made each decision.

The Module System

Each workflow module (incoming documents, outgoing documents, internal correspondence, leave requests, etc.) is essentially a mini-application. The first instinct might be to use micro-frontends, but for our team size and deployment model, that was overkill. Instead, I built a convention-based module system:

src/
  modules/
    incoming-documents/
      pages/
        IncomingDocumentList.tsx
        IncomingDocumentDetail.tsx
        IncomingDocumentCreate.tsx
      components/
        DocumentMetadataForm.tsx
        WorkflowStepIndicator.tsx
      hooks/
        useIncomingDocuments.ts
        useIncomingDocumentFilters.ts
      services/
        incomingDocumentService.ts
      types/
        incomingDocument.types.ts
      routes.tsx
      index.ts
    outgoing-documents/
      ...same structure...
    leave-requests/
      ...same structure...

Every module follows the same structure. When a new developer joins, they learn the pattern once. When a new module is needed, they copy the structure and start filling in the blanks. No guesswork, no architectural debates.

Route Registration

Each module exports its own routes, and a central router composes them:

typescript
// modules/incoming-documents/routes.tsx
export const incomingDocumentRoutes: RouteObject[] = [
  {
    path: 'incoming-documents',
    element: <ModuleLayout title="Incoming Documents" />,
    children: [
      { index: true, element: <IncomingDocumentList /> },
      { path: 'create', element: <IncomingDocumentCreate /> },
      { path: ':id', element: <IncomingDocumentDetail /> },
      { path: ':id/edit', element: <IncomingDocumentEdit /> },
    ],
  },
];

// router/index.tsx
const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />,
    children: [
      ...incomingDocumentRoutes,
      ...outgoingDocumentRoutes,
      ...leaveRequestRoutes,
      ...internalCorrespondenceRoutes,
      // ...additional modules
    ],
  },
]);

Lazy loading is applied at the module level, not the page level. Each module is a single code-split chunk:

typescript
const IncomingDocuments = lazy(() => import('./modules/incoming-documents'));
const OutgoingDocuments = lazy(() => import('./modules/outgoing-documents'));

This keeps the initial bundle small while avoiding the waterfall effect of lazy-loading every single page.

State Management: The Three-Layer Approach

With many modules, state management can become chaotic fast. I use a deliberate three-layer approach:

Layer 1: Server State with TanStack React Query

All data from the API is managed by React Query. No exceptions. This eliminated an entire category of bugs related to stale data and loading states:

typescript
// hooks/useIncomingDocuments.ts
export function useIncomingDocuments(filters: DocumentFilters) {
  return useQuery({
    queryKey: ['incoming-documents', filters],
    queryFn: () => incomingDocumentService.getList(filters),
    staleTime: 30_000,          // 30 seconds before refetch
    gcTime: 5 * 60 * 1000,     // 5 minutes in garbage collection
    placeholderData: keepPreviousData,  // Smooth pagination
  });
}

Layer 2: Global Client State with Zustand

Cross-module UI state lives in Zustand stores. This includes the active module, sidebar state, user preferences, and notification counts:

typescript
interface AppStore {
  sidebarCollapsed: boolean;
  activeModuleId: string | null;
  breadcrumbs: Breadcrumb[];
  toggleSidebar: () => void;
  setActiveModule: (id: string) => void;
  setBreadcrumbs: (crumbs: Breadcrumb[]) => void;
}

export const useAppStore = create<AppStore>((set) => ({
  sidebarCollapsed: false,
  activeModuleId: null,
  breadcrumbs: [],
  toggleSidebar: () =>
    set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
  setActiveModule: (id) => set({ activeModuleId: id }),
  setBreadcrumbs: (crumbs) => set({ breadcrumbs: crumbs }),
}));

Layer 3: Local Component State with React Context

Module-specific state that does not need to survive navigation stays in React Context scoped to the module:

typescript
// modules/incoming-documents/context/FilterContext.tsx
const FilterContext = createContext<FilterContextType | null>(null);

export function FilterProvider({ children }: { children: ReactNode }) {
  const [filters, setFilters] = useState<DocumentFilters>(defaultFilters);
  const [isFilterPanelOpen, setFilterPanelOpen] = useState(false);

  return (
    <FilterContext.Provider value={{
      filters,
      setFilters,
      isFilterPanelOpen,
      setFilterPanelOpen,
    }}>
      {children}
    </FilterContext.Provider>
  );
}

This three-layer separation means Zustand stores stay small, React Query handles caching, and ephemeral UI state does not pollute global stores.

Form Handling Across Modules

Every workflow module has forms — create forms, edit forms, approval forms, rejection forms. With many modules, form consistency is critical. I standardized on React Hook Form with Yup validation:

typescript
const documentSchema = yup.object({
  title: yup.string()
    .required('Document title is required')
    .max(500, 'Title cannot exceed 500 characters'),
  departmentId: yup.number()
    .required('Department is required')
    .positive('Please select a valid department'),
  urgencyLevel: yup.string()
    .oneOf(['normal', 'urgent', 'critical'])
    .required(),
  attachments: yup.array()
    .of(yup.mixed<File>())
    .min(1, 'At least one attachment is required'),
});

function DocumentCreateForm() {
  const { control, handleSubmit, formState: { errors, isSubmitting } } =
    useForm({
      resolver: yupResolver(documentSchema),
      defaultValues: { urgencyLevel: 'normal', attachments: [] },
    });

  const createMutation = useMutation({
    mutationFn: incomingDocumentService.create,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['incoming-documents']
      });
      navigate('/incoming-documents');
    },
  });

  return (
    <form onSubmit={handleSubmit((data) => createMutation.mutate(data))}>
      <WFTextField name="title" control={control} label="Title" />
      <WFDepartmentSelect name="departmentId" control={control} />
      <WFUrgencySelect name="urgencyLevel" control={control} />
      <WFFileUpload name="attachments" control={control} />
      <WFSubmitButton loading={isSubmitting}>Create</WFSubmitButton>
    </form>
  );
}

The WF prefix components are from our WFace custom component library, which wraps MUI 7 components with React Hook Form integration. Every form field component knows how to read from the form controller, display errors, and handle the MUI styling. This eliminates dozens of lines of boilerplate per form.

Request Deduplication

With many modules making API calls, duplicate requests are a real problem. Two modules might request the same department list simultaneously. React Query handles this naturally through query key deduplication:

typescript
// Both of these components will share a single API call
function ModuleA() {
  const { data } = useQuery({
    queryKey: ['departments'],
    queryFn: departmentService.getAll
  });
  // ...
}

function ModuleB() {
  const { data } = useQuery({
    queryKey: ['departments'],
    queryFn: departmentService.getAll
  });
  // ...
}

For non-React-Query requests (like file uploads or mutation side effects), I built a request deduplication layer in the API client:

typescript
const pendingRequests = new Map<string, Promise<any>>();

async function deduplicatedGet<T>(url: string): Promise<T> {
  if (pendingRequests.has(url)) {
    return pendingRequests.get(url)!;
  }

  const promise = apiClient.get<T>(url).then(res => res.data);
  pendingRequests.set(url, promise);

  try {
    return await promise;
  } finally {
    pendingRequests.delete(url);
  }
}

Internationalization

the internal platform supports Turkish and English via i18next. Each module has its own translation namespace to avoid key collisions:

typescript
// modules/incoming-documents/i18n/tr.json
{
  "incomingDocuments": {
    "pageTitle": "Gelen Evraklar",
    "createNew": "Yeni Evrak Oluştur",
    "filters": {
      "dateRange": "Tarih Aralığı",
      "department": "Birim"
    }
  }
}

Testing Strategy

With a broad automated test suite across the application, we focus tests where they matter most: business logic and data transformations. UI rendering tests are minimal — they break constantly with styling changes and provide little value. Instead:

  • Unit tests for utility functions, validators, and data transformations
  • Integration tests for React Query hooks with MSW (Mock Service Worker)
  • Component tests for complex interactive components (workflow builders, multi-step forms)

Key Architecture Principles

After maintaining this codebase for over a year, here are the principles that keep it manageable:

  1. Convention over configuration. Every module looks the same. Consistency beats cleverness.
  2. Three layers of state. Server state (React Query), global client state (Zustand), local state (Context). No overlap.
  3. Lazy load at module boundaries. Not every page, not every component — just the modules.
  4. Shared components in a library, module components stay local. If it is used in two or more modules, it goes in WFace. Otherwise, it stays in the module.
  5. Tests follow the same structure as code. Every module has a __tests__ folder that mirrors the source structure.