DEV Community

Ram Ji Tripathi
Ram Ji Tripathi

Posted on Originally published at blog.ramjwork.in

Frontend System Design: How I Would Architect a Production Dashboard in React

React dashboards rarely become difficult because of JSX. They become difficult when ownership gets unclear: who owns server data, session state, URL state, transient UI, permissions, mutations, and failures?

I use the React frontend of my AI Support Assistant as a reference. It has authentication, a dashboard, tickets, conversations, and AI reply suggestions. The code shows a useful set of boundaries and also where those boundaries need more definition. This article distinguishes what the application implements today from what I would change as it grows. The title describes a design goal, not a claim about production traffic or measured scale.

Start with boundaries, not components

Before choosing component sizes, I decide where navigation, identity, data access, and interaction state belong. In the current app, main.tsx mounts shared providers and the router. The router selects pages, AppLayout provides the authenticated shell, feature hooks connect UI to domain queries and mutations, and feature API modules use a shared Axios client.

For ticket reads, the current flow is:

Router -> Page -> Feature hook -> TanStack Query
       -> Feature API -> Shared Axios client -> Backend
Enter fullscreen mode Exit fullscreen mode

This keeps request construction and authentication headers out of the page. It does not remove every dependency: dashboard metrics build on ticket hooks, and message UI reuses ticket error helpers. Those are manageable now, but I would watch cross-feature imports as the application expands.

The repository already groups auth, tickets, messages, and AI by feature. Dashboard hooks and components are also grouped, though the dashboard page remains in src/pages. The active router is src/routes/AppRouter.tsx; an src/app/router.tsx file is empty. I would consolidate those entry points before adding more folders.

State ownership is the central decision

The current frontend keeps tickets and messages in TanStack Query, identity in auth context, ticket identity in the route, and editable form fields in component state. Each has a different authority and lifetime. Treating them as one undifferentiated global store would make synchronization harder to reason about.

State Owner and persistence Synchronization Common mistake
Local UI Component or nearest feature coordinator; usually mounted lifetime Props and callbacks between collaborators Put drafts and field errors in a global store by default
Server TanStack Query; current query-client lifetime Query keys, confirmed cache writes, invalidation/refetch Copy fetched records into local state and maintain two versions
Session/auth AuthProvider owns user/loading; token currently lives in localStorage Bootstrap, login, logout, and future identity transitions Turn AuthContext into a catch-all domain store
URL Router owns route identity; list search params are a future choice Parse navigation into canonical query inputs Keep shareable filters only in component state
Shared client Add a narrow provider/store only for a real cross-feature need Explicit subscribers and actions Introduce Redux because the app has multiple pages
Server data  -> TanStack Query
Session      -> AuthProvider
URL          -> Router / search parameters
Transient UI -> Component / feature coordinator
Shared state -> Only for a demonstrated cross-feature need
Enter fullscreen mode Exit fullscreen mode

I promote a value only when another feature truly needs it, it must survive navigation, or there is a clear owner beyond its current component. If two neighboring components need a draft, their coordinator may be enough. Persistence also has several meanings: a value may survive a render, a route change, a reload, or a new login. Those are separate requirements, not reasons to put everything into the same store. Dashboard counts are derived from fetched tickets rather than stored as a second editable copy; the trade-off is that the chosen dataset limits the accuracy of those counts.

The same test helps with filters. A dropdown selection being shared by two components does not automatically make it application state. If the user expects to copy the URL, return with Back, or reload and see the same result, the committed filter belongs in navigation state. If it is simply a temporary draft before pressing Apply, local state is a better fit. I would avoid synchronizing two writable copies of the same filter because one will eventually lag the other.

Routing, shell, and access boundaries

Current implementation: /, /login, and /register are public. Dashboard, ticket list, create, and detail routes are nested under ProtectedRoute and AppLayout. The guard waits for auth bootstrap, redirects when there is no user, and otherwise renders its outlet. It checks identity, not role. The create page separately redirects non-customers; ticket detail controls check roles before showing status management or AI replies.

The shell owns navigation, the user name, logout, and the nested page outlet. Pages own workflows and data. I would keep domain queries out of the shell so every authenticated route does not inherit unrelated fetching.

How I would evolve it: make route requirements explicit as route count grows, preserve a requested destination through sign-in, and provide a deliberate unknown-route page. The active router has no wildcard; the test helper does. This is a small example of how test composition can drift from the application.

Authentication establishes identity; it does not authorize each resource action. The current AuthProvider owns user and loading state. If a stored token exists, it fetches /auth/me; any bootstrap error clears the token and user. The essential current behavior is:

      try {
        const currentUser = await fetchCurrentUser();
        setUser(currentUser);
      } catch {
        storage.removeToken();
        setUser(null);
      } finally {
        setIsLoading(false);
      }
Enter fullscreen mode Exit fullscreen mode

Login and registration persist the returned token, set the user in context, and navigate to the dashboard. Logout clears the token and user. The broad bootstrap catch treats a temporary network failure like an invalid session. I would distinguish those outcomes and define what happens to in-flight requests during logout or account switching.

The UI hides or shows actions by role, but that is presentation. The inspected backend routes restrict ticket creation to customers and status changes to agents/admins. Ticket access is scoped by customer ownership or agent assignment, and the service rechecks access for cached tickets. The AI suggestion route also has an agent/admin gate. These checks show the authority boundary; they are not a full security audit. For a larger UI, I would centralize capability predicates to keep repeated presentation rules consistent, while leaving authorization decisions on the server.

Server state, cache consistency, and the API boundary

The current TanStack Query defaults are one retry, a one-minute stale time, and no refetch on window focus:

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      staleTime: 1000 * 60,
      refetchOnWindowFocus: false,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Ticket keys distinguish lists and details; message keys distinguish conversations by ticket id. Detail and message queries require a nonempty id. Feature API modules unwrap response data and return domain values. Stale time is a freshness policy; it does not make remote changes arrive immediately.

There is a meaningful session boundary to improve: the ticket and message keys do not include account identity, and logout does not clear the shared query cache. A later login in the same app lifetime can encounter cached data under the same keys. This is a code-level isolation risk, not a reported incident. I would scope protected queries to identity or tenant and clear or cancel them on session transitions, then test account switching with populated data.

Mutations make cache consistency explicit. Ticket creation invalidates ticket lists. Status updates patch the confirmed response into an existing detail cache, then invalidate list and detail queries. Message creation appends the server-confirmed response:

    onSuccess: (newMessage) => {
      queryClient.setQueryData<Message[]>(
        messageKeys.ticket(ticketId),
        (existing) => (existing ? [...existing, newMessage] : [newMessage]),
      );
    },
Enter fullscreen mode Exit fullscreen mode

This is not an optimistic update: the write occurs after success. If the conversation was not cached, the code seeds it with only the new message. The hook does not invalidate ticket detail or lists. Whether those views need refresh depends on what sending changes. As more views depend on a mutation, I would document affected queries and define reconciliation for incomplete caches or concurrent writers. I would add optimistic behavior only when its temporary identity, rollback, deduplication, and reconciliation costs are justified.

All inspected feature APIs use one Axios instance. It sets the API base URL, JSON headers, a ten-second timeout, and a request interceptor that adds the stored Bearer token. The AI suggestion call overrides the timeout to sixty seconds. The response interceptor removes the token on 401 and redirects to /login, except for /auth/me bootstrap failures:

    if (error.response?.status === 401) {
      storage.removeToken();

      const requestUrl = error.config?.url ?? "";
      const isSessionBootstrapRequest = requestUrl.includes("/auth/me");

      if (!isSessionBootstrapRequest) {
        window.location.href = "/login";
      }
    }
Enter fullscreen mode Exit fullscreen mode

That broad redirect also applies to a failed login request, while the login page has its own invalid-credentials message. I would assign session expiry and transport classification to an explicit session boundary, with feature-specific recovery messages. That boundary should not flatten every response into “try again”: invalid credentials, forbidden access, missing resources, offline failures, and server errors ask for different next steps. The API client can normalize transport details, while feature code still decides what recovery makes sense in context.

Typed response annotations do not validate payloads at runtime; runtime checks are a future option if contract drift warrants them. The localStorage token choice also needs to be evaluated against the deployment threat model rather than treated as proof of comprehensive security.

The ticket, message, and AI reply workflow

Ticket detail brings together three feature concerns: the ticket query, a message thread, and an AI suggestion. Server-owned ticket and message data stay in Query; the composer draft and coordination state stay local.

Current implementation: generating a suggestion is an explicit mutation with pending, error, empty, and success states. Regenerate calls the mutation again; retry resets its error first. “Use Reply” places suggestion text into the composer. It does not send it. The user can review, edit, and submit separately. The handoff is:

                  onUseReply={(content) => {
                    setComposerDraft(content);
                    setComposerKey((current) => current + 1);
                  }}
Enter fullscreen mode Exit fullscreen mode

Incrementing the key remounts the composer with the supplied initial text. That also resets its local state, so it replaces any existing edits. I would make draft replacement an explicit interaction contract, including a confirmation when preserving unsent text matters.

On message submit, the composer validates and trims the draft, disables input while the request is pending, and clears the field only after success. On failure it keeps the text and shows an error. The returned message is appended to the query cache. The detail page’s messageRefreshToken name might imply a refetch, but the thread uses it only as a scroll-effect dependency. The current workflow is request/response plus local cache updates; no subscription or polling mechanism was found for remote conversation changes.

This example shows why state ownership and feature boundaries meet at the page: the page coordinates a draft handoff, the feature hooks own data operations, Query owns server data, and the shared client carries authentication. That is enough coordination for the current flow, but draft lifetime and cross-view freshness need explicit rules as collaboration grows.

Where the design would become fragile

Several limitations are useful signals for future design, not evidence of production failures:

  • Account-independent keys and retained cache: protected records can remain cached across logout. Scope or clear that cache when identity changes.
  • Sampled metrics: the dashboard requests page one with a limit of 100, derives counts from that array, and warns at 100 that results may be truncated. Those values are not a complete aggregate. I would use a server aggregate contract and a separate bounded recent-ticket query.
  • No list controls: ticket types and API accept filters and pagination, but the screen calls useTickets() without parameters and exposes no pagination controls. I would make shareable filters and pages URL state when users need reload, sharing, or browser history.
  • Loading and refresh semantics: list/detail/thread have loading, error, empty, and retry states. An error branch can replace content even if stale data exists. I would distinguish initial failure from background refresh failure and preserve usable content when appropriate.
  • Feature coupling: message UI reuses ticket error wording, and dashboard derives from ticket hooks. I would keep transport errors shared while leaving domain messages with their owning feature.

A common design smell is duplicating server records in Redux or another global store while TanStack Query already owns them. Another is making AuthContext a general-purpose store for tickets, filters, and drafts. Neither is necessary here. Likewise, React-only permission checks, scattered HTTP clients, or component-only filters that users expect to bookmark are future risks to guard against, not current claims about every part of this app.

A mutation can succeed while one view remains stale even when another view updates correctly. Invalidating a list does not automatically define what happens to its detail page, dashboard aggregate, or sort order. The reverse mistake is invalidating everything and triggering needless network work. I would start by naming the views affected by the server operation, then choose a narrow cache patch, invalidation, or refetch for each. If a workflow has delayed server-side processing, the response to the initial mutation may not contain the final state; the client needs a refresh policy that matches that lifecycle.

Loading, error, and empty are not interchangeable placeholders. An empty list means the request succeeded and returned no records. A failed request means the interface does not know whether records exist. A background refresh can fail while previously fetched data is still useful. Treating those cases separately preserves context and makes retry behavior clearer. The current screens cover initial loading, error, empty, and content; retaining stale content through a failed background refresh is a possible evolution, not behavior I am claiming today.

Proposed architecture for a larger dashboard

I would keep the existing feature organization and sharpen its boundaries. This is proposed architecture, not the repository’s current tree:

src/
  app/       providers, router, shell
  features/  auth, tickets, messages, ai, dashboard
  shared/    api transport, UI primitives, config
Enter fullscreen mode Exit fullscreen mode
App -> Router -> Shell -> Feature page/coordinator
                          |-> local interaction state
                          |-> feature hook -> Query -> API -> Backend
AuthProvider -> session identity and bootstrap
URL          -> ticket id and proposed list parameters
Enter fullscreen mode Exit fullscreen mode

The import direction matters more than the folder names. Features should own their queries, keys, API functions, and domain UI. Shared code should not import ticket policy. A page or feature coordinator should make cross-feature orchestration visible. The URL should become the canonical owner of committed search, filters, and pagination when those values should survive reloads or be shared; an uncommitted search draft can remain local.

For larger systems, I would keep list browsing separate from dashboard aggregates, define cache scope around identity, and write down each mutation’s affected views. Those choices address actual pressure points in the reference app rather than introducing a state library or folder structure for appearance.

Performance, testing, and observability

Currently implemented: query caching and freshness defaults, parameterized keys, a bounded first-page dashboard request, component-local form state, and useMemo for dashboard metric calculation and recent-ticket slicing. The memoization is present; no measured speedup is established. Ticket rows and message arrays are mapped normally. Routes are eagerly imported; no route code splitting or list virtualization was found.

I would address performance in this order: avoid unnecessary requests and payloads; cache with deliberate freshness; bound lists through pagination; place state so edits do not update unrelated consumers; inspect rendering scope; then measure whether virtualization or route splitting is justified. For this app, the dashboard’s fixed 100-item request is a bound, but it is also the reason its derived counts are approximate. The ticket list itself has no controls to request a smaller page. Before tuning React renders, I would resolve the data contract and give users a way to navigate a bounded result set.

Memoization follows evidence of repeated expensive work. It cannot fix a wrong aggregate or an oversized response. Likewise, code splitting only helps if the delivered route bundle or initial load is a demonstrated cost, and virtualization only helps if a large rendered collection is the bottleneck. Each adds behavior and loading edges that need their own verification.

Current tests: the frontend uses Vitest, React Testing Library, and MSW. Test sources cover routes, permission-sensitive UI, form validation, query states, mutations, auth interceptors, AI draft use, and accessibility regressions. Some helpers use real auth while many use mocked identity. I reviewed the test sources and configuration, not a fresh test run or coverage report; configured thresholds are not measured coverage. No browser e2e suite was found in the inspected inventory, and the accessibility tests do not establish complete accessibility.

What I would add: exercise auth bootstrap failures and account switching with cached data; verify permission-sensitive actions, loading/error/empty states, query/mutation behavior, routing, and critical forms in browser journeys. I found no frontend telemetry integration in the inspected source. I would add sanitized error reporting, API failure context, performance monitoring, and user-impact signals while excluding tokens and message contents.

Decisions I would keep explicit

Concern Current approach Larger-system direction Reason
Session/cache Context, localStorage token, shared query client Identity-scoped cache and explicit transition cleanup Define protected data lifetime
Lists/metrics Plain list; first-100 dashboard sample URL pagination and aggregate endpoint Separate browsing from complete totals
Mutations Invalidation or confirmed append Affected-view and reconciliation policy Keep related views consistent
Drafts Local fields; suggestion remount Explicit replacement and ticket lifecycle Preserve user intent
Performance Cache, bounded dashboard request, some memoization Measure payload/render scope before splitting Spend effort on observed costs
Quality jsdom/MSW regression tests Session/cache cases and browser journeys Exercise boundary failures
Observability No integration found Sanitized errors and user-impact metrics Diagnose failures users encounter

My practical rule is simple: give each value an owner, each mutation a consistency policy, each permission an authoritative server check, and each asynchronous boundary a recovery path. The reference frontend has the core pieces; making cache lifetimes, URL state, and interaction transitions explicit is how I would evolve it as the product grows.


Originally published on my engineering blog: https://blog.ramjwork.in/frontend/react-dashboard-system-design

Top comments (0)